diff --git a/.env.example b/.env.example index 1e83047..6910cb0 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ # Backend settings DATABASE_URL=sqlite:///./data/librislog.db -GOOGLE_BOOKS_API_KEY= # REQUIRED for Google Books fallback — without this key Google's API returns 503. - # Create one at https://console.cloud.google.com → APIs & Services → Credentials. +GOOGLE_BOOKS_API_KEY= # optional — see https://codebude.github.io/librislog/guide/api-keys CORS_ORIGINS=["http://localhost", "http://localhost:5173"] LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR | CRITICAL COVERS_DIR=./data/covers # Directory where downloaded cover images are stored locally. -API_KEY_ENCRYPTION_KEY=CHANGE_ME_TO_32PLUS_CHARS +# Generate a real secret: openssl rand -base64 32 +API_KEY_ENCRYPTION_KEY=CHANGE_ME_TO_32PLUS_CHARS # <-- YOU MUST CHANGE THIS AUTH_COOKIE_NAME=librislog_session AUTH_COOKIE_SECURE=false # set true in production (HTTPS) AUTH_COOKIE_SAMESITE=lax # lax | strict | none diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..e41af7f --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,100 @@ +name: Docker Build & Publish + +on: + push: + branches: [develop] + release: + types: [published] + workflow_dispatch: + inputs: + branch: + description: "Branch to build from" + required: false + default: develop + +env: + REGISTRY: ghcr.io + +jobs: + build-and-push: + runs-on: ubuntu-latest + strategy: + matrix: + service: + - name: frontend + image: librislog + dockerfile: ./frontend/Dockerfile + context: ./frontend + - name: backend + image: librislog-api + dockerfile: ./backend/Dockerfile + context: ./backend + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.branch || github.ref }} + + - name: Derive version + id: version + run: | + if [ "${{ github.event_name }}" = "release" ]; then + VERSION="${{ github.event.release.tag_name }}" + else + VERSION="$(git describe --tags --always 2>/dev/null || echo 'v0.0.0-dev')" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "sha_short=$(echo '${{ github.sha }}' | cut -c1-7)" >> "$GITHUB_OUTPUT" + + - name: Sanitize version for Docker tag + id: sanitize + run: | + VERSION="${{ steps.version.outputs.version }}" + SANITIZED="$(echo "$VERSION" | sed 's/[^a-zA-Z0-9_.-]/-/g')" + SANITIZED="$(echo "$SANITIZED" | sed 's/^[^a-zA-Z0-9_]\+//')" + echo "sanitized_tag=${SANITIZED:0:128}" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate image tags + id: tags + run: | + IMAGE="${{ env.REGISTRY }}/${{ github.repository }}/${{ matrix.service.image }}" + SHA_SHORT="${{ steps.version.outputs.sha_short }}" + + if [ "${{ github.event_name }}" = "release" ]; then + SANITIZED="${{ steps.sanitize.outputs.sanitized_tag }}" + TAGS="${IMAGE}:${SANITIZED},${IMAGE}:latest" + else + TAGS="${IMAGE}:develop,${IMAGE}:${SHA_SHORT}" + fi + + echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: ${{ matrix.service.context }} + file: ${{ matrix.service.dockerfile }} + push: true + platforms: linux/amd64 + tags: ${{ steps.tags.outputs.tags }} + build-args: | + APP_VERSION=${{ steps.version.outputs.version }} + GIT_SHA=${{ github.sha }} + ${{ matrix.service.name == 'frontend' && 'PUBLIC_DEFAULT_LOCALE=en' || '' }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..9c73808 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,109 @@ +name: Docs + +on: + push: + branches: [develop] + release: + types: [published] + +concurrency: + group: docs-deploy + cancel-in-progress: true + +env: + NODE_VERSION: "22" + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + path: release + + - uses: actions/checkout@v6 + with: + ref: develop + path: develop + + - name: Determine release ref + id: release-ref + working-directory: release + run: | + if [ "${{ github.event_name }}" = "release" ]; then + REF="${{ github.event.release.tag_name }}" + else + DEFAULT_BRANCH=$(git remote show origin | sed -n '/HEAD branch/s/.*: //p') + REF=$(git tag --list 'v*' --sort=-version:refname | head -1) + REF="${REF:-$DEFAULT_BRANCH}" + fi + echo "ref=${REF}" >> "$GITHUB_OUTPUT" + + - name: Checkout release ref + working-directory: release + run: git checkout "${{ steps.release-ref.outputs.ref }}" + + - name: Check if release ref has docs + id: check-release-docs + working-directory: release + run: | + if [ -f docs/package.json ] && [ -f docs/.vitepress/config.ts ]; then + echo "has_docs=true" >> "$GITHUB_OUTPUT" + else + echo "has_docs=false" >> "$GITHUB_OUTPUT" + fi + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: | + release/docs/package-lock.json + develop/docs/package-lock.json + + - name: Build release docs + if: steps.check-release-docs.outputs.has_docs == 'true' + working-directory: release/docs + run: | + npm ci + npx vitepress build + mv .vitepress/dist /tmp/dist-release + + - name: Build nightly docs + working-directory: develop/docs + run: | + npm ci + cp .vitepress/config.ts .vitepress/config.original.ts + cp .vitepress/config.nightly.ts .vitepress/config.ts + npx vitepress build + mv .vitepress/dist /tmp/dist-nightly + + - name: Prepare full deploy (release + nightly) + if: steps.check-release-docs.outputs.has_docs == 'true' + run: | + mv /tmp/dist-release ./dist + mkdir -p ./dist/next + cp -a /tmp/dist-nightly/. ./dist/next/ + + - name: Prepare nightly-only deploy + if: steps.check-release-docs.outputs.has_docs != 'true' + run: cp -a /tmp/dist-nightly/. ./dist/ + + - uses: peaceiris/actions-gh-pages@v4 + if: steps.check-release-docs.outputs.has_docs == 'true' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist + keep_files: false + + - uses: peaceiris/actions-gh-pages@v4 + if: steps.check-release-docs.outputs.has_docs != 'true' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist + destination_dir: next + keep_files: false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..cb453d1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,279 @@ +name: Tests + +on: + push: + branches: [develop, main] + pull_request: + branches: [develop, main] + workflow_dispatch: + +env: + PYTHON_VERSION: "3.14" + NODE_VERSION: "24" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: uv sync --frozen + + - name: Run tests + run: uv run pytest --junitxml=report.xml --cov=app --cov-report=term-missing + + - name: Upload report + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-report-backend + path: backend/report.xml + + cli: + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: uv sync --frozen + + - name: Run tests + run: uv run pytest --junitxml=report.xml + + - name: Upload report + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-report-cli + path: cli/report.xml + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v6 + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npx vitest run --reporter=junit --reporter=default --outputFile=report.xml + + - name: Upload report + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-report-frontend + path: frontend/report.xml + + e2e: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Prepare E2E directories + run: | + docker run --rm -v ${{ github.workspace }}/data-e2e:/data alpine sh -c 'rm -rf /data/*' 2>/dev/null || true + mkdir -p playwright-report test-results + + - name: Build backend image + uses: docker/build-push-action@v7 + with: + context: ./backend + tags: librislog-e2e-backend + load: true + build-args: | + APP_VERSION=v0.0.0-dev + GIT_SHA=unknown + + - name: Build frontend image + uses: docker/build-push-action@v7 + with: + context: ./frontend + tags: librislog-e2e-frontend + load: true + build-args: | + APP_VERSION=v0.0.0-dev + GIT_SHA=unknown + PUBLIC_DEFAULT_LOCALE=en + + - name: Build test-runner image + uses: docker/build-push-action@v7 + with: + context: ./frontend + file: frontend/Dockerfile.e2e + tags: librislog-e2e-test-runner + load: true + + - name: Run E2E tests + run: docker compose -f ../docker-compose.e2e.yml up --abort-on-container-exit --attach test-runner --no-log-prefix + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: frontend/playwright-report/ + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v7 + with: + name: test-results + path: frontend/test-results/ + include-hidden-files: true + + report: + needs: [backend, cli, frontend, e2e] + if: always() + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + steps: + - uses: actions/download-artifact@v8 + with: + path: artifacts + pattern: "*report*" + + - name: Initialize git for test reporter + run: | + git init + git config user.email "ci@github.com" + git config user.name "CI" + git add -A + git commit --allow-empty -m "ci" + + - name: Publish backend results + id: backend + uses: dorny/test-reporter@v3 + continue-on-error: true + with: + name: Backend Tests (pytest) + path: artifacts/test-report-backend/report.xml + reporter: java-junit + fail-on-error: "false" + + - name: Publish CLI results + id: cli + uses: dorny/test-reporter@v3 + continue-on-error: true + with: + name: CLI Tests (pytest) + path: artifacts/test-report-cli/report.xml + reporter: java-junit + fail-on-error: "false" + + - name: Publish frontend results + id: frontend + uses: dorny/test-reporter@v3 + continue-on-error: true + with: + name: Frontend Tests (vitest) + path: artifacts/test-report-frontend/report.xml + reporter: java-junit + fail-on-error: "false" + + - name: Publish E2E results + id: e2e + uses: dorny/test-reporter@v3 + continue-on-error: true + with: + name: E2E Tests (Playwright) + path: artifacts/playwright-report/report.xml + reporter: java-junit + fail-on-error: "false" + + - name: Post PR comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v9 + env: + backend_passed: ${{ steps.backend.outputs.passed }} + backend_failed: ${{ steps.backend.outputs.failed }} + backend_skipped: ${{ steps.backend.outputs.skipped }} + backend_conclusion: ${{ steps.backend.outputs.conclusion }} + cli_passed: ${{ steps.cli.outputs.passed }} + cli_failed: ${{ steps.cli.outputs.failed }} + cli_skipped: ${{ steps.cli.outputs.skipped }} + cli_conclusion: ${{ steps.cli.outputs.conclusion }} + frontend_passed: ${{ steps.frontend.outputs.passed }} + frontend_failed: ${{ steps.frontend.outputs.failed }} + frontend_skipped: ${{ steps.frontend.outputs.skipped }} + frontend_conclusion: ${{ steps.frontend.outputs.conclusion }} + e2e_passed: ${{ steps.e2e.outputs.passed }} + e2e_failed: ${{ steps.e2e.outputs.failed }} + e2e_skipped: ${{ steps.e2e.outputs.skipped }} + e2e_conclusion: ${{ steps.e2e.outputs.conclusion }} + with: + script: | + function suite(label, prefix) { + const passed = parseInt(process.env[`${prefix}_passed`] || '0'); + const failed = parseInt(process.env[`${prefix}_failed`] || '0'); + const skipped = parseInt(process.env[`${prefix}_skipped`] || '0'); + const conclusion = process.env[`${prefix}_conclusion`] || 'skipped'; + const total = passed + failed + skipped; + const emoji = conclusion === 'success' ? '✅' : conclusion === 'failure' ? '❌' : '⏭️'; + return { passed, failed, skipped, total, line: `${emoji} **${label}** — ${passed} passed, ${failed} failed, ${skipped} skipped (${total} total)` }; + } + + const results = [ + suite('Backend (pytest)', 'backend'), + suite('CLI (pytest)', 'cli'), + suite('Frontend (vitest)', 'frontend'), + suite('E2E (Playwright)', 'e2e'), + ]; + + const sha = context.sha; + const shaLink = `[\`${sha.slice(0, 7)}\`](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/commit/${sha})`; + const totalPassed = results.reduce((s, r) => s + r.passed, 0); + const totalFailed = results.reduce((s, r) => s + r.failed, 0); + const totalSkipped = results.reduce((s, r) => s + r.skipped, 0); + const total = totalPassed + totalFailed + totalSkipped; + + const summary = totalFailed === 0 && totalSkipped === 0 + ? `**Summary:** ✅ All ${total} tests passed` + : `**Summary:** ${totalPassed} ✅ passed` + (totalFailed > 0 ? `, ${totalFailed} ❌ failed` : '') + (totalSkipped > 0 ? `, ${totalSkipped} ⏭️ skipped` : '') + ` (${total} total)`; + + const body = `## 🧪 Test Results — ${shaLink}\n\n` + + results.map(r => r.line).join('\n') + + `\n\n${summary}`; + + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); diff --git a/.gitignore b/.gitignore index 927829d..edabd7d 100644 --- a/.gitignore +++ b/.gitignore @@ -222,8 +222,12 @@ __marimo__/ /ideas.txt /backend/data/ /data/ +/data-e2e/ /backend/data/ !frontend/src/lib cookies.txt profile-snapshot -.plan/ \ No newline at end of file +.plan/ +node_modules/ +/*.png +*-snapshot.md \ No newline at end of file diff --git a/README.md b/README.md index db94057..384d042 100644 --- a/README.md +++ b/README.md @@ -1,235 +1,136 @@ # LibrisLog -**Single-user book tracking webapp** — maintain three reading lists (Want to Read, Currently Reading, Read), import books from Open Library & Google Books, scrape cover art, and manage your collection through a modern Svelte dashboard. - -![Python](https://img.shields.io/badge/python-3.14-%233776AB?logo=python) -[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -![Svelte](https://img.shields.io/badge/svelte-5-%23FF3E00?logo=svelte) -![FastAPI](https://img.shields.io/badge/FastAPI-0.136-%23009688?logo=fastapi) -![License](https://img.shields.io/badge/license-MIT-green) -![Backend tests](https://img.shields.io/badge/tests-628_✔️-2ea44f?logo=pytest) -![Frontend tests](https://img.shields.io/badge/tests-290_✔️-2ea44f?logo=vitest) - - -## AI-Assisted Development Disclaimer - -This project was developed with the assistance of AI coding tools (OpenCode CLI) under the following human-supervised workflow: - -1. **Requirements engineering** — human specifies the feature -2. **Agent drafts** — AI agent generates an initial implementation for larger changes -3. **Plan review** — human reviews and corrects the plan iteratively -4. **Implementation** — agent writes code guided by the approved plan -5. **Code review** — agent runs a separate code-review AI model, reports findings -6. **Human review** — all changes are reviewed and corrected by a human before commit - -No AI-generated code is committed without human review and approval. +

+ 📚 Full Documentation +  ·  + Quick Start +  ·  + API Reference +  ·  + Nightly Docs +

+ +

+ Tests + Docker Build + Docs Build + Python + Svelte + FastAPI + License +

+ +**Multi-user book tracking webapp** — maintain four reading lists, import books from Open Library & Google Books, scrape cover art, and get rich reading analytics — all on your own hardware. + +> `docker compose up -d` → full data ownership, no vendor lock-in, no API keys required. --- -## Quick Start (Docker) +## Quick Start ```bash -cp .env.example .env # review and adjust values -docker compose up --build -d +mkdir librislog && cd librislog +curl -O https://raw.githubusercontent.com/codebude/librislog/main/docker-compose.yml +curl -O https://raw.githubusercontent.com/codebude/librislog/main/.env.example +cp .env.example .env +# generate a random secret key +sed -i "s/CHANGE_ME_TO_32PLUS_CHARS/$(openssl rand -base64 32)/" .env +docker compose up -d ``` -The frontend is available at **http://localhost:8001** and the API at **http://localhost:8000**. Health check: `GET /api/health`. +Open **http://localhost:8001** and create your account. --- -## Configuration - -All configuration is done through the `.env` file in the project root. See `.env.example` for defaults. - -### Core - -| Variable | Default | Description | -|---|---|---| -| `DATABASE_URL` | `sqlite:///./data/librislog.db` | SQLite database path | -| `CORS_ORIGINS` | `["http://localhost", …]` | Allowed CORS origins | -| `LOG_LEVEL` | `INFO` | Python log level | -| `API_KEY_ENCRYPTION_KEY` | — | **Required.** 32+ char secret for API key encryption | -| `FORWARDED_ALLOW_IPS` | `*` | Trusted proxy IPs for forwarded headers. `*` trusts all (recommended behind your own TLS proxy). Set to specific IPs to restrict. | - -### Authentication - -| Variable | Default | Description | -|---|---|---| -| `AUTH_COOKIE_NAME` | `librislog_session` | Session cookie name | -| `AUTH_COOKIE_SECURE` | `false` | Set `true` in production (HTTPS) | -| `AUTH_COOKIE_SAMESITE` | `lax` | `lax` \| `strict` \| `none` | - -### OIDC (optional) - -| Variable | Default | Description | -|---|---|---| -| `OIDC_ENABLED` | `false` | Enable OpenID Connect login | -| `OIDC_CLIENT_ID` | — | OIDC client ID | -| `OIDC_CLIENT_SECRET` | — | OIDC client secret | -| `OIDC_WELL_KNOWN_URL` | — | OIDC discovery URL | - -### Book import +## Screenshots -| Variable | Default | Description | -|---|---|---| -| `GOOGLE_BOOKS_API_KEY` | — | Google Books API key (required for Google fallback) | -| `HARDCOVER_APP_API_TOKEN` | — | Hardcover.app API token (optional source) | +
+ Dashboard + Library + Statistics +
+
+ Reading Progress + Book Import + Administration +
-### Cover scraping - -| Variable | Default | Description | -|---|---|---| -| `COVERS_DIR` | `./data/covers` | Local cover image storage directory | -| `THALIA_COVER_SEARCH_ENABLED` | `false` | Enable Thalia.de cover scraping. **Research-only:** users must ensure compliance with Thalia's ToS. The author assumes no liability for misuse. | - -### Dashboard - -| Variable | Default | Description | -|---|---|---| -| `DASHBOARD_QUOTE_ENABLED` | `true` | Show motivational quote on dashboard | -| `DASHBOARD_QUOTE_URL` | *(spark API)* | Quote API endpoint | -| `DASHBOARD_QUOTE_CACHE_TTL` | `86400` | Quote cache TTL in seconds | +--- -### Frontend (build-time) +## Why LibrisLog? -| Variable | Default | Description | -|---|---|---| -| `PUBLIC_DEFAULT_LOCALE` | `en` | UI default locale: `en` \| `de` | +- **Your data, your rules.** Fully self-hosted under MIT license — no ads, no tracking, no vendor lock-in. A single SQLite file you can back up anytime. +- **No API keys required.** Works with Open Library out of the box. Add Google Books or Hardcover.app tokens optionally for richer search results. +- **Rich insights from day one.** Calendar heatmap, language/status/page distribution charts, books finished per month/year, top authors — all on your hardware. +- **Multi-user from the start.** User roles (admin/user), optional OIDC SSO, per-user libraries. One instance works for your whole household or small group. +- **Import any format you have.** Goodreads CSV with automatic field mapping, generic CSV with per-field Python transforms, JSON, ZIP with covers. +- **Point your phone at an ISBN barcode.** Real-time barcode scanning in the browser — no native app required. +- **Cover art from multiple sources.** Automatic search across AbeBooks, Open Library, Amazon, and Hardcover — plus manual upload or URL paste. +- **Full REST API.** OpenAPI-documented backend you can script against — build your own frontend, connect home automation, or pipe data into your own tools. +- **Lightweight.** Two Docker containers, one SQLite database. +- **Bilingual UI.** English and German with a localization framework ready for more languages. -### Build-time version injection +--- -```bash -export APP_VERSION=$(git describe --tags --always) -export GIT_SHA=$(git rev-parse HEAD) -docker compose up --build -d -``` +## Features -Omitting these vars leaves the fallback `v0.0.0-dev` / `unknown`. Version is shown in the sidebar and exposed on the health endpoint. +- **Library** — Grid/list view, search and sort, four reading statuses (Want to Read, Currently Reading, Read, Did Not Finish) +- **Reading progress** — Page-level slider, full progress timeline per book with edit/history +- **Statistics dashboard** — Calendar heatmap, distribution charts, books finished per period, top authors +- **Book import** — Search Open Library, Google Books, Hardcover.app. Scan ISBN barcodes on mobile. Manual entry for anything not found +- **Data portability** — Export as JSON, CSV, or ZIP with covers. Import from Goodreads or any CSV with custom field mapping +- **Cover management** — Automatic multi-source cover search with manual override, URL paste, or file upload +- **Data hygiene** — Find and fix missing metadata (covers, page counts, authors) in bulk +- **Multi-user** — Admin/user roles, per-user libraries, optional OIDC login +- **Themes** — Light, dark, and custom DaisyUI themes with persistent preferences +- **Administration** — Full backup/restore of the SQLite database, user management, API key management --- -## Development +## API -### Prerequisites +The backend is a standalone FastAPI application. The full API is documented via Swagger UI at `/api/docs` when the server is running. -- Python 3.14+ -- Node.js 26+ — use `nvm use` inside `frontend/` (`.nvmrc` is provided) -- [uv](https://github.com/astral-sh/uv) - -### Backend +Create API keys from the web UI (Profile → API Keys) for headless access. See the [API Reference](https://codebude.github.io/librislog/api/) for details. ```bash cd backend uv sync uv run alembic upgrade head -uv run uvicorn app.main:app --reload # http://localhost:8000 +uv run uvicorn app.main:app --reload ``` -All routes are documented at `/docs` (Swagger UI) when the server is running. - -### Frontend - -```bash -cd frontend -nvm use -npm install -npm run dev # http://localhost:5173 -``` - -The dev server proxies `/api` requests to `http://localhost:8000`. - --- -## Testing - -### Backend (pytest, 628 tests) - -```bash -cd backend -uv run pytest # runs tests with coverage -``` +## Stack -### Frontend (Vitest, 290 tests) +| Layer | Technology | +|---|---| +| **Backend** | FastAPI, SQLModel, SQLite, Alembic, Pydantic v2 | +| **Frontend** | Svelte 5, SvelteKit, Tailwind CSS v4, DaisyUI v5 | +| **Auth** | Session cookies, optional OIDC (Authlib) | +| **Deployment** | Docker, Docker Compose | +| **Package managers** | `uv` (Python), `npm` (Node) | +| **Testing** | pytest + pytest-cov (backend), Vitest + Testing Library (frontend), Playwright (E2E) | -```bash -cd frontend -npm test # runs tests -npm run test:coverage # runs tests with coverage report -``` +--- -### Frontend type-checking (Svelte validation) +## Contributing -```bash -cd frontend -npm run check # runs svelte-check -``` +See the [Developer Setup](https://codebude.github.io/librislog/guide/developer-setup) guide for instructions on running LibrisLog locally, running tests, and using the CLI tool. ---- +This project was developed with the assistance of AI coding tools under a human-supervised workflow. No AI-generated code is committed without human review and approval. -## Project Structure +## License -``` -librislog/ -├── backend/ -│ ├── app/ -│ │ ├── main.py # FastAPI entry point -│ │ ├── config.py # pydantic-settings configuration -│ │ ├── models.py # SQLModel ORM models -│ │ ├── schemas.py # Pydantic request/response schemas -│ │ ├── database.py # DB engine & session dependency -│ │ ├── auth.py # Authentication & session logic -│ │ ├── oidc.py # OpenID Connect integration -│ │ ├── routers/ # API route handlers -│ │ │ ├── books.py # Book CRUD -│ │ │ ├── auth.py # Login/logout -│ │ │ ├── covers.py # Cover upload/import -│ │ │ ├── cover_candidates.py # Auto-search covers by ISBN -│ │ │ ├── data.py # Data export/import -│ │ │ ├── import_.py # Book import (Open Library, Google Books) -│ │ │ ├── progress.py # Reading progress -│ │ │ ├── statistics.py # Dashboard statistics -│ │ │ ├── profile.py # User profile & settings -│ │ │ ├── admin.py # Admin endpoints -│ │ │ ├── users.py # User management -│ │ │ ├── health.py # Health check -│ │ │ └── docs.py # Documentation routes -│ │ └── services/ # Business logic -│ │ ├── book_import.py # Open Library & Google Books search -│ │ ├── cover_import.py # Cover download & processing -│ │ ├── cover_storage.py # Local cover file storage -│ │ ├── data_export.py # Export to JSON/CSV/ZIP -│ │ ├── data_import.py # Import from JSON/CSV -│ │ ├── backup_restore.py # Full DB backup & restore -│ │ ├── tags.py # Tag management -│ │ ├── quote_cache.py # Dashboard quote caching -│ │ ├── isbn_utils.py # ISBN-10/13 conversion -│ │ └── user_deletion.py # Account deletion -│ ├── alembic/ # Database migrations -│ ├── tests/ # 628 pytest tests -│ └── Dockerfile -├── frontend/ -│ ├── src/ -│ │ ├── lib/ -│ │ │ ├── api.ts # Typed fetch wrappers -│ │ │ ├── types.ts # TypeScript interfaces -│ │ │ ├── toasts.ts # Toast notification store -│ │ │ ├── i18n/ # Internationalisation (en, de) -│ │ │ ├── stores/ # Svelte stores (auth, timezone) -│ │ │ ├── components/ # 27 Svelte components (41 files incl. tests) -│ │ │ └── test/ # Test setup & mocks -│ │ └── routes/ # SvelteKit pages -│ └── Dockerfile -├── docker-compose.yml # 2 services: backend + frontend -└── .env.example # All configurable variables -``` +MIT -## Stack +## Star History -| Layer | Technology | -|---|---| -| **Backend** | FastAPI, SQLModel, SQLite, Alembic, Pydantic v2 | -| **Frontend** | Svelte 5, SvelteKit, Tailwind CSS v4, DaisyUI v5 | -| **Auth** | Session cookies, optional OIDC (Authlib) | -| **Reverse proxy** | nginx (embedded in frontend container) | -| **Package managers** | `uv` (Python), `npm` (Node) | -| **Testing** | pytest + pytest-cov (backend), Vitest + Testing Library (frontend) | + + + + + Star History Chart + + diff --git a/backend/alembic/versions/86fa9b4f6d61_nest_import_mapping_config.py b/backend/alembic/versions/86fa9b4f6d61_nest_import_mapping_config.py new file mode 100644 index 0000000..4a2958f --- /dev/null +++ b/backend/alembic/versions/86fa9b4f6d61_nest_import_mapping_config.py @@ -0,0 +1,63 @@ +"""nest_import_mapping_config + +Revision ID: 86fa9b4f6d61 +Revises: c31124664378 +Create Date: 2026-05-25 22:51:25.715478 + +""" +import json +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import table, column + + +# revision identifiers, used by Alembic. +revision: str = '86fa9b4f6d61' +down_revision: Union[str, Sequence[str], None] = 'c31124664378' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +import_mapping = table( + "import_mapping", + column("id", sa.Integer), + column("mapping_json", sa.String), +) + + +def upgrade() -> None: + """Nest flat source strings into ImportFieldConfig objects.""" + conn = op.get_bind() + rows = conn.execute(sa.select(import_mapping.c.id, import_mapping.c.mapping_json)).fetchall() + for row_id, mapping_json in rows: + if not mapping_json: + continue + mapping = json.loads(mapping_json) + nested = { + target: {"source": source, "transform": None} + for target, source in mapping.items() + } + conn.execute( + import_mapping.update().where(import_mapping.c.id == row_id), + {"mapping_json": json.dumps(nested)}, + ) + + +def downgrade() -> None: + """Flatten nested ImportFieldConfig objects back to source strings.""" + conn = op.get_bind() + rows = conn.execute(sa.select(import_mapping.c.id, import_mapping.c.mapping_json)).fetchall() + for row_id, mapping_json in rows: + if not mapping_json: + continue + mapping = json.loads(mapping_json) + flat = { + target: config["source"] + for target, config in mapping.items() + } + conn.execute( + import_mapping.update().where(import_mapping.c.id == row_id), + {"mapping_json": json.dumps(flat)}, + ) diff --git a/backend/alembic/versions/a1b2c3d4e5f6_make_author_page_count_required.py b/backend/alembic/versions/a1b2c3d4e5f6_make_author_page_count_required.py new file mode 100644 index 0000000..6384b6a --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_make_author_page_count_required.py @@ -0,0 +1,43 @@ +"""Make author and page_count required with defaults. + +Revision ID: a1b2c3d4e5f6 +Revises: f4c2b8a1d9e3 +Create Date: 2026-05-24 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = 'a1b2c3d4e5f6' +down_revision = 'bfe919c8b47b' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Set defaults for existing NULLs + op.execute("UPDATE book SET author = '' WHERE author IS NULL") + op.execute("UPDATE book SET page_count = 0 WHERE page_count IS NULL") + + with op.batch_alter_table('book') as batch_op: + batch_op.alter_column('author', + existing_type=sa.String(), + nullable=False, + server_default='') + batch_op.alter_column('page_count', + existing_type=sa.Integer(), + nullable=False, + server_default='0') + + +def downgrade() -> None: + with op.batch_alter_table('book') as batch_op: + batch_op.alter_column('author', + existing_type=sa.String(), + nullable=True, + server_default=None) + batch_op.alter_column('page_count', + existing_type=sa.Integer(), + nullable=True, + server_default=None) diff --git a/backend/alembic/versions/bfe919c8b47b_add_theme_and_custom_theme_to_user_.py b/backend/alembic/versions/bfe919c8b47b_add_theme_and_custom_theme_to_user_.py new file mode 100644 index 0000000..874579e --- /dev/null +++ b/backend/alembic/versions/bfe919c8b47b_add_theme_and_custom_theme_to_user_.py @@ -0,0 +1,34 @@ +"""add theme and custom_theme to user_settings + +Revision ID: bfe919c8b47b +Revises: b5c3d9a2e1f4 +Create Date: 2026-05-22 15:22:58.100122 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bfe919c8b47b' +down_revision: Union[str, Sequence[str], None] = 'b5c3d9a2e1f4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('usersettings', sa.Column('theme', sa.String(length=20), nullable=False, server_default='light')) + op.add_column('usersettings', sa.Column('custom_theme', sa.String(length=30), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('usersettings', 'custom_theme') + op.drop_column('usersettings', 'theme') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/c31124664378_flip_import_mapping_direction.py b/backend/alembic/versions/c31124664378_flip_import_mapping_direction.py new file mode 100644 index 0000000..cf1e7ff --- /dev/null +++ b/backend/alembic/versions/c31124664378_flip_import_mapping_direction.py @@ -0,0 +1,80 @@ +"""flip_import_mapping_direction + +Revision ID: c31124664378 +Revises: a1b2c3d4e5f6 +Create Date: 2026-05-25 18:49:51.798647 + +""" +import logging +from typing import Sequence, Union +import json + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +# revision identifiers, used by Alembic. +revision: str = 'c31124664378' +down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _flip_mapping_json(mapping_json: str, mapping_id: int) -> str: + """Flip mapping from {source: target} to {target: source}.""" + mapping = json.loads(mapping_json) + # Warn if duplicate source→target mappings will lose data + seen: set[str] = set() + for k, v in mapping.items(): + if v in seen: + logger.warning( + "Import mapping %d: multiple sources map to target '%s'. " + "Only the last source ('%s') will be preserved after flip.", + mapping_id, v, k, + ) + seen.add(v) + flipped = {v: k for k, v in mapping.items()} + return json.dumps(flipped) + + +def upgrade() -> None: + """Flip all import mapping directions from source→target to target→source.""" + conn = op.get_bind() + session = Session(conn) + + # Get all import mappings + rows = conn.execute(sa.text("SELECT id, mapping_json FROM import_mapping")).fetchall() + + for row_id, mapping_json in rows: + if not mapping_json: + continue + flipped = _flip_mapping_json(mapping_json, row_id) + conn.execute( + sa.text("UPDATE import_mapping SET mapping_json = :mapping WHERE id = :id"), + {"mapping": flipped, "id": row_id} + ) + + session.commit() + + +def downgrade() -> None: + """Flip all import mapping directions back from target→source to source→target.""" + conn = op.get_bind() + session = Session(conn) + + # Applying the same inversion restores the original format + rows = conn.execute(sa.text("SELECT id, mapping_json FROM import_mapping")).fetchall() + + for row_id, mapping_json in rows: + if not mapping_json: + continue + flipped = _flip_mapping_json(mapping_json, row_id) + conn.execute( + sa.text("UPDATE import_mapping SET mapping_json = :mapping WHERE id = :id"), + {"mapping": flipped, "id": row_id} + ) + + session.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 9f91551..01c2fb1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,9 +10,11 @@ from starlette.middleware.sessions import SessionMiddleware from starlette.responses import Response +from app._build_info import __git_sha__, __version__ from app.config import settings from app.logging_config import configure_logging -from app.routers import admin, auth, books, cover_candidates, covers, data, docs, health, import_, oidc, profile, progress, statistics, users +from app.routers import admin, auth, books, cover_candidates, covers, data, docs, health, hygiene, import_, oidc, profile, progress, statistics, users +from app.services.cover_storage import cleanup_orphan_covers from app.services.data_import import cleanup_temp_files logger = logging.getLogger(__name__) @@ -20,15 +22,21 @@ configure_logging(settings.log_level) -async def _periodic_temp_cleanup(interval_hours: int = 1) -> None: - """Periodically clean up stale temporary import files. +async def _periodic_maintenance(interval_hours: int = 1) -> None: + """Periodically run background maintenance tasks. Runs every *interval_hours* hours. After three consecutive failures the log level escalates from warning to error. + Tasks: + - Clean up stale temporary import files. + - Delete orphaned cover files no longer referenced by any book. + Args: interval_hours: Hours between cleanup cycles. Defaults to 1. """ + from app.database import get_session + loop = asyncio.get_running_loop() failures = 0 while True: @@ -36,13 +44,19 @@ async def _periodic_temp_cleanup(interval_hours: int = 1) -> None: try: await loop.run_in_executor(None, cleanup_temp_files) logger.info("Periodic temp file cleanup completed") + + with next(get_session()) as session: + deleted = await loop.run_in_executor(None, cleanup_orphan_covers, session) + if deleted: + logger.info("Orphaned cover cleanup: deleted %d file(s)", deleted) + failures = 0 except Exception as exc: failures += 1 if failures >= 3: - logger.error("Temp file cleanup failed %d times consecutively: %s", failures, exc) + logger.error("Periodic maintenance failed %d times consecutively: %s", failures, exc) else: - logger.warning("Temp file cleanup failed (%d): %s", failures, exc) + logger.warning("Periodic maintenance failed (%d): %s", failures, exc) @asynccontextmanager @@ -52,18 +66,24 @@ async def lifespan(app: FastAPI): Path(settings.import_temp_dir).mkdir(parents=True, exist_ok=True) cleanup_temp_files() - cleanup_task = asyncio.create_task(_periodic_temp_cleanup()) + maintenance_task = asyncio.create_task(_periodic_maintenance()) yield - cleanup_task.cancel() + maintenance_task.cancel() try: - await cleanup_task + await maintenance_task except asyncio.CancelledError: pass +if __git_sha__ != "unknown" and __version__.find(__git_sha__[:7]) == -1: + _display_version = f"{__version__} ({__git_sha__[:7]})" +else: + _display_version = __version__ + app = FastAPI( title="LibrisLog API", description="Backend API for LibrisLog.", + version=_display_version, lifespan=lifespan, openapi_url="/api/openapi.json", docs_url=None, @@ -121,7 +141,12 @@ async def proxy_headers_middleware(request: Request, call_next) -> Response: if "*" in _TRUSTED_PROXY_IPS or (request.client and request.client.host in _TRUSTED_PROXY_IPS): forwarded_proto = request.headers.get("x-forwarded-proto") if forwarded_proto: + logger.debug("X-Forwarded-Proto=%s — patching scheme to %s", forwarded_proto, forwarded_proto) request.scope["scheme"] = forwarded_proto + else: + logger.debug("No X-Forwarded-Proto header received — keeping scheme=%s", request.scope.get("scheme", "unknown")) + else: + logger.debug("Request not from trusted proxy (client=%s) — skipping X-Forwarded-Proto check", request.client) return await call_next(request) @@ -136,6 +161,7 @@ async def proxy_headers_middleware(request: Request, call_next) -> Response: app.include_router(progress.router) app.include_router(docs.router) app.include_router(health.router) +app.include_router(hygiene.router) app.include_router(statistics.router) app.include_router(data.router) app.include_router(admin.router) diff --git a/backend/app/models.py b/backend/app/models.py index 49f95af..c335aa0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -56,12 +56,12 @@ class Book(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) title: str = Field(index=True) subtitle: Optional[str] = None - author: Optional[str] = Field(default=None, index=True) + author: str = Field(default="", index=True) isbn: Optional[str] = Field(default=None, unique=True) cover_url: Optional[str] = None publisher: Optional[str] = None published_year: Optional[int] = None - page_count: Optional[int] = None + page_count: int = Field(default=0) language: Optional[str] = Field(default=None, max_length=2) notes: Optional[str] = None blurb: Optional[str] = None @@ -126,12 +126,14 @@ class User(SQLModel, table=True): class UserSettings(SQLModel, table=True): - """Per-user settings such as language and timezone.""" + """Per-user settings such as language, timezone, and theme.""" id: Optional[int] = Field(default=None, primary_key=True) user_id: int = Field(foreign_key="user.id", unique=True, index=True) language: str = Field(default="en", max_length=10) timezone: str = Field(default="UTC", max_length=64) + theme: str = Field(default="light", max_length=20) + custom_theme: Optional[str] = Field(default=None, max_length=30) class ApiKey(SQLModel, table=True): diff --git a/backend/app/routers/books.py b/backend/app/routers/books.py index 5c45d12..7409720 100644 --- a/backend/app/routers/books.py +++ b/backend/app/routers/books.py @@ -14,6 +14,7 @@ from app.models import Book, BookTag, ReadingProgress, ReadingStatus, Tag, User from app.schemas import ( BookCreate, + BookListResponse, BookRead, BookUpdate, DashboardQuote, @@ -80,7 +81,7 @@ def _validate_dates(data: dict) -> None: if val.tzinfo is None: val = val.replace(tzinfo=timezone.utc) if val > now: - raise HTTPException(status_code=422, detail="error.dateInFuture") + raise HTTPException(status_code=422, detail="Date cannot be in the future.") ds = data.get("date_started") df = data.get("date_finished") if ds is not None and df is not None and ds.tzinfo is None: @@ -88,7 +89,7 @@ def _validate_dates(data: dict) -> None: if df is not None and df.tzinfo is None: df = df.replace(tzinfo=timezone.utc) if ds is not None and df is not None and ds > df: - raise HTTPException(status_code=422, detail="error.dateStartedAfterFinished") + raise HTTPException(status_code=422, detail="Start date cannot be after finish date.") def _validate_date_finished_for_read( @@ -104,7 +105,7 @@ def _validate_date_finished_for_read( if book.date_finished is None: return if book.reading_status == ReadingStatus.read and target_status == ReadingStatus.read: - raise HTTPException(status_code=422, detail="error.dateFinishedRequiredForRead") + raise HTTPException(status_code=422, detail="A finished book must have an end date. Change the status if you want to remove the finish date.") def _normalize_language(language: str | None) -> str | None: @@ -115,7 +116,7 @@ def _normalize_language(language: str | None) -> str | None: if not normalized: return None if len(normalized) != 2 or not normalized.isalpha(): - raise HTTPException(status_code=422, detail="error.invalidLanguageCode") + raise HTTPException(status_code=422, detail="Language must be a 2-letter ISO code (for example: EN, DE, FR).") return normalized @@ -123,11 +124,11 @@ def _raise_integrity_conflict(exc: IntegrityError) -> None: """Convert ISBN unique-constraint violations to HTTP 409.""" message = str(exc.orig).lower() if exc.orig else str(exc).lower() if "book.isbn" in message and "unique" in message: - raise HTTPException(status_code=409, detail="error.isbnAlreadyExists") from exc + raise HTTPException(status_code=409, detail="This ISBN is already used by another book.") from exc raise -@router.get("", response_model=List[BookRead]) +@router.get("", response_model=BookListResponse) def list_books( status: Optional[ReadingStatus] = Query(default=None), q: Optional[str] = Query(default=None), @@ -140,7 +141,7 @@ def list_books( limit: Optional[int] = Query(default=None, ge=1, le=200), current_user: User = Depends(require_user), session: Session = Depends(get_session), -) -> List[BookRead]: +) -> BookListResponse: """List books for the authenticated user with filtering, sorting, and pagination. ``smart_sort`` overrides *sort*/*order* when a status filter is active: @@ -151,10 +152,10 @@ def list_books( "list_books — status=%r q=%r sort=%s order=%s smart_sort=%s", status, q, sort, order, smart_sort, ) - statement = select(Book).where(Book.user_id == current_user.id) + base_statement = select(Book).where(Book.user_id == current_user.id) if status is not None: - statement = statement.where(Book.reading_status == status) + base_statement = base_statement.where(Book.reading_status == status) if q: pattern = f"%{q}%" @@ -162,7 +163,7 @@ def list_books( Tag.user_id == current_user.id, Tag.name.ilike(pattern), ) - statement = statement.where( + base_statement = base_statement.where( or_( Book.title.ilike(pattern), Book.subtitle.ilike(pattern), @@ -172,6 +173,10 @@ def list_books( ) ) + total = session.exec( + select(func.count()).select_from(base_statement.subquery()) + ).one() + if smart_sort and status is not None: sort_col = STATUS_DEFAULT_SORT_COLUMN[status] sort_order = "desc" @@ -195,13 +200,16 @@ def list_books( if sort_col in (Book.date_started, Book.date_finished): sort_expression = sort_expression.nullslast() - statement = statement.order_by(sort_expression).offset(offset) + statement = base_statement.order_by(sort_expression).offset(offset) if limit is not None: statement = statement.limit(limit) books = list(session.exec(statement).all()) - logger.debug("list_books — returning %d book(s)", len(books)) - return [build_book_read(session, book) for book in books] + logger.debug("list_books — returning %d/%d book(s)", len(books), total) + return BookListResponse( + books=[build_book_read(session, book) for book in books], + total=total, + ) @router.get("/stats", response_model=LibraryStats) diff --git a/backend/app/routers/data.py b/backend/app/routers/data.py index 618478f..9fb291f 100644 --- a/backend/app/routers/data.py +++ b/backend/app/routers/data.py @@ -2,6 +2,7 @@ import json import asyncio +from datetime import datetime from typing import Any from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile @@ -19,21 +20,27 @@ DataImportMappingRead, DataImportMappingSave, DataImportParseResponse, + DataImportPreviewRequest, + DataImportPreviewResponse, DataImportRunRequest, DataImportSuggestRequest, DataImportSuggestResponse, DataImportValidateRequest, DataImportValidateResponse, + ImportFieldConfig, ) from app.time_utils import utcnow from app.services.data_export import build_export_zip from app.services.data_import import ( BOOK_IMPORT_FIELDS, + PREDEFINED_MAPPINGS, compute_schema_fingerprint, delete_parsed_upload, execute_import, + get_predefined_mapping, load_parsed_upload, parse_upload, + preview_import, suggest_mapping, validate_import, ) @@ -43,13 +50,15 @@ def _mapping_read(model: ImportMapping) -> DataImportMappingRead: """Convert an ImportMapping DB model to its response schema.""" + raw_mapping = json.loads(model.mapping_json) return DataImportMappingRead( id=model.id or 0, name=model.name, source_fields=json.loads(model.source_fields_json), - mapping=json.loads(model.mapping_json), + mapping={k: ImportFieldConfig(**v) for k, v in raw_mapping.items()}, created_at=model.created_at, updated_at=model.updated_at, + is_predefined=False, ) @@ -61,7 +70,7 @@ def export_data( ) -> Response: """Export user data as a ZIP archive with CSV or JSON datasets.""" if not body.datasets: - raise HTTPException(status_code=400, detail="error.exportNoDatasets") + raise HTTPException(status_code=400, detail="Select at least one dataset to export.") zip_bytes, filename = build_export_zip( session=session, user=current_user, @@ -90,7 +99,7 @@ async def parse_import_file( "text/plain", } if file.content_type and file.content_type not in allowed_content_types: - raise HTTPException(status_code=415, detail="error.importUnsupportedContentType") + raise HTTPException(status_code=415, detail="Unsupported upload content type. Use CSV or JSON files.") try: payload = parse_upload(await file.read(), file.filename or "upload", current_user.id) except (ValueError, json.JSONDecodeError) as exc: @@ -132,9 +141,10 @@ def save_import_mapping( ) ).first() + mapping_dict = {k: v.model_dump() for k, v in body.mapping.items()} if existing: existing.source_fields_json = json.dumps(body.source_fields) - existing.mapping_json = json.dumps(body.mapping) + existing.mapping_json = json.dumps(mapping_dict) existing.schema_fingerprint = schema_fingerprint existing.updated_at = now session.add(existing) @@ -147,7 +157,7 @@ def save_import_mapping( name=body.name, schema_fingerprint=schema_fingerprint, source_fields_json=json.dumps(body.source_fields), - mapping_json=json.dumps(body.mapping), + mapping_json=json.dumps(mapping_dict), created_at=now, updated_at=now, ) @@ -156,7 +166,7 @@ def save_import_mapping( session.commit() except IntegrityError as exc: session.rollback() - raise HTTPException(status_code=409, detail="error.importMappingNameConflict") from exc + raise HTTPException(status_code=409, detail="A mapping with this name already exists.") from exc session.refresh(mapping) return _mapping_read(mapping) @@ -166,7 +176,18 @@ def list_import_mappings( current_user: User = Depends(require_user), session: Session = Depends(get_session), ) -> list[DataImportMappingListItem]: - """List saved import mappings, newest first.""" + """List saved and predefined import mappings.""" + epoch = datetime(2000, 1, 1) + predefined: list[DataImportMappingListItem] = [ + DataImportMappingListItem( + id=int(pm["id"]), # type: ignore[arg-type] + name=str(pm["name"]), + created_at=epoch, + updated_at=epoch, + is_predefined=True, + ) + for pm in PREDEFINED_MAPPINGS + ] rows = list( session.exec( select(ImportMapping) @@ -174,7 +195,7 @@ def list_import_mappings( .order_by(ImportMapping.updated_at.desc()) ).all() ) - return [ + user_mappings = [ DataImportMappingListItem( id=row.id or 0, name=row.name, @@ -183,6 +204,7 @@ def list_import_mappings( ) for row in rows ] + return predefined + user_mappings @router.get("/import/mappings/{mapping_id}", response_model=DataImportMappingRead) @@ -191,10 +213,25 @@ def get_import_mapping( current_user: User = Depends(require_user), session: Session = Depends(get_session), ) -> DataImportMappingRead: - """Return a single saved import mapping by ID.""" + """Return a single import mapping by ID (supports predefined mappings with negative IDs).""" + if mapping_id < 0: + pm = get_predefined_mapping(mapping_id) + if pm is None: + raise HTTPException(status_code=404, detail="Predefined mapping not found.") + raw_mapping: Any = pm.get("mapping") + raw_sources: Any = pm.get("source_fields", []) + return DataImportMappingRead( + id=mapping_id, + name=str(pm.get("name", "")), + source_fields=list(raw_sources), + mapping={k: ImportFieldConfig(**v) for k, v in raw_mapping.items()}, + created_at=datetime(2000, 1, 1), + updated_at=datetime(2000, 1, 1), + is_predefined=True, + ) row = session.get(ImportMapping, mapping_id) if not row or row.user_id != current_user.id: - raise HTTPException(status_code=404, detail="error.importMappingNotFound") + raise HTTPException(status_code=404, detail="Import mapping not found.") return _mapping_read(row) @@ -204,10 +241,12 @@ def delete_import_mapping( current_user: User = Depends(require_user), session: Session = Depends(get_session), ) -> None: - """Delete a saved import mapping.""" + """Delete a saved import mapping. Predefined mappings cannot be deleted.""" + if mapping_id < 0: + raise HTTPException(status_code=403, detail="Predefined mappings cannot be deleted.") row = session.get(ImportMapping, mapping_id) if not row or row.user_id != current_user.id: - raise HTTPException(status_code=404, detail="error.importMappingNotFound") + raise HTTPException(status_code=404, detail="Import mapping not found.") session.delete(row) session.commit() @@ -229,6 +268,19 @@ def validate_import_data( return DataImportValidateResponse.model_validate(payload) +@router.post("/import/preview", response_model=DataImportPreviewResponse) +def preview_import_data( + body: DataImportPreviewRequest, + current_user: User = Depends(require_user), +) -> DataImportPreviewResponse: + """Preview how a mapping and transforms will affect the first rows.""" + try: + payload = preview_import(body.file_id, current_user, body.mapping) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return DataImportPreviewResponse.model_validate(payload) + + @router.post("/import/execute") async def execute_import_data( body: DataImportRunRequest, diff --git a/backend/app/routers/hygiene.py b/backend/app/routers/hygiene.py new file mode 100644 index 0000000..aead46a --- /dev/null +++ b/backend/app/routers/hygiene.py @@ -0,0 +1,263 @@ +"""Data hygiene endpoints — find books with missing attributes and batch-update them.""" + +import logging +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import and_, or_ +from sqlmodel import Session, func, select, update as sqlmodel_update + +from app.auth import require_user +from app.config import settings +from app.database import get_session +from app.models import Book, User +from app.schemas import ( + HygieneAttribute, + HygieneBatchUpdateRequest, + HygieneBatchUpdateResponse, + HygieneMissingBook, + HygieneMissingResponse, +) +from app.services.cover_import import import_cover_from_url, is_external_cover_url +from app.services.tags import build_book_read + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/hygiene", tags=["hygiene"]) + +_MAX_BATCH_SIZE = 500 + + +def _missing_condition(attr: HygieneAttribute): + """Return a SQLAlchemy filter condition for a given attribute being missing.""" + col = getattr(Book, attr.value) + if attr == HygieneAttribute.author: + return or_(col == "", col.is_(None)) + if attr == HygieneAttribute.page_count: + return or_(col == 0, col.is_(None)) + return col.is_(None) + + +def _compute_missing_attributes(book: Book) -> list[HygieneAttribute]: + """Return the list of hygiene attributes that are missing for a given book.""" + missing: list[HygieneAttribute] = [] + for attr in HygieneAttribute: + val = getattr(book, attr.value) + is_missing = val is None or val == "" or val == 0 + if is_missing: + missing.append(attr) + return missing + + +@router.get("/missing", response_model=HygieneMissingResponse) +def list_missing( + attributes: str = Query(default="", description="Comma-separated list of HygieneAttribute values"), + match: Literal["any", "all"] = Query(default="all"), + offset: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> HygieneMissingResponse: + """List books with missing attributes for the current user.""" + requested: list[HygieneAttribute] = [] + if attributes.strip(): + for part in attributes.split(","): + part = part.strip() + if not part: + continue + try: + requested.append(HygieneAttribute(part)) + except ValueError: + raise HTTPException(status_code=422, detail=f"Unknown attribute: {part}") + else: + requested = list(HygieneAttribute) + + if not requested: + raise HTTPException(status_code=422, detail="At least one attribute is required") + + # When ALL attributes are selected (no explicit filter), always use OR logic. + # When specific attributes are requested, respect the match parameter. + all_attrs_selected = set(requested) == set(HygieneAttribute) + effective_match = "any" if all_attrs_selected else match + + conditions = [_missing_condition(attr) for attr in requested] + + if effective_match == "all": + filter_ = conditions[0] + for c in conditions[1:]: + filter_ = and_(filter_, c) + else: + filter_ = conditions[0] + for c in conditions[1:]: + filter_ = or_(filter_, c) + + base = select(Book).where(Book.user_id == current_user.id).where(filter_) + + total = session.exec( + select(func.count()).select_from(base.subquery()) + ).one() + + books = session.exec( + base.order_by(Book.title).offset(offset).limit(limit) + ).all() + + hygiene_books = [] + for book in books: + br = build_book_read(session, book) + missing_attrs = _compute_missing_attributes(book) + hygiene_books.append(HygieneMissingBook( + id=br.id, + title=br.title, + author=br.author, + isbn=br.isbn, + publisher=br.publisher, + published_year=br.published_year, + blurb=br.blurb, + language=br.language, + subtitle=br.subtitle, + page_count=br.page_count or 0, + cover_url=br.cover_url, + missing_attributes=[a for a in requested if a in missing_attrs], + )) + + total_missing_per_attribute: dict[str, int] = {} + for attr in requested: + cond = _missing_condition(attr) + cnt = session.exec( + select(func.count()).select_from(Book).where( + Book.user_id == current_user.id, + cond, + ) + ).one() + total_missing_per_attribute[attr.value] = cnt + + return HygieneMissingResponse( + books=hygiene_books, + total=total, + total_missing_per_attribute=total_missing_per_attribute, + ) + + +@router.post("/batch-update", response_model=HygieneBatchUpdateResponse) +async def batch_update( + req: HygieneBatchUpdateRequest, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> HygieneBatchUpdateResponse: + """Set a single attribute value on multiple books.""" + if not req.book_ids: + raise HTTPException(status_code=422, detail="book_ids must not be empty") + + if len(req.book_ids) > _MAX_BATCH_SIZE: + raise HTTPException( + status_code=422, + detail=f"At most {_MAX_BATCH_SIZE} books can be updated at once", + ) + + if req.field == HygieneAttribute.author: + if req.value is not None: + val = str(req.value).strip() + if not val: + raise HTTPException( + status_code=422, + detail="author must not be empty", + ) + req.value = val + elif req.field == HygieneAttribute.published_year: + if req.value is not None: + try: + val = int(str(req.value)) + if val > 2099: + raise ValueError + except (ValueError, TypeError): + raise HTTPException( + status_code=422, + detail="published_year must be an integer no greater than 2099", + ) + req.value = val + elif req.field == HygieneAttribute.page_count: + if req.value is not None: + try: + val = int(str(req.value)) + if val < 1: + raise ValueError + except (ValueError, TypeError): + raise HTTPException( + status_code=422, + detail="page_count must be a positive integer", + ) + req.value = val + elif req.field == HygieneAttribute.language: + if req.value is not None: + val = str(req.value).strip().upper() + if len(val) != 2 or not val.isalpha(): + raise HTTPException( + status_code=422, + detail="Language must be a 2-letter ISO code (for example: EN, DE, FR)", + ) + req.value = val + elif req.field == HygieneAttribute.cover_url: + if req.value is not None: + url = str(req.value).strip() + if not is_external_cover_url(url): + raise HTTPException( + status_code=422, + detail="cover_url must be an external http:// or https:// URL", + ) + filename = await import_cover_from_url( + url, + settings.covers_dir, + current_user.id, # type: ignore[arg-type] + settings.cover_import_timeout_seconds, + ) + if filename: + req.value = f"/api/covers/{filename}" + else: + logger.warning("Cover download failed for %s — setting cover_url to None", url) + req.value = None + + books = session.exec( + select(Book).where( + Book.id.in_(req.book_ids), # type: ignore[union-attr] + Book.user_id == current_user.id, + ) + ).all() + + found_ids = {b.id for b in books} + missing_ids = set(req.book_ids) - found_ids + if missing_ids: + raise HTTPException( + status_code=404, + detail=f"Books not found or not owned: {sorted(missing_ids)}", + ) + + skipped_ids: list[int] = [] + to_update_ids: list[int] = [] + for book in books: + current_val = getattr(book, req.field.value) + if current_val == req.value: + skipped_ids.append(book.id) # type: ignore[arg-type] + else: + to_update_ids.append(book.id) # type: ignore[arg-type] + + updated = 0 + if to_update_ids: + try: + stmt = ( + sqlmodel_update(Book) + .where(Book.id.in_(to_update_ids)) # type: ignore[union-attr] + .values({req.field.value: req.value}) + ) + updated = len(to_update_ids) + session.exec(stmt) + session.commit() + except Exception: + session.rollback() + logger.exception("Batch update failed for %d books", len(to_update_ids)) + raise HTTPException(status_code=500, detail="Batch update failed due to a database error") + + return HygieneBatchUpdateResponse( + updated=updated, + skipped=len(skipped_ids), + skipped_ids=skipped_ids, + ) diff --git a/backend/app/routers/import_.py b/backend/app/routers/import_.py index 5835936..fe3a750 100644 --- a/backend/app/routers/import_.py +++ b/backend/app/routers/import_.py @@ -28,7 +28,7 @@ def _raise_integrity_conflict(exc: IntegrityError) -> None: """Convert ISBN unique-constraint violations to HTTP 409.""" message = str(exc.orig).lower() if exc.orig else str(exc).lower() if "book.isbn" in message and "unique" in message: - raise HTTPException(status_code=409, detail="error.isbnAlreadyExists") from exc + raise HTTPException(status_code=409, detail="This ISBN is already used by another book.") from exc raise @@ -40,7 +40,7 @@ def _normalize_language(language: str | None) -> str | None: if not normalized: return None if len(normalized) != 2 or not normalized.isalpha(): - raise HTTPException(status_code=422, detail="error.invalidLanguageCode") + raise HTTPException(status_code=422, detail="Language must be a 2-letter ISO code (for example: EN, DE, FR).") return normalized @@ -52,7 +52,10 @@ async def search_books( ) -> List[BookImportCandidate]: """Search external APIs for books by title or ISBN.""" logger.debug("Search request — q=%r type=%r", q, type) - async with httpx.AsyncClient(timeout=10.0) as client: + async with httpx.AsyncClient( + timeout=10.0, + headers={"User-Agent": "LibrisLog/1.0 (book import; +https://github.com/codebude/librislog)"}, + ) as client: results = await book_import.search( q, type, @@ -79,7 +82,10 @@ async def search_books_stream( logger.debug("Stream search request — q=%r type=%r", q, type) async def event_generator(): - async with httpx.AsyncClient(timeout=10.0) as client: + async with httpx.AsyncClient( + timeout=10.0, + headers={"User-Agent": "LibrisLog/1.0 (book import; +https://github.com/codebude/librislog)"}, + ) as client: async for event in book_import.search_with_progress( q, type, diff --git a/backend/app/routers/profile.py b/backend/app/routers/profile.py index 7fae1e6..256a66a 100644 --- a/backend/app/routers/profile.py +++ b/backend/app/routers/profile.py @@ -47,7 +47,7 @@ def _validate_confirmation(confirmation: str, expected_phrase: str) -> None: """Validate that *confirmation* matches *expected_phrase* exactly.""" if confirmation.strip() != expected_phrase: - raise HTTPException(status_code=400, detail="error.invalidConfirmationPhrase") + raise HTTPException(status_code=400, detail="Confirmation phrase does not match.") @router.get("", response_model=UserRead) @@ -95,6 +95,8 @@ def get_settings( language=settings.language, timezone=settings.timezone, quote_service_enabled=app_settings.dashboard_quote_enabled, + theme=settings.theme, + custom_theme=settings.custom_theme, ) @@ -110,7 +112,10 @@ def update_settings( ).first() if not settings: settings = UserSettings(user_id=current_user.id, language="en") - settings.sqlmodel_update(body.model_dump(exclude_unset=True)) + update_data = body.model_dump(exclude_unset=True) + settings.sqlmodel_update(update_data) + if settings.theme != 'custom': + settings.custom_theme = None session.add(settings) session.commit() session.refresh(settings) @@ -119,6 +124,8 @@ def update_settings( language=settings.language, timezone=settings.timezone, quote_service_enabled=app_settings.dashboard_quote_enabled, + theme=settings.theme, + custom_theme=settings.custom_theme, ) diff --git a/backend/app/routers/progress.py b/backend/app/routers/progress.py index d3e9fae..85b7369 100644 --- a/backend/app/routers/progress.py +++ b/backend/app/routers/progress.py @@ -10,7 +10,7 @@ from app.auth import require_user from app.database import get_session from app.models import Book, ReadingProgress, User -from app.schemas import ReadingProgressCreate, ReadingProgressLatest, ReadingProgressRead +from app.schemas import ReadingProgressCreate, ReadingProgressLatest, ReadingProgressRead, ReadingProgressUpdate logger = logging.getLogger(__name__) @@ -87,6 +87,33 @@ def list_progress_entries( ] +@router.patch("/{book_id}/progress/{entry_id}", response_model=ReadingProgressRead) +def update_progress_entry( + book_id: int, + entry_id: int, + data: ReadingProgressUpdate, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> ReadingProgressRead: + """Update the date of a single progress entry.""" + entry = session.get(ReadingProgress, entry_id) + if not entry or entry.book_id != book_id or entry.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Progress entry not found") + + entry.created_at = data.created_at + entry.updated_at = data.created_at + session.commit() + session.refresh(entry) + logger.debug("Updated progress entry date: entry_id=%s", entry_id) + return ReadingProgressRead( + id=entry.id, + book_id=entry.book_id, + page=entry.page, + created_at=entry.created_at, + updated_at=entry.updated_at, + ) + + @router.delete("/{book_id}/progress/{entry_id}", status_code=204) def delete_progress_entry( book_id: int, diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py index 9a55696..23b1bd7 100644 --- a/backend/app/routers/statistics.py +++ b/backend/app/routers/statistics.py @@ -187,7 +187,7 @@ def get_pages_per_day( data=data, total_days=days, days_with_activity=len(data), - total_pages=sum(d.pages for d in data), + total_pages=int(round(sum(pages for _, pages in sorted(combined.items()) if start_date_str <= _ <= end_date_str))), ) @@ -198,6 +198,9 @@ def get_statistics( ) -> StatisticsResponse: """Return the full statistics dashboard for the authenticated user.""" tz = _user_timezone(session, current_user.id) + now = datetime.now(tz) + current_month_key = f"{now.year:04d}-{now.month:02d}" + current_year = now.year books = list(session.exec(select(Book).where(Book.user_id == current_user.id)).all()) status_counts = Counter(book.reading_status for book in books) @@ -261,12 +264,55 @@ def get_statistics( ] finished_books_per_month: Counter[str] = Counter() - pages_read_per_month_counter: Counter[str] = Counter() for book in finished_books: month = _month_key(book.date_finished, tz) finished_books_per_month[month] += 1 - if book.page_count is not None: - pages_read_per_month_counter[month] += int(book.page_count) + + progress_entries = list( + session.exec( + select(ReadingProgress) + .where(ReadingProgress.user_id == current_user.id) + .order_by(ReadingProgress.book_id, ReadingProgress.created_at) + ).all() + ) + + books_with_progress = {e.book_id for e in progress_entries} + + virtual_entries = [] + for book in books: + if book.id in books_with_progress and book.date_started: + virtual_entries.append( + SimpleNamespace( + book_id=book.id, + page=0, + created_at=book.date_started, + ) + ) + + all_progress_entries = list(progress_entries) + virtual_entries + progress_daily = _extract_progress_daily_pages(all_progress_entries, tz) + + fallback_books = [ + b + for b in books + if b.id not in books_with_progress + and b.reading_status == ReadingStatus.read + and b.date_started + and b.date_finished + and b.page_count + ] + fallback_daily = _extract_book_level_daily_pages(fallback_books, tz) + + combined_daily: Counter[str] = Counter() + for k, v in progress_daily.items(): + combined_daily[k] += v + for k, v in fallback_daily.items(): + combined_daily[k] += v + + pages_read_per_month_counter: Counter[str] = Counter() + for date_str, pages in combined_daily.items(): + month_key = date_str[:7] + pages_read_per_month_counter[month_key] += pages if finished_books_per_month: avg_books_per_month = round( @@ -280,7 +326,7 @@ def get_statistics( ), key=lambda item: (-item[1], item[0]), ) - month_keys = _month_range(min(finished_books_per_month), max(finished_books_per_month)) + month_keys = _month_range(min(finished_books_per_month), max(max(finished_books_per_month), current_month_key)) books_finished_per_month = [ MonthlyBooks(month=month, count=finished_books_per_month.get(month, 0)) for month in month_keys ] @@ -291,9 +337,12 @@ def get_statistics( books_finished_per_month = [] if pages_read_per_month_counter: - month_keys = _month_range(min(pages_read_per_month_counter), max(pages_read_per_month_counter)) + all_months = set(pages_read_per_month_counter) | {current_month_key} + if finished_books_per_month: + all_months |= set(finished_books_per_month) + month_keys = _month_range(min(all_months), max(all_months)) pages_read_per_month = [ - MonthlyPages(month=month, pages=pages_read_per_month_counter.get(month, 0)) for month in month_keys + MonthlyPages(month=month, pages=int(round(pages_read_per_month_counter.get(month, 0)))) for month in month_keys ] else: pages_read_per_month = [] @@ -303,7 +352,7 @@ def get_statistics( for month_key, count in finished_books_per_month.items(): yearly_counts[int(month_key.split("-")[0])] += count year_start = min(yearly_counts) - year_end = max(yearly_counts) + year_end = max(max(yearly_counts), current_year) books_finished_per_year = [ YearlyBooks(year=year, count=yearly_counts.get(year, 0)) for year in range(year_start, year_end + 1) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index a69731b..2ae41bd 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -101,7 +101,7 @@ def delete_user( if admin.id == user_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="error.cannotDeleteOwnAccountHere", + detail="You cannot delete your own account here. Use Profile > Danger Zone.", ) user = session.get(User, user_id) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index fa00e0f..22a9f59 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -2,9 +2,10 @@ from typing import Optional from datetime import datetime +from enum import Enum from typing import Literal -from pydantic import ConfigDict +from pydantic import BaseModel, ConfigDict, field_validator from sqlmodel import Field, SQLModel from app.models import ReadingStatus, UserRole @@ -24,6 +25,11 @@ class ReadingProgressRead(SQLModel): updated_at: datetime +class ReadingProgressUpdate(SQLModel): + """Request body to update a reading progress entry's date.""" + created_at: datetime + + class ReadingProgressLatest(SQLModel): """Latest reading progress for a single book.""" book_id: int @@ -34,12 +40,12 @@ class BookCreate(SQLModel): """Request body to create a new book.""" title: str subtitle: Optional[str] = None - author: Optional[str] = None + author: str isbn: Optional[str] = None cover_url: Optional[str] = None publisher: Optional[str] = None published_year: Optional[int] = None - page_count: Optional[int] = None + page_count: int language: Optional[str] = None tags: Optional[str] = None notes: Optional[str] = None @@ -136,6 +142,12 @@ class BookRead(SQLModel): date_finished: Optional[datetime] +class BookListResponse(BaseModel): + """Paginated book list with total count.""" + books: list[BookRead] + total: int + + class TagCloudEntry(SQLModel): """A single tag with its usage count.""" tag: str @@ -297,12 +309,30 @@ class UserSettingsRead(SQLModel): language: str timezone: str quote_service_enabled: bool + theme: str + custom_theme: Optional[str] = None class UserSettingsUpdate(SQLModel): """User settings update request.""" language: Optional[str] = None timezone: Optional[str] = None + theme: Optional[str] = None + custom_theme: Optional[str] = None + + @field_validator('theme') + @classmethod + def validate_theme(cls, v: Optional[str]) -> Optional[str]: + if v is not None and v not in ('light', 'dark', 'custom'): + raise ValueError('theme must be one of: light, dark, custom') + return v + + @field_validator('custom_theme') + @classmethod + def validate_custom_theme(cls, v: Optional[str]) -> Optional[str]: + if v is not None and v.strip() == '': + return None + return v class ConfirmationPhrase(SQLModel): @@ -386,6 +416,56 @@ class OidcLoginResponse(SQLModel): user: UserRead +class HygieneAttribute(str, Enum): + """Book attributes that can be checked for missing values.""" + author = "author" + isbn = "isbn" + publisher = "publisher" + published_year = "published_year" + blurb = "blurb" + language = "language" + subtitle = "subtitle" + page_count = "page_count" + cover_url = "cover_url" + + +class HygieneMissingBook(SQLModel): + """A single book in the data-hygiene listing with its missing fields annotated.""" + id: int + title: str + author: str | None + isbn: str | None + publisher: str | None + published_year: int | None + blurb: str | None + language: str | None + subtitle: str | None + page_count: int + cover_url: str | None + missing_attributes: list[HygieneAttribute] + + +class HygieneMissingResponse(SQLModel): + """Paginated list of books with missing attributes.""" + books: list[HygieneMissingBook] + total: int + total_missing_per_attribute: dict[str, int] + + +class HygieneBatchUpdateRequest(SQLModel): + """Batch-update a single field on multiple books.""" + book_ids: list[int] + field: HygieneAttribute + value: str | int | None + + +class HygieneBatchUpdateResponse(SQLModel): + """Result of a batch update operation.""" + updated: int + skipped: int + skipped_ids: list[int] + + class DailyPages(SQLModel): """Pages read on a single day.""" date: str @@ -415,11 +495,17 @@ class DataImportParseResponse(SQLModel): row_count: int +class ImportFieldConfig(SQLModel): + """Per-target field mapping configuration.""" + source: Optional[str] = None + transform: Optional[str] = None + + class DataImportMappingSave(SQLModel): """Request body to save an import column mapping.""" name: str source_fields: list[str] - mapping: dict[str, str] + mapping: dict[str, ImportFieldConfig] class DataImportMappingRead(SQLModel): @@ -427,9 +513,10 @@ class DataImportMappingRead(SQLModel): id: int name: str source_fields: list[str] - mapping: dict[str, str] + mapping: dict[str, ImportFieldConfig] created_at: datetime updated_at: datetime + is_predefined: bool = False class DataImportMappingListItem(SQLModel): @@ -438,12 +525,13 @@ class DataImportMappingListItem(SQLModel): name: str created_at: datetime updated_at: datetime + is_predefined: bool = False class DataImportRunRequest(SQLModel): """Request body to execute an import.""" file_id: str - mapping: dict[str, str] + mapping: dict[str, ImportFieldConfig] import_mode: Literal["rollback_all", "continue_on_error"] = "rollback_all" create_progress_for_read: bool = False @@ -455,14 +543,14 @@ class DataImportSuggestRequest(SQLModel): class DataImportSuggestResponse(SQLModel): """Suggested column mapping response.""" - suggested_mapping: dict[str, str] + suggested_mapping: dict[str, ImportFieldConfig] db_fields: list[str] class DataImportValidateRequest(SQLModel): """Request body to validate an import.""" file_id: str - mapping: dict[str, str] + mapping: dict[str, ImportFieldConfig] create_progress_for_read: bool = False @@ -474,6 +562,27 @@ class DataImportValidateResponse(SQLModel): errors: list[str] +class DataImportPreviewRow(SQLModel): + """A single row in the import preview.""" + row_number: int + source: dict[str, str] + transformed: dict[str, Optional[str]] + errors: list[str] + + +class DataImportPreviewRequest(SQLModel): + """Request body to preview an import with transforms.""" + file_id: str + mapping: dict[str, ImportFieldConfig] + + +class DataImportPreviewResponse(SQLModel): + """Import preview response.""" + preview_rows: list[DataImportPreviewRow] + row_count: int + errors: list[str] = [] + + class DataImportExecuteResult(SQLModel): """Import execution result summary.""" imported: int diff --git a/backend/app/services/book_import.py b/backend/app/services/book_import.py index e6a1bea..38e4fbe 100644 --- a/backend/app/services/book_import.py +++ b/backend/app/services/book_import.py @@ -26,6 +26,8 @@ OPEN_LIBRARY_SEARCH_URL: str = "https://openlibrary.org/search.json" OPEN_LIBRARY_COVER_URL: str = "https://covers.openlibrary.org/b/id/{cover_id}-L.jpg" +_USER_AGENT: str = "LibrisLog/1.0 (book import; +https://github.com/codebude/librislog)" + GOOGLE_BOOKS_SEARCH_URL: str = "https://www.googleapis.com/books/v1/volumes" HARDCOVER_GRAPHQL_URL: str = "https://api.hardcover.app/v1/graphql" @@ -138,7 +140,10 @@ async def search_with_progress( ) own_client = http_client is None - client = http_client or httpx.AsyncClient(timeout=10.0) + client = http_client or httpx.AsyncClient( + timeout=10.0, + headers={"User-Agent": _USER_AGENT}, + ) try: results: list[BookImportCandidate] = [] @@ -255,7 +260,10 @@ async def search( query, search_type, bool(api_key), bool(hardcover_api_token)) own_client = http_client is None - client = http_client or httpx.AsyncClient(timeout=10.0) + client = http_client or httpx.AsyncClient( + timeout=10.0, + headers={"User-Agent": _USER_AGENT}, + ) try: tasks = [_search_open_library(query, search_type, client)] @@ -334,7 +342,7 @@ async def _search_open_library( logger.debug("Open Library request — url=%s params=%s", OPEN_LIBRARY_SEARCH_URL, params) try: - resp = await client.get(OPEN_LIBRARY_SEARCH_URL, params=params) + resp = await client.get(OPEN_LIBRARY_SEARCH_URL, params=params, headers={"User-Agent": _USER_AGENT}) logger.debug("Open Library response — status=%d body_size=%d bytes", resp.status_code, len(resp.content)) resp.raise_for_status() diff --git a/backend/app/services/cover_storage.py b/backend/app/services/cover_storage.py index 16ba64e..e3982d9 100644 --- a/backend/app/services/cover_storage.py +++ b/backend/app/services/cover_storage.py @@ -3,7 +3,7 @@ Downloads cover images from external URLs, stores them on disk with an atomic write, and returns the local filename. Callers fall back to the original URL -when this module returns None. +when this module returns ``None``. """ import hashlib @@ -13,6 +13,11 @@ from typing import Optional import httpx +from sqlalchemy import text +from sqlmodel import Session, col, select + +from app.config import settings +from app.models import Book logger = logging.getLogger(__name__) @@ -213,3 +218,47 @@ def delete_cover_file(filename: str, covers_dir: str | Path) -> bool: logger.debug("Deleted cover: %s", filename) return True + + +def cleanup_orphan_covers(session: Session, grace_minutes: int = 60) -> int: + """Delete cover files on disk that are no longer referenced by any book. + + Only files older than *grace_minutes* are considered for deletion, to + avoid removing covers that were just uploaded but not yet saved to the + book entry. + + Args: + session: Database session for querying book cover URLs. + grace_minutes: Minimum age in minutes before an orphaned file is + eligible for deletion. Defaults to 60. + + Returns: + Number of orphaned files deleted. + """ + import time + + covers_path = Path(settings.covers_dir) + if not covers_path.exists(): + return 0 + + referenced = { + url.removeprefix("/api/covers/") + for url in session.exec(select(Book.cover_url).where(col(Book.cover_url).is_not(None))).all() + if url and url.startswith("/api/covers/") + } + + cutoff = time.time() - (grace_minutes * 60) + deleted = 0 + for filename in covers_path.iterdir(): + if filename.is_file() and filename.name not in referenced: + try: + if filename.stat().st_mtime > cutoff: + logger.debug("Skipping recently modified cover: %s", filename.name) + continue + filename.unlink() + logger.info("Deleted orphaned cover: %s", filename.name) + deleted += 1 + except OSError as exc: + logger.warning("Failed to delete orphaned cover %s: %s", filename.name, exc) + + return deleted diff --git a/backend/app/services/data_import.py b/backend/app/services/data_import.py index 97aa8a0..a26c935 100644 --- a/backend/app/services/data_import.py +++ b/backend/app/services/data_import.py @@ -7,7 +7,7 @@ import secrets from datetime import datetime, timezone from pathlib import Path -from typing import Any, Optional +from typing import Any, Callable, Optional import httpx from sqlalchemy.exc import IntegrityError @@ -15,6 +15,7 @@ from app.config import settings from app.models import Book, ReadingProgress, ReadingStatus, User +from app.schemas import ImportFieldConfig from app.time_utils import utcnow from app.services.cover_storage import download_cover from app.services.tags import sync_book_tags @@ -216,25 +217,27 @@ def load_parsed_upload(file_id: str, user_id: int) -> dict: return json.loads(path.read_text(encoding="utf-8")) -def suggest_mapping(source_fields: list[str]) -> dict[str, str]: +def suggest_mapping(source_fields: list[str]) -> dict[str, "ImportFieldConfig"]: """Suggest a column mapping based on known field name aliases. Args: source_fields: List of field names from the import file. Returns: - Dict mapping each recognised source field to its target field. + Dict mapping each recognised DB target field to its source field config. """ - suggested: dict[str, str] = {} + from app.schemas import ImportFieldConfig + + suggested: dict[str, ImportFieldConfig] = {} for field in source_fields: key = " ".join(field.strip().lower().replace("_", " ").split()) if key in _ALIASES: - suggested[field] = _ALIASES[key] + suggested[_ALIASES[key]] = ImportFieldConfig(source=field) continue compact = key.replace(" ", "") for alias, target in _ALIASES.items(): if alias.replace(" ", "") == compact: - suggested[field] = target + suggested[target] = ImportFieldConfig(source=field) break return suggested @@ -360,21 +363,56 @@ def _parse_reading_status(value: object) -> ReadingStatus: return ReadingStatus(raw) -def _mapped_row(row: dict, mapping: dict[str, str]) -> dict: - """Apply a field mapping to a single row.""" +def _build_transform_cache( + mapping: dict[str, "ImportFieldConfig"], +) -> dict[str, Callable[..., str]]: + """Compile all transform expressions into a cache of callables.""" + from app.services.transform_engine import compile_transform + + cache: dict[str, Callable[..., str]] = {} + for target, config in mapping.items(): + if config.transform: + cache[target] = compile_transform(config.transform) + return cache + + +def _mapped_row( + row: dict, + mapping: dict[str, "ImportFieldConfig"], + transform_cache: dict[str, Callable[..., str]], + context: dict[str, Any], + errors: list[str] | None = None, +) -> dict: + """Apply a field mapping and optional transforms to a single row.""" mapped: dict[str, object] = {} - for source, target in mapping.items(): - if not target: + for target, config in mapping.items(): + source = config.source + if not source: continue - mapped[target] = row.get(source) + value = row.get(source, "") + value_str = "" if value is None else str(value) + if target in transform_cache: + from app.services.transform_engine import TransformExecutionError, execute_transform + + try: + value_str = execute_transform( + transform_cache[target], value_str, row, context + ) + except TransformExecutionError as exc: + if errors is not None: + errors.append(f"\x1f{target}\x1f{exc}") + continue + mapped[target] = value_str return mapped -def _validate_mapping(mapping: dict[str, str], source_fields: set[str]) -> tuple[list[str], list[str]]: +def _validate_mapping( + mapping: dict[str, ImportFieldConfig], source_fields: set[str] +) -> tuple[list[str], list[str]]: """Validate an import mapping, returning (warnings, errors).""" warnings: list[str] = [] errors: list[str] = [] - mapped_targets = [target for target in mapping.values() if target] + mapped_targets = [target for target in mapping.keys() if target] if "title" not in mapped_targets: errors.append("Mapping missing required field: title") @@ -383,16 +421,19 @@ def _validate_mapping(mapping: dict[str, str], source_fields: set[str]) -> tuple for target in invalid_targets: errors.append(f"Invalid mapping target: {target}") - target_counts: dict[str, int] = {} - for target in mapped_targets: - target_counts[target] = target_counts.get(target, 0) + 1 - for target, count in sorted(target_counts.items()): - if count > 1: - warnings.append(f"Multiple source fields map to '{target}'; last value wins") - - for source in mapping.keys(): + for target, config in mapping.items(): + source = config.source + if not source: + continue if source not in source_fields: warnings.append(f"Mapped source field missing in file: {source}") + if config.transform: + try: + from app.services.transform_engine import compile_transform + + compile_transform(config.transform) + except ValueError as exc: + errors.append(f"\x1f{target}\x1f{exc}") return warnings, errors @@ -400,7 +441,7 @@ def _validate_mapping(mapping: dict[str, str], source_fields: set[str]) -> tuple def validate_import( file_id: str, user: User, - mapping: dict[str, str], + mapping: dict[str, ImportFieldConfig], session: Session, create_progress_for_read: bool = False, ) -> dict: @@ -422,14 +463,27 @@ def validate_import( warnings, errors = _validate_mapping(mapping, source_fields) + if errors: + return {"valid": False, "row_count": len(rows), "warnings": warnings, "errors": errors} + + transform_cache = _build_transform_cache(mapping) + for idx, row in enumerate(rows, start=1): - row_data = _mapped_row(row, mapping) + row_data = _mapped_row(row, mapping, transform_cache, {"row": idx, "total": len(rows)}, errors) title_value = row_data.get("title") title = str(title_value).strip() if title_value is not None else "" if not title: errors.append(f"Row {idx}: missing required field 'title'") continue + cover_raw = row_data.get("cover_url") + if cover_raw: + cover_val = str(cover_raw).strip() + if not (cover_val.startswith("http://") or cover_val.startswith("https://")): + warnings.append( + f"Row {idx}: cover_url must be an HTTP(S) URL to an image; non-URL values will be ignored" + ) + try: rating = _parse_int(row_data.get("rating"), "rating") if rating is not None and (rating < 1 or rating > 5): @@ -455,7 +509,7 @@ def validate_import( # Batch-check ISBNs to avoid N+1 queries isbns_in_file: set[str] = set() for idx, row in enumerate(rows, start=1): - row_data = _mapped_row(row, mapping) + row_data = _mapped_row(row, mapping, transform_cache, {"row": idx, "total": len(rows)}) isbn = row_data.get("isbn") if isbn: isbns_in_file.add(str(isbn)) @@ -468,7 +522,7 @@ def validate_import( existing_isbns = set(results) for idx, row in enumerate(rows, start=1): - row_data = _mapped_row(row, mapping) + row_data = _mapped_row(row, mapping, transform_cache, {"row": idx, "total": len(rows)}) isbn = row_data.get("isbn") if isbn and str(isbn) in existing_isbns: warnings.append(f"Row {idx}: ISBN already exists and may fail to import") @@ -481,10 +535,73 @@ def validate_import( } +def preview_import( + file_id: str, + user: User, + mapping: dict[str, ImportFieldConfig], + limit: int = 5, +) -> dict: + """Preview how a mapping and transforms will affect the first *limit* rows. + + Returns a dict with keys: preview_rows, row_count, errors. + """ + parsed = load_parsed_upload(file_id, user.id) + rows = parsed.get("rows", []) + source_fields = set(parsed.get("source_fields", [])) + + _warnings, mapping_errors = _validate_mapping(mapping, source_fields) + if mapping_errors: + return {"preview_rows": [], "row_count": len(rows), "errors": mapping_errors} + + transform_cache = _build_transform_cache(mapping) + preview_rows: list[dict] = [] + + for idx, row in enumerate(rows[:limit], start=1): + row_errors: list[str] = [] + row_data = _mapped_row(row, mapping, transform_cache, {"row": idx, "total": len(rows)}, row_errors) + + # Validate required fields and data types for preview + title_value = row_data.get("title") + title = str(title_value).strip() if title_value is not None else "" + if not title: + row_errors.append("Missing required field 'title'") + + try: + rating = _parse_int(row_data.get("rating"), "rating") + if rating is not None and (rating < 1 or rating > 5): + row_errors.append("Rating out of range, will be ignored") + _parse_year(row_data.get("published_year"), "published_year") + _parse_int(row_data.get("page_count"), "page_count") + _parse_reading_status(row_data.get("reading_status")) + date_started = _parse_datetime(row_data.get("date_started"), "date_started") + date_finished = _parse_datetime(row_data.get("date_finished"), "date_finished") + if date_started and date_finished and date_started > date_finished: + row_errors.append("date_started is after date_finished") + _normalize_language( + None if row_data.get("language") is None else str(row_data.get("language")) + ) + except ValueError as exc: + row_errors.append(str(exc)) + + # Convert raw values to strings for display + source_display = {k: str(v) if v is not None else "" for k, v in row.items()} + # Convert transformed values to strings for display + transformed_display = {k: str(v) if v is not None else "" for k, v in row_data.items()} + + preview_rows.append({ + "row_number": idx, + "source": source_display, + "transformed": transformed_display, + "errors": row_errors, + }) + + return {"preview_rows": preview_rows, "row_count": len(rows), "errors": []} + + async def execute_import( file_id: str, user: User, - mapping: dict[str, str], + mapping: dict[str, ImportFieldConfig], session: Session, import_mode: str, create_progress_for_read: bool = False, @@ -519,10 +636,15 @@ async def execute_import( yield {"event": "error", "message": "; ".join(mapping_errors)} return + transform_cache = _build_transform_cache(mapping) + async with httpx.AsyncClient(timeout=15.0) as client: for idx, row in enumerate(rows, start=1): try: - row_data = _mapped_row(row, mapping) + row_transform_errors: list[str] = [] + row_data = _mapped_row(row, mapping, transform_cache, {"row": idx, "total": total}, row_transform_errors) + if row_transform_errors: + raise ValueError(f"Transform error: {row_transform_errors[0]}") title_value = row_data.get("title") title = str(title_value).strip() if title_value is not None else "" if not title: @@ -626,6 +748,64 @@ async def execute_import( } +PREDEFINED_MAPPINGS: list[dict[str, Any]] = [ + { + "id": -1, + "name": "Goodreads Export", + "source_fields": [ + "Book Id", "Title", "Author", "ISBN", "ISBN13", + "Publisher", "Number of Pages", "Year Published", + "Original Publication Year", "Date Read", "Date Added", + "Exclusive Shelf", "My Review", "Bookshelves", + "My Rating", "Average Rating", "Read Count", + ], + "mapping": { + "title": {"source": "Title", "transform": None}, + "subtitle": {"source": "", "transform": None}, + "author": {"source": "Author", "transform": None}, + "isbn": {"source": "ISBN13", "transform": None}, + "publisher": {"source": "Publisher", "transform": None}, + "published_year": { + "source": "Original Publication Year", + "transform": "str(int(value)) if value and str(value).strip() else None", + }, + "page_count": { + "source": "Number of Pages", + "transform": "str(int(value)) if value and str(value).strip() else None", + }, + "language": {"source": "", "transform": None}, + "tags": {"source": "Bookshelves", "transform": None}, + "notes": {"source": "My Review", "transform": None}, + "blurb": {"source": "", "transform": None}, + "rating": { + "source": "My Rating", + "transform": "str(int(value)) if value and str(value).strip() else None", + }, + "reading_status": { + "source": "Exclusive Shelf", + "transform": ( + "shelf_map = {'to-read': 'want_to_read', " + "'currently-reading': 'currently_reading', 'read': 'read'}\n" + "status = shelf_map.get(value.strip().lower(), 'want_to_read')\n" + "return status" + ), + }, + "date_started": {"source": "Date Added", "transform": None}, + "date_finished": {"source": "Date Read", "transform": None}, + "cover_url": {"source": "", "transform": None}, + }, + }, +] + + +def get_predefined_mapping(mapping_id: int) -> dict[str, object] | None: + """Return a predefined mapping by its negative ID, or None if not found.""" + for pm in PREDEFINED_MAPPINGS: + if pm["id"] == mapping_id: + return pm + return None + + def cleanup_temp_files(max_age_hours: int = 24) -> None: """Delete temporary import files older than *max_age_hours*. diff --git a/backend/app/services/transform_engine.py b/backend/app/services/transform_engine.py new file mode 100644 index 0000000..41c3224 --- /dev/null +++ b/backend/app/services/transform_engine.py @@ -0,0 +1,241 @@ +"""Safe Python transform engine for data import field transformations. + +Uses RestrictedPython to compile user-provided Python code blocks into +restricted callables that run in a sandboxed environment. +""" + +import ast +import textwrap +from typing import Any, Callable + +from RestrictedPython import compile_restricted +from RestrictedPython.Guards import safe_builtins, safer_getattr + + +class TransformExecutionError(Exception): + """Raised when a compiled transform fails at runtime.""" + +# Whitelisted modules available in transform globals +_TRANSFORM_MODULES: dict[str, Any] = { + "datetime": __import__("datetime"), + "re": __import__("re"), + "json": __import__("json"), + "math": __import__("math"), + "_strptime": __import__("_strptime"), +} + +def _guarded_import(name: str, *args: Any, **kwargs: Any) -> Any: + """Allow imports only for whitelisted modules and their submodules.""" + top = name.split(".")[0] + if top in _ALLOWED_IMPORTS or top == "_strptime": + return __import__(name, *args, **kwargs) + raise ImportError(f"Import of '{name}' is not allowed in transforms") + + +# Build custom builtins that include safe_builtins plus our guarded __import__ +_CUSTOM_BUILTINS: dict[str, Any] = dict(safe_builtins) +_CUSTOM_BUILTINS["__import__"] = _guarded_import + + +# Safe builtins + whitelisted names +_TRANSFORM_GLOBALS: dict[str, Any] = { + "__builtins__": _CUSTOM_BUILTINS, + "_getattr_": safer_getattr, + "_getiter_": iter, + "_iter_unpack_sequence_": lambda x, y: x, + **_TRANSFORM_MODULES, + "str": str, + "int": int, + "float": float, + "bool": bool, + "len": len, + "range": range, + "abs": abs, + "min": min, + "max": max, + "sum": sum, + "round": round, + "enumerate": enumerate, + "zip": zip, + "map": map, + "filter": filter, + "sorted": sorted, + "list": list, + "dict": dict, + "set": set, + "tuple": tuple, + "type": type, + "isinstance": isinstance, + "hasattr": hasattr, + "getattr": safer_getattr, +} + +# Whitelisted modules that may be imported +_ALLOWED_IMPORTS: set[str] = {"datetime", "re", "json", "math"} + +# AST node types that are forbidden in transforms +_FORBIDDEN_AST_NODES: tuple[type[ast.AST], ...] = ( + ast.Delete, + ast.AugAssign, + ast.AsyncFor, + ast.AsyncWith, + ast.AsyncFunctionDef, + ast.ClassDef, + ast.FunctionDef, + ast.Lambda, + ast.Yield, + ast.YieldFrom, + ast.Await, + ast.Global, + ast.Nonlocal, + ast.Raise, + ast.Assert, + ast.With, + ast.Try, + ast.TryStar, + ast.ExceptHandler, +) + +# Forbidden function names (simple Name checks) +_FORBIDDEN_NAMES: set[str] = {"open", "__import__", "eval", "exec", "compile"} + + +def _validate_ast(source: str) -> list[str]: + """Walk the AST and return a list of forbidden construct errors.""" + errors: list[str] = [] + try: + tree = ast.parse(source, mode="exec") + except SyntaxError as exc: + return [f"Syntax error: {exc}"] + + for node in ast.walk(tree): + if isinstance(node, _FORBIDDEN_AST_NODES): + errors.append(f"Forbidden construct: {node.__class__.__name__}") + + # Block calls to forbidden names like open(), eval(), etc. + if isinstance(node, ast.Name) and node.id in _FORBIDDEN_NAMES: + errors.append(f"Forbidden name: {node.id}") + + # Allow imports only for whitelisted modules + if isinstance(node, ast.Import): + for alias in node.names: + top_module = alias.name.split(".")[0] + if top_module not in _ALLOWED_IMPORTS: + errors.append(f"Forbidden import: {alias.name}") + elif isinstance(node, ast.ImportFrom): + top_module = node.module.split(".")[0] if node.module else "" + if top_module not in _ALLOWED_IMPORTS: + errors.append(f"Forbidden import: {node.module}") + + return errors + + +def compile_transform(code: str) -> Callable[..., str]: + """Compile a Python code block into a restricted callable. + + The user's code is wrapped in a function definition: + + def _transform(value, row, context): + + + Returns a callable that accepts ``(value, row, context)``. + """ + # Strip leading/trailing whitespace but preserve internal indentation + code = code.strip() + if not code: + raise ValueError("Transform code is empty") + + # AST validation before compilation + ast_errors = _validate_ast(code) + if ast_errors: + raise ValueError("; ".join(ast_errors)) + + # Detect single expression vs statement block. + # If the code is a single expression, prepend "return" so the + # function yields a value. Otherwise leave it as-is (user may + # already have return statements). + try: + ast.parse(code, mode="eval") + body = f" return {code}\n" + except SyntaxError: + body = textwrap.indent(code, " ") + "\n" + + # Wrap user code in a function so multiline statements work. + # Note: RestrictedPython forbids names starting with "_", so we use "transform". + wrapped = textwrap.dedent( + f"""\ +def transform(value, row, context): +{body}""" + ) + + # Compile with RestrictedPython + bytecode = compile_restricted( + wrapped, + filename="", + mode="exec", + ) + if bytecode is None: + raise ValueError("RestrictedPython rejected the code") + + # Execute in restricted globals to define the function + exec_globals = dict(_TRANSFORM_GLOBALS) + exec(bytecode, exec_globals) + + fn = exec_globals.get("transform") + if fn is None: + raise ValueError("Failed to define transform function") + + return fn + + +def execute_transform( + fn: Callable[..., Any], + value: str, + row: dict[str, str], + context: dict[str, Any], +) -> str: + """Execute a compiled transform and enforce string return.""" + try: + result = fn(value=value, row=row, context=context) + except Exception as exc: + raise TransformExecutionError(str(exc)) from exc + if result is None: + return "" + if not isinstance(result, str): + return str(result) + return result + + +def validate_transform(code: str) -> list[str]: + """Validate transform code without compiling it. + + Returns a list of error messages (empty if valid). + """ + code = code.strip() + if not code: + return ["Transform code is empty"] + + errors = _validate_ast(code) + if errors: + return errors + + # Try compiling to catch RestrictedPython-level rejections + wrapped = textwrap.dedent( + f"""\ +def transform(value, row, context): +{textwrap.indent(code, ' ')} +""" + ) + + try: + bytecode = compile_restricted( + wrapped, + filename="", + mode="exec", + ) + if bytecode is None: + errors.append("RestrictedPython rejected the code") + except Exception as exc: + errors.append(f"Compilation error: {exc}") + + return errors diff --git a/backend/app/services/user_deletion.py b/backend/app/services/user_deletion.py index bc725e1..4e47811 100644 --- a/backend/app/services/user_deletion.py +++ b/backend/app/services/user_deletion.py @@ -29,7 +29,7 @@ def assert_not_last_admin(session: Session, target_user: User) -> None: if admin_count <= 1: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="error.cannotDeleteLastAdmin", + detail="Cannot delete the last administrator account.", ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9c96d1e..4cd6ad4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,8 +21,19 @@ dependencies = [ "sqlmodel>=0.0.38", "uvicorn[standard]>=0.46.0", "browserforge>=1.2.4", + "restrictedpython>=8.1", ] +[tool.uv] +package = true + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + [dependency-groups] dev = [ "httpx>=0.28.1", diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index bd6a24c..fbed0eb 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -207,7 +207,7 @@ def test_admin_restore_success(admin_client_with_file_db: tuple[TestClient, str] # 2. Modify the database (add a new book) conn = sqlite3.connect(db_path) - conn.execute("INSERT INTO book (title, user_id, reading_status) VALUES ('New Book', 1, 'read')") + conn.execute("INSERT INTO book (title, author, page_count, user_id, reading_status) VALUES ('New Book', '', 0, 1, 'read')") conn.commit() row = conn.execute("SELECT COUNT(*) FROM book").fetchone() assert row[0] == 2 diff --git a/backend/tests/test_auth_profile_users.py b/backend/tests/test_auth_profile_users.py index be52fd6..c70edda 100644 --- a/backend/tests/test_auth_profile_users.py +++ b/backend/tests/test_auth_profile_users.py @@ -219,6 +219,8 @@ def test_profile_settings_get_and_update(client: TestClient) -> None: assert current.status_code == 200 assert current.json()["language"] == "en" assert current.json()["timezone"] == "UTC" + assert current.json()["theme"] == "light" + assert current.json()["custom_theme"] is None updated = client.patch("/api/profile/settings", json={"language": "de"}) assert updated.status_code == 200 @@ -229,6 +231,16 @@ def test_profile_settings_get_and_update(client: TestClient) -> None: assert tz_updated.status_code == 200 assert tz_updated.json()["timezone"] == "Europe/Berlin" + theme_updated = client.patch("/api/profile/settings", json={"theme": "dark"}) + assert theme_updated.status_code == 200 + assert theme_updated.json()["theme"] == "dark" + assert theme_updated.json()["custom_theme"] is None + + custom_updated = client.patch("/api/profile/settings", json={"theme": "custom", "custom_theme": "dracula"}) + assert custom_updated.status_code == 200 + assert custom_updated.json()["theme"] == "custom" + assert custom_updated.json()["custom_theme"] == "dracula" + def test_profile_api_key_lifecycle(client: TestClient) -> None: listed = client.get("/api/profile/api-keys") @@ -274,6 +286,8 @@ def test_users_create_creates_user_settings(client: TestClient, session: Session assert settings is not None assert settings.language == "en" assert settings.timezone == "UTC" + assert settings.theme == "light" + assert settings.custom_theme is None def test_users_create_rejects_duplicate_email(client: TestClient) -> None: resp = client.post( @@ -506,12 +520,12 @@ def test_oidc_link_status_disabled_by_default(client: TestClient) -> None: def test_profile_reset_data_requires_confirmation_phrase(client: TestClient) -> None: resp = client.post("/api/profile/reset-data", json={"confirmation": "WRONG"}) assert resp.status_code == 400 - assert resp.json()["detail"] == "error.invalidConfirmationPhrase" + assert resp.json()["detail"] == "Confirmation phrase does not match." def test_profile_reset_data_deletes_books_tags_progress(client: TestClient) -> None: - b1 = client.post("/api/books", json={"title": "A", "tags": "one,two"}).json() - b2 = client.post("/api/books", json={"title": "B", "tags": "one"}).json() + b1 = client.post("/api/books", json={"title": "A", "author": "Test Author", "page_count": 100, "tags": "one,two"}).json() + b2 = client.post("/api/books", json={"title": "B", "author": "Test Author", "page_count": 100, "tags": "one"}).json() client.post(f"/api/books/{b1['id']}/progress", json={"page": 10}) client.post(f"/api/books/{b1['id']}/progress", json={"page": 20}) @@ -522,13 +536,13 @@ def test_profile_reset_data_deletes_books_tags_progress(client: TestClient) -> N assert data["deleted"]["tags"] == 2 assert data["deleted"]["progress_entries"] == 2 - assert client.get("/api/books").json() == [] + assert client.get("/api/books").json() == {"books": [], "total": 0} def test_profile_delete_account_rejects_last_admin(client: TestClient) -> None: resp = client.request("DELETE", "/api/profile/account", json={"confirmation": "DELETE MY ACCOUNT"}) assert resp.status_code == 403 - assert resp.json()["detail"] == "error.cannotDeleteLastAdmin" + assert resp.json()["detail"] == "Cannot delete the last administrator account." def test_profile_delete_account_deletes_regular_user_data( @@ -540,7 +554,7 @@ def test_profile_delete_account_deletes_regular_user_data( with TestClient(client.app) as c2: c2.headers.update({"X-API-Key": key}) - create = c2.post("/api/books", json={"title": "To Delete", "tags": "x"}) + create = c2.post("/api/books", json={"title": "To Delete", "author": "Test Author", "page_count": 100, "tags": "x"}) assert create.status_code == 201 book_id = create.json()["id"] c2.post(f"/api/books/{book_id}/progress", json={"page": 7}) diff --git a/backend/tests/test_books.py b/backend/tests/test_books.py index e750f58..fbb3bc8 100644 --- a/backend/tests/test_books.py +++ b/backend/tests/test_books.py @@ -18,7 +18,7 @@ def _create_book(client: TestClient, **kwargs: Any) -> dict[str, Any]: """Create a book via POST /api/books and return the response JSON.""" - payload: dict[str, Any] = {"title": "Test Book", **kwargs} + payload: dict[str, Any] = {"title": "Test Book", "author": "Test Author", "page_count": 100, **kwargs} resp = client.post("/api/books", json=payload) assert resp.status_code == 201 return resp.json() @@ -27,7 +27,7 @@ def _create_book(client: TestClient, **kwargs: Any) -> dict[str, Any]: # ── create ──────────────────────────────────────────────────────────────────── def test_create_book_returns_201(client: TestClient) -> None: - resp = client.post("/api/books", json={"title": "Dune"}) + resp = client.post("/api/books", json={"title": "Dune", "author": "Frank Herbert", "page_count": 412}) assert resp.status_code == 201 data = resp.json() assert data["title"] == "Dune" @@ -62,12 +62,12 @@ def test_create_book_with_all_fields(client: TestClient) -> None: def test_create_book_missing_title_returns_422(client: TestClient) -> None: - resp = client.post("/api/books", json={"author": "Frank Herbert"}) + resp = client.post("/api/books", json={"author": "Frank Herbert", "page_count": 400}) assert resp.status_code == 422 def test_create_book_invalid_rating_returns_422(client: TestClient) -> None: - resp = client.post("/api/books", json={"title": "Dune", "rating": 6}) + resp = client.post("/api/books", json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "rating": 6}) assert resp.status_code == 422 @@ -76,7 +76,7 @@ def test_create_book_invalid_rating_returns_422(client: TestClient) -> None: def test_list_books_empty(client: TestClient) -> None: resp = client.get("/api/books") assert resp.status_code == 200 - assert resp.json() == [] + assert resp.json() == {"books": [], "total": 0} def test_list_books_returns_all(client: TestClient) -> None: @@ -84,7 +84,9 @@ def test_list_books_returns_all(client: TestClient) -> None: _create_book(client, title="Book B") resp = client.get("/api/books") assert resp.status_code == 200 - assert len(resp.json()) == 2 + body = resp.json() + assert body["total"] == 2 + assert len(body["books"]) == 2 def test_list_books_filter_by_status(client: TestClient) -> None: @@ -95,9 +97,9 @@ def test_list_books_filter_by_status(client: TestClient) -> None: resp = client.get("/api/books?status=currently_reading") assert resp.status_code == 200 - data = resp.json() - assert len(data) == 1 - assert data[0]["title"] == "Reading" + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Reading" def test_list_books_search_by_title(client: TestClient) -> None: @@ -105,9 +107,9 @@ def test_list_books_search_by_title(client: TestClient) -> None: _create_book(client, title="Foundation") resp = client.get("/api/books?q=dune") assert resp.status_code == 200 - data = resp.json() - assert len(data) == 1 - assert data[0]["title"] == "Dune" + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Dune" def test_list_books_search_by_author(client: TestClient) -> None: @@ -115,9 +117,9 @@ def test_list_books_search_by_author(client: TestClient) -> None: _create_book(client, title="Foundation", author="Isaac Asimov") resp = client.get("/api/books?q=asimov") assert resp.status_code == 200 - data = resp.json() - assert len(data) == 1 - assert data[0]["title"] == "Foundation" + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Foundation" def test_list_books_sort_by_rating(client: TestClient) -> None: @@ -125,9 +127,9 @@ def test_list_books_sort_by_rating(client: TestClient) -> None: _create_book(client, title="High", rating=5) resp = client.get("/api/books?sort=rating&order=desc") assert resp.status_code == 200 - data = resp.json() - assert data[0]["title"] == "High" - assert data[1]["title"] == "Low" + body = resp.json() + assert body["books"][0]["title"] == "High" + assert body["books"][1]["title"] == "Low" def test_list_books_sort_by_date_added_asc(client: TestClient) -> None: @@ -135,8 +137,8 @@ def test_list_books_sort_by_date_added_asc(client: TestClient) -> None: _create_book(client, title="Second") resp = client.get("/api/books?sort=date_added&order=asc") assert resp.status_code == 200 - data = resp.json() - assert data[0]["title"] == "First" + body = resp.json() + assert body["books"][0]["title"] == "First" def test_list_books_smart_sort_currently_reading_by_date_started(client: TestClient) -> None: @@ -156,8 +158,8 @@ def test_list_books_smart_sort_currently_reading_by_date_started(client: TestCli resp = client.get("/api/books?status=currently_reading") assert resp.status_code == 200 - data = resp.json() - assert [item["title"] for item in data] == ["Newer", "Older", "No Start"] + body = resp.json() + assert [item["title"] for item in body["books"]] == ["Newer", "Older", "No Start"] def test_list_books_smart_sort_read_by_date_finished(client: TestClient) -> None: @@ -167,8 +169,8 @@ def test_list_books_smart_sort_read_by_date_finished(client: TestClient) -> None resp = client.get("/api/books?status=read") assert resp.status_code == 200 - data = resp.json() - assert [item["title"] for item in data] == ["Newer", "Older", "No Finish"] + body = resp.json() + assert [item["title"] for item in body["books"]] == ["Newer", "Older", "No Finish"] def test_list_books_manual_sort_still_available_with_smart_sort_off(client: TestClient) -> None: @@ -177,8 +179,8 @@ def test_list_books_manual_sort_still_available_with_smart_sort_off(client: Test resp = client.get("/api/books?status=currently_reading&smart_sort=false&sort=rating&order=desc") assert resp.status_code == 200 - data = resp.json() - assert [item["title"] for item in data] == ["High", "Low"] + body = resp.json() + assert [item["title"] for item in body["books"]] == ["High", "Low"] # ── get ─────────────────────────────────────────────────────────────────────── @@ -212,13 +214,13 @@ def test_update_book_language(client: TestClient) -> None: def test_create_book_invalid_language_returns_422(client: TestClient) -> None: - resp = client.post("/api/books", json={"title": "Dune", "language": "english"}) + resp = client.post("/api/books", json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "language": "english"}) assert resp.status_code == 422 - assert resp.json()["detail"] == "error.invalidLanguageCode" + assert resp.json()["detail"] == "Language must be a 2-letter ISO code (for example: EN, DE, FR)." def test_create_book_with_did_not_finish_status(client: TestClient) -> None: - resp = client.post("/api/books", json={"title": "DNF Book", "reading_status": "did_not_finish"}) + resp = client.post("/api/books", json={"title": "DNF Book", "author": "Test Author", "page_count": 100, "reading_status": "did_not_finish"}) assert resp.status_code == 201 assert resp.json()["reading_status"] == "did_not_finish" @@ -229,9 +231,9 @@ def test_list_books_filter_by_did_not_finish_status(client: TestClient) -> None: resp = client.get("/api/books?status=did_not_finish") assert resp.status_code == 200 - data = resp.json() - assert len(data) == 1 - assert data[0]["title"] == "DNF" + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "DNF" def test_list_books_supports_limit_and_offset(client: TestClient) -> None: @@ -241,11 +243,13 @@ def test_list_books_supports_limit_and_offset(client: TestClient) -> None: first_page = client.get("/api/books?sort=title&order=asc&limit=2&offset=0") assert first_page.status_code == 200 - assert [item["title"] for item in first_page.json()] == ["First", "Second"] + first_body = first_page.json() + assert [item["title"] for item in first_body["books"]] == ["First", "Second"] second_page = client.get("/api/books?sort=title&order=asc&limit=2&offset=2") assert second_page.status_code == 200 - assert [item["title"] for item in second_page.json()] == ["Third"] + second_body = second_page.json() + assert [item["title"] for item in second_body["books"]] == ["Third"] def test_update_book_to_did_not_finish_status(client: TestClient) -> None: @@ -688,7 +692,7 @@ def test_create_book_with_external_cover_downloads_local(client: TestClient, tmp monkeypatch.setattr(settings, "covers_dir", str(tmp_path)) monkeypatch.setattr(books_router, "import_cover_from_url", _fake_download_cover_success) - resp = client.post("/api/books", json={"title": "Book", "cover_url": "https://example.com/c.jpg"}) + resp = client.post("/api/books", json={"title": "Book", "author": "Test Author", "page_count": 100, "cover_url": "https://example.com/c.jpg"}) assert resp.status_code == 201 data = resp.json() assert data["cover_url"] == "/api/covers/fakecover123.jpg" @@ -701,7 +705,7 @@ def test_create_book_cover_download_fail_skips_cover(client: TestClient, tmp_pat monkeypatch.setattr(books_router, "import_cover_from_url", _fake_download_cover_fail) ext_url = "https://example.com/fallback.jpg" - resp = client.post("/api/books", json={"title": "Book", "cover_url": ext_url}) + resp = client.post("/api/books", json={"title": "Book", "author": "Test Author", "page_count": 100, "cover_url": ext_url}) assert resp.status_code == 201 assert resp.json()["cover_url"] is None @@ -716,7 +720,7 @@ async def spy(*args: Any, **kwargs: Any) -> None: # pragma: no cover monkeypatch.setattr(books_router, "import_cover_from_url", spy) local_url = "/api/covers/existing.jpg" - resp = client.post("/api/books", json={"title": "Book", "cover_url": local_url}) + resp = client.post("/api/books", json={"title": "Book", "author": "Test Author", "page_count": 100, "cover_url": local_url}) assert resp.status_code == 201 assert resp.json()["cover_url"] == local_url assert called == [] # download_cover must NOT be called @@ -911,7 +915,7 @@ def test_suggest_user_isolation(client: TestClient, create_user_with_key: Callab user2, key2 = create_user_with_key(email="other@example.com") with TestClient(client.app) as c2: c2.headers.update({"X-API-Key": key2}) - resp2 = c2.post("/api/books", json={"title": "User2 Book", "author": "Isaac Asimov"}) + resp2 = c2.post("/api/books", json={"title": "User2 Book", "author": "Isaac Asimov", "page_count": 200}) assert resp2.status_code == 201 resp = client.get("/api/books/suggestions/authors?q=frank") @@ -930,7 +934,7 @@ def test_update_book_rejects_clearing_date_finished_for_read(client: TestClient) resp = client.patch(f"/api/books/{book['id']}", json={"date_finished": None}) assert resp.status_code == 422 - assert resp.json()["detail"] == "error.dateFinishedRequiredForRead" + assert resp.json()["detail"] == "A finished book must have an end date. Change the status if you want to remove the finish date." def test_update_book_allows_clearing_date_finished_when_changing_status(client: TestClient) -> None: @@ -1046,31 +1050,31 @@ def test_create_book_future_date_started_returns_422(client: TestClient, monkeyp "_utcnow", lambda: datetime(2024, 1, 1, tzinfo=timezone.utc), ) - resp = client.post("/api/books", json={"title": "Future", "date_started": "2025-01-01"}) + resp = client.post("/api/books", json={"title": "Future", "author": "Test Author", "page_count": 100, "date_started": "2025-01-01"}) assert resp.status_code == 422 - assert resp.json()["detail"] == "error.dateInFuture" + assert resp.json()["detail"] == "Date cannot be in the future." def test_create_book_date_started_after_finished_returns_422(client: TestClient) -> None: resp = client.post( "/api/books", - json={"title": "Bad Dates", "date_started": "2024-02-01", "date_finished": "2024-01-01"}, + json={"title": "Bad Dates", "author": "Test Author", "page_count": 100, "date_started": "2024-02-01", "date_finished": "2024-01-01"}, ) assert resp.status_code == 422 - assert resp.json()["detail"] == "error.dateStartedAfterFinished" + assert resp.json()["detail"] == "Start date cannot be after finish date." def test_create_book_whitespace_language_returns_none(client: TestClient) -> None: - resp = client.post("/api/books", json={"title": "Whitespace Lang", "language": " "}) + resp = client.post("/api/books", json={"title": "Whitespace Lang", "author": "Test Author", "page_count": 100, "language": " "}) assert resp.status_code == 201 assert resp.json()["language"] is None def test_create_book_duplicate_isbn_returns_409(client: TestClient) -> None: _create_book(client, title="First", isbn="9780441013593") - resp = client.post("/api/books", json={"title": "Duplicate", "isbn": "9780441013593"}) + resp = client.post("/api/books", json={"title": "Duplicate", "author": "Test Author", "page_count": 100, "isbn": "9780441013593"}) assert resp.status_code == 409 - assert resp.json()["detail"] == "error.isbnAlreadyExists" + assert resp.json()["detail"] == "This ISBN is already used by another book." def test_list_books_sort_by_date_started(client: TestClient) -> None: @@ -1078,8 +1082,8 @@ def test_list_books_sort_by_date_started(client: TestClient) -> None: _create_book(client, title="B", date_started="2024-02-01") resp = client.get("/api/books?sort=date_started&order=desc") assert resp.status_code == 200 - data = resp.json() - assert data[0]["title"] == "B" + body = resp.json() + assert body["books"][0]["title"] == "B" def test_list_books_sort_by_date_finished(client: TestClient) -> None: @@ -1087,8 +1091,8 @@ def test_list_books_sort_by_date_finished(client: TestClient) -> None: _create_book(client, title="B", date_finished="2024-02-01") resp = client.get("/api/books?sort=date_finished&order=desc") assert resp.status_code == 200 - data = resp.json() - assert data[0]["title"] == "B" + body = resp.json() + assert body["books"][0]["title"] == "B" def test_get_library_stats(client: TestClient) -> None: @@ -1156,7 +1160,7 @@ def test_update_book_duplicate_isbn_returns_409(client: TestClient) -> None: json={"isbn": "9780441013593"}, ) assert resp.status_code == 409 - assert resp.json()["detail"] == "error.isbnAlreadyExists" + assert resp.json()["detail"] == "This ISBN is already used by another book." def test_transition_status_not_found_returns_404(client: TestClient) -> None: @@ -1231,7 +1235,7 @@ def _fake_commit(self: Session) -> None: monkeypatch.setattr(Session, "commit", _fake_commit) with pytest.raises(SQLAIntegrityError): - client.post("/api/books", json={"title": "Commit Conflict"}) + client.post("/api/books", json={"title": "Commit Conflict", "author": "Test Author", "page_count": 100}) def test_update_book_commit_integrity_error(client: TestClient, monkeypatch: MonkeyPatch) -> None: diff --git a/backend/tests/test_cover_storage.py b/backend/tests/test_cover_storage.py index ad0eb32..3e3eef2 100644 --- a/backend/tests/test_cover_storage.py +++ b/backend/tests/test_cover_storage.py @@ -5,14 +5,17 @@ """ import hashlib +import os from pathlib import Path from typing import Any from unittest.mock import MagicMock import httpx import pytest +from sqlmodel import Session, col, select from app.services.cover_storage import ( + cleanup_orphan_covers, delete_cover_file, download_cover, resolve_cover_path, @@ -314,3 +317,69 @@ def test_save_uploaded_cover_content_type_with_params(tmp_path: Path) -> None: assert filename is not None assert filename.endswith(".jpg") + + +def test_cleanup_orphan_covers_deletes_unreferenced_files(session: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Cover files on disk that are not referenced by any book should be deleted.""" + from app.models import Book + from app.services import cover_storage + import time + + # Create a book with a local cover URL + book = Book(user_id=1, title="Test", cover_url="/api/covers/1__abc123.jpg") + session.add(book) + session.commit() + + # Verify the book is in the database + result = session.exec(select(Book.cover_url).where(col(Book.cover_url).is_not(None))).all() + assert len(result) == 1 + assert result[0] == "/api/covers/1__abc123.jpg" + + # Create cover files on disk + (tmp_path / "1__abc123.jpg").write_bytes(b"referenced") + (tmp_path / "1__orphan1.jpg").write_bytes(b"orphan1") + (tmp_path / "1__orphan2.png").write_bytes(b"orphan2") + + # Make orphan files old enough to be eligible for deletion + old_time = time.time() - 7200 # 2 hours ago + os.utime(tmp_path / "1__orphan1.jpg", (old_time, old_time)) + os.utime(tmp_path / "1__orphan2.png", (old_time, old_time)) + + monkeypatch.setattr(cover_storage.settings, "covers_dir", str(tmp_path)) + + deleted = cleanup_orphan_covers(session) + assert deleted == 2 + assert (tmp_path / "1__abc123.jpg").exists() + assert not (tmp_path / "1__orphan1.jpg").exists() + assert not (tmp_path / "1__orphan2.png").exists() + + +def test_cleanup_orphan_covers_skips_recent_files(session: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Recently modified orphan files should not be deleted.""" + from app.services import cover_storage + + (tmp_path / "1__recent.jpg").write_bytes(b"recent") + + monkeypatch.setattr(cover_storage.settings, "covers_dir", str(tmp_path)) + + deleted = cleanup_orphan_covers(session) + assert deleted == 0 + assert (tmp_path / "1__recent.jpg").exists() + + +def test_cleanup_orphan_covers_empty_dir(session: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Empty covers directory should return 0.""" + from app.services import cover_storage + + monkeypatch.setattr(cover_storage.settings, "covers_dir", str(tmp_path)) + + assert cleanup_orphan_covers(session) == 0 + + +def test_cleanup_orphan_covers_nonexistent_dir(session: Session, monkeypatch: pytest.MonkeyPatch) -> None: + """Nonexistent covers directory should return 0.""" + from app.services import cover_storage + + monkeypatch.setattr(cover_storage.settings, "covers_dir", "/nonexistent/path") + + assert cleanup_orphan_covers(session) == 0 diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index d6b3b41..1b0cb25 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -15,7 +15,7 @@ def _create_book(client: TestClient, **kwargs: Any) -> dict[str, Any]: """Helper to create a book via the API and return the JSON response.""" - payload = {"title": "Test Book", **kwargs} + payload = {"title": "Test Book", "author": "Test Author", "page_count": 100, **kwargs} resp = client.post("/api/books", json=payload) assert resp.status_code == 201 return resp.json() diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index bb6a19d..e574736 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -24,7 +24,7 @@ def _parse_sse(text: str) -> list[dict[str, str | int | bool | None]]: def test_data_export_zip_contains_manifest_and_books_json(client: TestClient) -> None: create_resp = client.post( "/api/books", - json={"title": "Dune", "author": "Frank Herbert", "reading_status": "read"}, + json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "reading_status": "read"}, ) assert create_resp.status_code == 201 @@ -51,7 +51,7 @@ def test_data_export_zip_contains_manifest_and_books_json(client: TestClient) -> def test_data_export_csv_format(client: TestClient) -> None: create_resp = client.post( "/api/books", - json={"title": "Dune", "author": "Frank Herbert", "reading_status": "read"}, + json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "reading_status": "read"}, ) assert create_resp.status_code == 201 @@ -78,7 +78,7 @@ def test_data_export_with_covers(client: TestClient, monkeypatch: MonkeyPatch, t create_resp = client.post( "/api/books", - json={"title": "Dune", "author": "Frank Herbert", "reading_status": "read", "cover_url": "/api/covers/test_cover.jpg"}, + json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "reading_status": "read", "cover_url": "/api/covers/test_cover.jpg"}, ) assert create_resp.status_code == 201 @@ -105,7 +105,7 @@ def test_data_export_missing_cover_skipped(client: TestClient, monkeypatch: Monk create_resp = client.post( "/api/books", - json={"title": "Dune", "author": "Frank Herbert", "reading_status": "read", "cover_url": "/api/covers/missing.jpg"}, + json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "reading_status": "read", "cover_url": "/api/covers/missing.jpg"}, ) assert create_resp.status_code == 201 @@ -171,9 +171,9 @@ def test_data_import_parse_and_suggest_mapping(client: TestClient, monkeypatch: ) assert suggest_resp.status_code == 200 suggested = suggest_resp.json()["suggested_mapping"] - assert suggested["Title"] == "title" - assert suggested["Author"] == "author" - assert suggested["My Rating"] == "rating" + assert suggested["title"]["source"] == "Title" + assert suggested["author"]["source"] == "Author" + assert suggested["rating"]["source"] == "My Rating" def test_data_import_mapping_crud(client: TestClient) -> None: @@ -182,7 +182,7 @@ def test_data_import_mapping_crud(client: TestClient) -> None: json={ "name": "Goodreads", "source_fields": ["Title", "Author"], - "mapping": {"Title": "title", "Author": "author"}, + "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}}, }, ) assert save_resp.status_code == 201 @@ -190,7 +190,12 @@ def test_data_import_mapping_crud(client: TestClient) -> None: list_resp = client.get("/api/data/import/mappings") assert list_resp.status_code == 200 - assert len(list_resp.json()) == 1 + data = list_resp.json() + assert len(data) == 2 + assert data[0]["is_predefined"] is True + assert data[0]["name"] == "Goodreads Export" + assert data[1]["is_predefined"] is False + assert data[1]["name"] == "Goodreads" get_resp = client.get(f"/api/data/import/mappings/{saved['id']}") assert get_resp.status_code == 200 @@ -214,16 +219,26 @@ def test_data_import_validate_and_execute_continue_on_error(client: TestClient, validate_resp = client.post( "/api/data/import/validate", - json={"file_id": file_id, "mapping": {"Title": "title", "Author": "author"}}, + json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}}}, ) assert validate_resp.status_code == 200 assert validate_resp.json()["valid"] is False + preview_resp = client.post( + "/api/data/import/preview", + json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}}}, + ) + assert preview_resp.status_code == 200 + preview = preview_resp.json() + assert preview["row_count"] == 2 + assert len(preview["preview_rows"]) == 2 + assert preview["preview_rows"][0]["transformed"]["title"] == "Dune" + execute_resp = client.post( "/api/data/import/execute", json={ "file_id": file_id, - "mapping": {"Title": "title", "Author": "author"}, + "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}}, "import_mode": "continue_on_error", }, ) @@ -247,7 +262,7 @@ def test_data_import_execute_rollback_all_rolls_back(client: TestClient, monkeyp "/api/data/import/execute", json={ "file_id": file_id, - "mapping": {"Title": "title", "Author": "author"}, + "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}}, "import_mode": "rollback_all", }, ) @@ -257,7 +272,7 @@ def test_data_import_execute_rollback_all_rolls_back(client: TestClient, monkeyp books = client.get("/api/books") assert books.status_code == 200 - assert books.json() == [] + assert books.json() == {"books": [], "total": 0} def test_data_import_execute_rejects_invalid_target_mapping(client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: @@ -271,7 +286,7 @@ def test_data_import_execute_rejects_invalid_target_mapping(client: TestClient, validate_resp = client.post( "/api/data/import/validate", - json={"file_id": file_id, "mapping": {"Title": "invalid_field"}}, + json={"file_id": file_id, "mapping": {"invalid_field": {"source": "Title", "transform": None}}}, ) assert validate_resp.status_code == 200 assert validate_resp.json()["valid"] is False @@ -292,7 +307,7 @@ def test_data_import_validate_rejects_invalid_reading_status_enum(client: TestCl validate_resp = client.post( "/api/data/import/validate", - json={"file_id": file_id, "mapping": {"Title": "title", "Status": "reading_status"}}, + json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "reading_status": {"source": "Status", "transform": None}}}, ) assert validate_resp.status_code == 200 payload = validate_resp.json() @@ -318,7 +333,7 @@ def test_data_import_execute_deletes_temp_file_after_completion(client: TestClie "/api/data/import/execute", json={ "file_id": file_id, - "mapping": {"Title": "title"}, + "mapping": {"title": {"source": "Title", "transform": None}}, "import_mode": "continue_on_error", }, ) @@ -344,10 +359,10 @@ def test_data_import_execute_progress_uses_date_finished_for_read_books( json={ "file_id": file_id, "mapping": { - "Title": "title", - "Status": "reading_status", - "Pages": "page_count", - "Date Finished": "date_finished", + "title": {"source": "Title", "transform": None}, + "reading_status": {"source": "Status", "transform": None}, + "page_count": {"source": "Pages", "transform": None}, + "date_finished": {"source": "Date Finished", "transform": None}, }, "import_mode": "continue_on_error", "create_progress_for_read": True, @@ -357,9 +372,9 @@ def test_data_import_execute_progress_uses_date_finished_for_read_books( books_resp = client.get("/api/books") assert books_resp.status_code == 200 - books = books_resp.json() - assert len(books) == 1 - book = books[0] + books_body = books_resp.json() + assert books_body["total"] == 1 + book = books_body["books"][0] assert book["date_finished"] == "2024-01-15T10:30:00Z" progress_resp = client.get(f"/api/books/{book['id']}/progress") @@ -387,9 +402,9 @@ def test_data_import_execute_progress_falls_back_to_now_without_date_finished( json={ "file_id": file_id, "mapping": { - "Title": "title", - "Status": "reading_status", - "Pages": "page_count", + "title": {"source": "Title", "transform": None}, + "reading_status": {"source": "Status", "transform": None}, + "page_count": {"source": "Pages", "transform": None}, }, "import_mode": "continue_on_error", "create_progress_for_read": True, @@ -400,9 +415,9 @@ def test_data_import_execute_progress_falls_back_to_now_without_date_finished( books_resp = client.get("/api/books") assert books_resp.status_code == 200 - books = books_resp.json() - assert len(books) == 1 - book = books[0] + books_body = books_resp.json() + assert books_body["total"] == 1 + book = books_body["books"][0] assert book["date_finished"] is None progress_resp = client.get(f"/api/books/{book['id']}/progress") @@ -417,7 +432,7 @@ def test_data_import_execute_progress_falls_back_to_now_without_date_finished( def test_data_export_no_datasets_raises_400(client: TestClient) -> None: resp = client.post("/api/data/export", json={"datasets": [], "format": "json"}) assert resp.status_code == 400 - assert resp.json()["detail"] == "error.exportNoDatasets" + assert resp.json()["detail"] == "Select at least one dataset to export." def test_data_import_parse_unsupported_content_type(client: TestClient) -> None: @@ -426,7 +441,7 @@ def test_data_import_parse_unsupported_content_type(client: TestClient) -> None: files={"file": ("test.exe", b"invalid", "application/octet-stream")}, ) assert resp.status_code == 415 - assert resp.json()["detail"] == "error.importUnsupportedContentType" + assert resp.json()["detail"] == "Unsupported upload content type. Use CSV or JSON files." def test_data_import_parse_invalid_json(client: TestClient) -> None: @@ -449,7 +464,7 @@ def test_data_import_mapping_update_existing(client: TestClient) -> None: json={ "name": "UpdateMe", "source_fields": ["F1"], - "mapping": {"F1": "title"}, + "mapping": {"title": {"source": "F1", "transform": None}}, }, ) # Save again with same name @@ -458,7 +473,7 @@ def test_data_import_mapping_update_existing(client: TestClient) -> None: json={ "name": "UpdateMe", "source_fields": ["F1", "F2"], - "mapping": {"F1": "title", "F2": "author"}, + "mapping": {"title": {"source": "F1", "transform": None}, "author": {"source": "F2", "transform": None}}, }, ) assert resp.status_code == 201 @@ -469,7 +484,7 @@ def test_data_import_mapping_update_existing(client: TestClient) -> None: def test_data_import_mapping_not_found(client: TestClient) -> None: resp = client.get("/api/data/import/mappings/99999") assert resp.status_code == 404 - assert resp.json()["detail"] == "error.importMappingNotFound" + assert resp.json()["detail"] == "Import mapping not found." resp = client.delete("/api/data/import/mappings/99999") assert resp.status_code == 404 @@ -509,10 +524,10 @@ def fake_commit() -> None: resp = client.post( "/api/data/import/mappings", - json={"name": "Conflict", "source_fields": ["F1"], "mapping": {"F1": "title"}}, + json={"name": "Conflict", "source_fields": ["F1"], "mapping": {"title": {"source": "F1", "transform": None}}}, ) assert resp.status_code == 409 - assert resp.json()["detail"] == "error.importMappingNameConflict" + assert resp.json()["detail"] == "A mapping with this name already exists." def test_data_import_execute_rollback_when_not_completed(client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: @@ -533,7 +548,7 @@ async def mock_execute(*args: object, **kwargs: object) -> AsyncGenerator[dict[s resp = client.post( "/api/data/import/execute", - json={"file_id": file_id, "mapping": {"Title": "title"}, "import_mode": "continue_on_error"}, + json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}}, "import_mode": "continue_on_error"}, ) assert resp.status_code == 200 events = _parse_sse(resp.text) @@ -561,7 +576,7 @@ async def mock_execute(*args: object, **kwargs: object) -> AsyncGenerator[dict[s resp = client.post( "/api/data/import/execute", - json={"file_id": file_id, "mapping": {"Title": "title"}, "import_mode": "continue_on_error"}, + json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}}, "import_mode": "continue_on_error"}, ) # StreamingResponse returns 200 before consuming the generator. # The generator raises CancelledError, which closes the stream. @@ -587,7 +602,7 @@ async def mock_execute(*args: object, **kwargs: object) -> AsyncGenerator[dict[s resp = client.post( "/api/data/import/execute", - json={"file_id": file_id, "mapping": {"Title": "title"}, "import_mode": "continue_on_error"}, + json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}}, "import_mode": "continue_on_error"}, ) events = _parse_sse(resp.text) assert any(event.get("message") == "error.importExecutionFailed" for event in events) diff --git a/backend/tests/test_data_import.py b/backend/tests/test_data_import.py index 38cc2a7..9e3dfd4 100644 --- a/backend/tests/test_data_import.py +++ b/backend/tests/test_data_import.py @@ -15,6 +15,7 @@ from app.config import settings from app.models import Book, ReadingStatus, User, UserRole +from app.schemas import ImportFieldConfig from app.services import data_import as di @@ -138,18 +139,18 @@ def test_delete_parsed_upload_missing_ok(tmp_path: Path, monkeypatch: MonkeyPatc def test_suggest_mapping_direct_alias() -> None: # "book title" should directly match via _ALIASES result = di.suggest_mapping(["book title"]) - assert result["book title"] == "title" + assert result["title"].source == "book title" def test_suggest_mapping_compact_match() -> None: # "booktitle" should match "book title" -> "title" result = di.suggest_mapping(["booktitle"]) - assert result["booktitle"] == "title" + assert result["title"].source == "booktitle" def test_suggest_mapping_no_match() -> None: result = di.suggest_mapping(["unknown_field"]) - assert "unknown_field" not in result + assert "unknown_field" not in {cfg.source for cfg in result.values()} # ── _parse_int ──────────────────────────────────────────────────────────────── @@ -230,25 +231,109 @@ def test_parse_reading_status_invalid() -> None: # ── _mapped_row ─────────────────────────────────────────────────────────────── -def test_mapped_row_skips_empty_target() -> None: - result = di._mapped_row({"A": "1"}, {"A": "", "B": "title"}) - assert result == {"title": None} # row.get("B") returns None +def test_mapped_row_skips_empty_source() -> None: + result = di._mapped_row( + {"A": "1"}, + {"title": ImportFieldConfig(source=""), "author": ImportFieldConfig(source="B")}, + {}, + {}, + ) + assert result == {"author": ""} # row.get("B") returns None -> "" + + +# ── _validate_mapping ───────────────────────────────────────────── +def test_validate_mapping_empty_mapping() -> None: + warnings, errors = di._validate_mapping({}, {"A"}) + assert any("title" in e for e in errors) -# ── _validate_mapping ───────────────────────────────────────────────────────── -def test_validate_mapping_duplicate_targets() -> None: - mapping = {"A": "title", "B": "title"} +def test_validate_mapping_invalid_targets() -> None: + mapping = {"title": ImportFieldConfig(source="A"), "invalid_field": ImportFieldConfig(source="B")} warnings, errors = di._validate_mapping(mapping, {"A", "B"}) - assert any("Multiple source fields map to 'title'" in w for w in warnings) + assert any("Invalid mapping target" in e for e in errors) def test_validate_mapping_source_missing() -> None: - mapping = {"A": "title", "C": "author"} + mapping = {"title": ImportFieldConfig(source="A"), "author": ImportFieldConfig(source="C")} warnings, errors = di._validate_mapping(mapping, {"A"}) assert any("Mapped source field missing in file: C" in w for w in warnings) +def test_validate_mapping_transform_invalid() -> None: + mapping = {"title": ImportFieldConfig(source="A", transform="bad syntax {{")} + warnings, errors = di._validate_mapping(mapping, {"A"}) + assert any(e.startswith("\x1ftitle\x1f") for e in errors) + + +def test_validate_mapping_transform_valid() -> None: + mapping = {"title": ImportFieldConfig(source="A", transform="value.upper()")} + warnings, errors = di._validate_mapping(mapping, {"A"}) + assert len(errors) == 0 + + +# ── preview_import ──────────────────────────────────────────────────────────── + +def test_preview_import_basic(session: Session, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "author": "Author"}], + "source_fields": ["title", "author"], + } + file_id = "test_preview" + path = di._temp_file_path(user.id, file_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.preview_import( + file_id, user, {"title": ImportFieldConfig(source="title"), "author": ImportFieldConfig(source="author")} + ) + assert len(result["preview_rows"]) == 1 + assert result["preview_rows"][0]["transformed"]["title"] == "Book" + assert result["row_count"] == 1 + + +def test_preview_import_with_transform(session: Session, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "book", "author": "author"}], + "source_fields": ["title", "author"], + } + file_id = "test_preview_transform" + path = di._temp_file_path(user.id, file_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.preview_import( + file_id, + user, + { + "title": ImportFieldConfig(source="title", transform="value.upper()"), + "author": ImportFieldConfig(source="author"), + }, + ) + assert result["preview_rows"][0]["transformed"]["title"] == "BOOK" + + +def test_preview_import_mapping_errors(session: Session, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book"}], + "source_fields": ["title"], + } + file_id = "test_preview_errors" + path = di._temp_file_path(user.id, file_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.preview_import(file_id, user, {"invalid_target": ImportFieldConfig(source="title")}) + assert len(result["preview_rows"]) == 0 + assert len(result["errors"]) > 0 + + # ── validate_import ─────────────────────────────────────────────────────────── def _create_test_user(session: Session) -> User: @@ -279,7 +364,7 @@ def test_validate_import_rating_out_of_range(session: Session, tmp_path: Path, m path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) - result = di.validate_import(file_id, user, {"title": "title", "rating": "rating"}, session) + result = di.validate_import(file_id, user, {"title": ImportFieldConfig(source="title"), "rating": ImportFieldConfig(source="rating")}, session) assert any("rating out of range" in w for w in result["warnings"]) @@ -295,7 +380,7 @@ def test_validate_import_date_started_after_finished(session: Session, tmp_path: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) - result = di.validate_import(file_id, user, {"title": "title", "started": "date_started", "finished": "date_finished"}, session) + result = di.validate_import(file_id, user, {"title": ImportFieldConfig(source="title"), "date_started": ImportFieldConfig(source="started"), "date_finished": ImportFieldConfig(source="finished")}, session) assert any("date_started is after date_finished" in e for e in result["errors"]) @@ -312,7 +397,7 @@ def test_validate_import_progress_warning_no_pages(session: Session, tmp_path: P path.write_text(json.dumps(payload)) result = di.validate_import( - file_id, user, {"title": "title", "status": "reading_status"}, session, create_progress_for_read=True + file_id, user, {"title": ImportFieldConfig(source="title"), "reading_status": ImportFieldConfig(source="status")}, session, create_progress_for_read=True ) assert any("marked as 'read' but has no page count" in w for w in result["warnings"]) @@ -334,7 +419,7 @@ def test_validate_import_isbn_already_exists(session: Session, tmp_path: Path, m path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) - result = di.validate_import(file_id, user, {"title": "title", "isbn": "isbn"}, session) + result = di.validate_import(file_id, user, {"title": ImportFieldConfig(source="title"), "isbn": ImportFieldConfig(source="isbn")}, session) assert any("ISBN already exists" in w for w in result["warnings"]) @@ -351,7 +436,7 @@ def test_validate_import_no_isbns(session: Session, tmp_path: Path, monkeypatch: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) - result = di.validate_import(file_id, user, {"title": "title"}, session) + result = di.validate_import(file_id, user, {"title": ImportFieldConfig(source="title")}, session) assert result["valid"] is True @@ -367,7 +452,7 @@ def test_validate_import_missing_title(session: Session, tmp_path: Path, monkeyp path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) - result = di.validate_import(file_id, user, {"title": "title"}, session) + result = di.validate_import(file_id, user, {"title": ImportFieldConfig(source="title")}, session) assert any("missing required field 'title'" in e for e in result["errors"]) @@ -383,10 +468,46 @@ def test_validate_import_value_error_caught(session: Session, tmp_path: Path, mo path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) - result = di.validate_import(file_id, user, {"title": "title", "pages": "page_count"}, session) + result = di.validate_import(file_id, user, {"title": ImportFieldConfig(source="title"), "page_count": ImportFieldConfig(source="pages")}, session) assert any("Row 1:" in e for e in result["errors"]) +def test_validate_import_cover_url_warns_on_non_url(session: Session, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "cover": "/local/path/image.jpg"}], + "source_fields": ["title", "cover"], + } + file_id = "test_cover_nonurl" + path = di._temp_file_path(user.id, file_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.validate_import( + file_id, user, {"title": ImportFieldConfig(source="title"), "cover_url": ImportFieldConfig(source="cover")}, session + ) + assert any("cover_url must be an HTTP(S) URL" in w for w in result["warnings"]) + + +def test_validate_import_cover_url_accepts_valid_url(session: Session, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "cover": "https://example.com/cover.jpg"}], + "source_fields": ["title", "cover"], + } + file_id = "test_cover_valid" + path = di._temp_file_path(user.id, file_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.validate_import( + file_id, user, {"title": ImportFieldConfig(source="title"), "cover_url": ImportFieldConfig(source="cover")}, session + ) + assert not any("cover_url" in w for w in result["warnings"]) + + # ── execute_import ──────────────────────────────────────────────────────────── @pytest.mark.anyio @@ -404,7 +525,7 @@ async def test_execute_import_mapping_errors(session: Session, tmp_path: Path, m events = [] async for event in di.execute_import( - file_id, user, {"title": "invalid_target"}, session, "continue_on_error" + file_id, user, {"invalid_target": ImportFieldConfig(source="title")}, session, "continue_on_error" ): events.append(event) assert any("Invalid mapping target" in e.get("message", "") for e in events) @@ -425,7 +546,7 @@ async def test_execute_import_rating_out_of_range_set_to_none(session: Session, events = [] async for event in di.execute_import( - file_id, user, {"title": "title", "rating": "rating"}, session, "continue_on_error" + file_id, user, {"title": ImportFieldConfig(source="title"), "rating": ImportFieldConfig(source="rating")}, session, "continue_on_error" ): events.append(event) complete = [e for e in events if e["event"] == "complete"][0] @@ -447,7 +568,7 @@ async def test_execute_import_date_started_after_finished(session: Session, tmp_ events = [] async for event in di.execute_import( - file_id, user, {"title": "title", "started": "date_started", "finished": "date_finished"}, session, "continue_on_error" + file_id, user, {"title": ImportFieldConfig(source="title"), "date_started": ImportFieldConfig(source="started"), "date_finished": ImportFieldConfig(source="finished")}, session, "continue_on_error" ): events.append(event) complete = [e for e in events if e["event"] == "complete"][0] @@ -475,7 +596,7 @@ async def _fake_download(url: str, covers_dir: str, client: Any, user_id: int) - events = [] async for event in di.execute_import( - file_id, user, {"title": "title", "cover": "cover_url"}, session, "continue_on_error" + file_id, user, {"title": ImportFieldConfig(source="title"), "cover_url": ImportFieldConfig(source="cover")}, session, "continue_on_error" ): events.append(event) complete = [e for e in events if e["event"] == "complete"][0] @@ -500,9 +621,8 @@ async def test_execute_import_progress_date_naive_tz_fix(session: Session, tmp_p async for event in di.execute_import( file_id, user, - {"title": "title", "status": "reading_status", "pages": "page_count", "finished": "date_finished"}, - session, - "continue_on_error", + {"title": ImportFieldConfig(source="title"), "reading_status": ImportFieldConfig(source="status"), "page_count": ImportFieldConfig(source="pages"), "date_finished": ImportFieldConfig(source="finished")}, + session, "continue_on_error", create_progress_for_read=True, ): events.append(event) @@ -525,7 +645,7 @@ async def test_execute_import_rollback_all_commit(session: Session, tmp_path: Pa events = [] async for event in di.execute_import( - file_id, user, {"title": "title"}, session, "rollback_all" + file_id, user, {"title": ImportFieldConfig(source="title")}, session, "rollback_all" ): events.append(event) complete = [e for e in events if e["event"] == "complete"][0] @@ -547,7 +667,7 @@ async def test_execute_import_missing_title_row(session: Session, tmp_path: Path events = [] async for event in di.execute_import( - file_id, user, {"title": "title"}, session, "continue_on_error" + file_id, user, {"title": ImportFieldConfig(source="title")}, session, "continue_on_error" ): events.append(event) complete = [e for e in events if e["event"] == "complete"][0] @@ -569,7 +689,7 @@ async def test_execute_import_rollback_all_error(session: Session, tmp_path: Pat events = [] async for event in di.execute_import( - file_id, user, {"title": "title"}, session, "rollback_all" + file_id, user, {"title": ImportFieldConfig(source="title")}, session, "rollback_all" ): events.append(event) assert any(e["event"] == "error" and "All changes rolled back" in e.get("message", "") for e in events) @@ -603,9 +723,8 @@ async def test_execute_import_progress_naive_date_finished(session: Session, tmp async for event in di.execute_import( file_id, user, - {"title": "title", "status": "reading_status", "pages": "page_count", "finished": "date_finished"}, - session, - "continue_on_error", + {"title": ImportFieldConfig(source="title"), "reading_status": ImportFieldConfig(source="status"), "page_count": ImportFieldConfig(source="pages"), "date_finished": ImportFieldConfig(source="finished")}, + session, "continue_on_error", create_progress_for_read=True, ): events.append(event) @@ -634,9 +753,8 @@ async def test_execute_import_progress_naive_utcnow_fallback(session: Session, t async for event in di.execute_import( file_id, user, - {"title": "title", "status": "reading_status", "pages": "page_count"}, - session, - "continue_on_error", + {"title": ImportFieldConfig(source="title"), "reading_status": ImportFieldConfig(source="status"), "page_count": ImportFieldConfig(source="pages")}, + session, "continue_on_error", create_progress_for_read=True, ): events.append(event) diff --git a/backend/tests/test_hygiene.py b/backend/tests/test_hygiene.py new file mode 100644 index 0000000..bbf1647 --- /dev/null +++ b/backend/tests/test_hygiene.py @@ -0,0 +1,355 @@ +"""Tests for the data hygiene endpoints.""" + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.models import Book, ReadingStatus, User +from app.routers import hygiene as hygiene_router + + +def _create_book(session: Session, user_id: int, **overrides: object) -> Book: + """Create a test book with sensible defaults.""" + defaults: dict = { + "title": "Test Book", + "author": "Test Author", + "isbn": None, + "publisher": "Test Publisher", + "published_year": 2020, + "page_count": 200, + "language": "EN", + "subtitle": None, + "blurb": "A test book.", + "cover_url": None, + "reading_status": ReadingStatus.want_to_read, + "user_id": user_id, + } + defaults.update(overrides) + book = Book(**defaults) + session.add(book) + session.commit() + session.refresh(book) + return book + + +class TestListMissing: + def test_missing_no_filters(self, client: TestClient, session: Session) -> None: + """Returns all books with any missing attribute.""" + user_id = 1 + _create_book(session, user_id, title="Complete Book", isbn="1111111111", blurb="desc", subtitle="sub", cover_url="/covers/x.jpg", language="EN", publisher="Pub", published_year=2020, page_count=200) + _create_book(session, user_id, title="Missing Author", author="", isbn="2222222222", blurb="desc", subtitle="sub", cover_url="/covers/x.jpg", language="EN", publisher="Pub", published_year=2020, page_count=200) + _create_book(session, user_id, title="Missing ISBN", author="Author", blurb="desc", subtitle="sub", cover_url="/covers/x.jpg", language="EN", publisher="Pub", published_year=2020, page_count=200) + + resp = client.get("/api/hygiene/missing") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + titles = {b["title"] for b in data["books"]} + assert "Missing Author" in titles + assert "Missing ISBN" in titles + assert "Complete Book" not in titles + + def test_missing_single_attribute(self, client: TestClient, session: Session) -> None: + """Filter by one attribute only.""" + user_id = 1 + _create_book(session, user_id, title="Missing Author", author="", isbn="1234567890") + _create_book(session, user_id, title="Missing ISBN", isbn=None, author="Author A") + _create_book(session, user_id, title="Has Both", author="Author B", isbn="123") + + resp = client.get("/api/hygiene/missing?attributes=isbn&match=all") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["books"][0]["title"] == "Missing ISBN" + + def test_missing_match_any(self, client: TestClient, session: Session) -> None: + """match=any returns books missing ANY of the requested attributes.""" + user_id = 1 + _create_book(session, user_id, title="Missing Author", author="") + _create_book(session, user_id, title="Missing Both", author="", isbn=None) + + resp = client.get("/api/hygiene/missing?attributes=author,isbn&match=any") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + + def test_missing_pagination(self, client: TestClient, session: Session) -> None: + """offset and limit work correctly.""" + user_id = 1 + for i in range(5): + _create_book(session, user_id, title=f"Missing {i}", author="", isbn=None) + + resp = client.get("/api/hygiene/missing?limit=2&offset=1") + assert resp.status_code == 200 + data = resp.json() + assert len(data["books"]) == 2 + assert data["total"] == 5 + + def test_missing_total_counts(self, client: TestClient, session: Session) -> None: + """total_missing_per_attribute accurately counts missing values.""" + user_id = 1 + _create_book(session, user_id, title="B1", author="", isbn="111") + _create_book(session, user_id, title="B2", author="", isbn="222") + _create_book(session, user_id, title="B3", isbn=None, author="Author") + + resp = client.get("/api/hygiene/missing?attributes=author,isbn") + assert resp.status_code == 200 + data = resp.json() + assert data["total_missing_per_attribute"]["author"] == 2 + assert data["total_missing_per_attribute"]["isbn"] == 1 + + def test_missing_respects_user_scoping(self, client: TestClient, session: Session, create_user_with_key) -> None: + """User A's missing books don't include user B's books.""" + user2, key2 = create_user_with_key(email="other@example.com") + _create_book(session, 1, title="User1 Missing Author", author="") + _create_book(session, user2.id, title="User2 Missing Author", author="") + + resp = client.get("/api/hygiene/missing?attributes=author") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["books"][0]["title"] == "User1 Missing Author" + + def test_missing_empty_author_treated_as_missing(self, client: TestClient, session: Session) -> None: + """Empty string author is treated as missing.""" + user_id = 1 + _create_book(session, user_id, title="Empty Author", author="") + _create_book(session, user_id, title="None Author", author=None) + + resp = client.get("/api/hygiene/missing?attributes=author") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + + def test_missing_page_count_zero_treated_as_missing(self, client: TestClient, session: Session) -> None: + """page_count of 0 is treated as missing.""" + user_id = 1 + _create_book(session, user_id, title="Zero Pages", page_count=0, author="Author") + _create_book(session, user_id, title="Has Pages", page_count=300, author="Author") + + resp = client.get("/api/hygiene/missing?attributes=page_count") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["books"][0]["title"] == "Zero Pages" + + +class TestBatchUpdate: + def test_batch_update_single_field(self, client: TestClient, session: Session) -> None: + """Happy path: updates multiple books.""" + user_id = 1 + b1 = _create_book(session, user_id, title="B1", author="Old") + b2 = _create_book(session, user_id, title="B2", author="Old") + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id, b2.id], + "field": "author", + "value": "New Author", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["updated"] == 2 + assert data["skipped"] == 0 + + session.refresh(b1) + session.refresh(b2) + assert b1.author == "New Author" + assert b2.author == "New Author" + + def test_batch_update_too_many_ids(self, client: TestClient, session: Session) -> None: + """Rejects more than 500 book IDs.""" + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": list(range(501)), + "field": "author", + "value": "X", + }) + assert resp.status_code == 422 + assert "500" in resp.json()["detail"] + + def test_batch_update_empty_ids(self, client: TestClient, session: Session) -> None: + """Rejects empty book_ids.""" + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [], + "field": "author", + "value": "X", + }) + assert resp.status_code == 422 + + def test_batch_update_invalid_field_type(self, client: TestClient, session: Session) -> None: + """Rejects invalid value for published_year.""" + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [1], + "field": "published_year", + "value": "not-a-year", + }) + assert resp.status_code == 422 + + def test_batch_update_partial_skipped(self, client: TestClient, session: Session) -> None: + """Books that already have the target value are reported as skipped.""" + user_id = 1 + b1 = _create_book(session, user_id, title="B1", author="Already Set") + b2 = _create_book(session, user_id, title="B2", author="Old") + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id, b2.id], + "field": "author", + "value": "Already Set", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["updated"] == 1 + assert data["skipped"] == 1 + assert b1.id in data["skipped_ids"] + + def test_batch_update_unauthorized_book(self, client: TestClient, session: Session, create_user_with_key) -> None: + """Trying to update a book owned by another user returns 404.""" + user2, _ = create_user_with_key(email="other@example.com") + b2 = _create_book(session, user2.id, title="Other's Book", author="Old") + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b2.id], + "field": "author", + "value": "X", + }) + assert resp.status_code == 404 + + def test_batch_update_clears_field(self, client: TestClient, session: Session) -> None: + """Setting value to null clears the field.""" + user_id = 1 + b1 = _create_book(session, user_id, title="B1", isbn="12345") + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "isbn", + "value": None, + }) + assert resp.status_code == 200 + session.refresh(b1) + assert b1.isbn is None + + def test_batch_update_invalid_language(self, client: TestClient, session: Session) -> None: + """Invalid language code returns 422.""" + user_id = 1 + b1 = _create_book(session, user_id, title="B1", language=None) + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "language", + "value": "ABC", + }) + assert resp.status_code == 422 + + def test_batch_update_valid_language(self, client: TestClient, session: Session) -> None: + """Valid language code is normalized to uppercase.""" + user_id = 1 + b1 = _create_book(session, user_id, title="B1", language=None) + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "language", + "value": "de", + }) + assert resp.status_code == 200 + session.refresh(b1) + assert b1.language == "DE" + + def test_batch_update_page_count(self, client: TestClient, session: Session) -> None: + """Setting page_count to a value requires non-negative integer.""" + user_id = 1 + b1 = _create_book(session, user_id, title="B1", page_count=0) + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "page_count", + "value": -1, + }) + assert resp.status_code == 422 + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "page_count", + "value": 250, + }) + assert resp.status_code == 200 + session.refresh(b1) + assert b1.page_count == 250 + + def test_batch_update_cover_url_valid(self, client: TestClient, session: Session, monkeypatch) -> None: + """Valid external cover URL is downloaded and stored as local path.""" + async def _fake_download(url: str, covers_dir: str, http_client: object, user_id: int) -> str: + return "1__cover.jpg" + monkeypatch.setattr(hygiene_router, "import_cover_from_url", _fake_download) + + b1 = _create_book(session, 1, title="B1", cover_url=None) + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "cover_url", + "value": "https://example.com/cover.jpg", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["updated"] == 1 + assert data["skipped"] == 0 + session.refresh(b1) + assert b1.cover_url == "/api/covers/1__cover.jpg" + + def test_batch_update_cover_url_invalid(self, client: TestClient, session: Session) -> None: + """Non-external cover URL is rejected.""" + b1 = _create_book(session, 1, title="B1", cover_url=None) + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "cover_url", + "value": "data:image/png;base64,iVBORw0KGgo=", + }) + assert resp.status_code == 422 + + def test_batch_update_cover_url_download_fails(self, client: TestClient, session: Session, monkeypatch) -> None: + """When download fails, cover_url is set to None.""" + async def _fake_download(url: str, covers_dir: str, http_client: object, user_id: int) -> None: + return None + monkeypatch.setattr(hygiene_router, "import_cover_from_url", _fake_download) + + b1 = _create_book(session, 1, title="B1", cover_url=None) + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "cover_url", + "value": "https://example.com/fail.jpg", + }) + assert resp.status_code == 200 + session.refresh(b1) + assert b1.cover_url is None + + def test_batch_update_cover_url_none(self, client: TestClient, session: Session) -> None: + """Setting cover_url to None clears it.""" + b1 = _create_book(session, 1, title="B1", cover_url="/api/covers/old.jpg") + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "cover_url", + "value": None, + }) + assert resp.status_code == 200 + session.refresh(b1) + assert b1.cover_url is None + + def test_batch_update_cover_url_already_set(self, client: TestClient, session: Session, monkeypatch) -> None: + """Book already has the target cover path — skipped after download.""" + async def _fake_download(url: str, covers_dir: str, http_client: object, user_id: int) -> str: + return "1__same.jpg" + monkeypatch.setattr(hygiene_router, "import_cover_from_url", _fake_download) + + b1 = _create_book(session, 1, title="B1", cover_url="/api/covers/1__same.jpg") + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "cover_url", + "value": "https://example.com/same.jpg", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["updated"] == 0 + assert data["skipped"] == 1 + assert b1.id in data["skipped_ids"] diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index b99afc5..3f8e35e 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -1074,7 +1074,7 @@ def test_normalize_language_invalid_code() -> None: with pytest.raises(HTTPException) as exc_info: import_router._normalize_language("english") assert exc_info.value.status_code == 422 - assert "invalidLanguageCode" in exc_info.value.detail + assert "Language must be a 2-letter ISO code" in exc_info.value.detail def test_raise_integrity_conflict_isbn_unique() -> None: @@ -1083,7 +1083,7 @@ def test_raise_integrity_conflict_isbn_unique() -> None: with pytest.raises(HTTPException) as exc_info: import_router._raise_integrity_conflict(exc) assert exc_info.value.status_code == 409 - assert "isbnAlreadyExists" in exc_info.value.detail + assert "This ISBN is already used by another book." in exc_info.value.detail def test_raise_integrity_conflict_other() -> None: @@ -1120,7 +1120,7 @@ def fake_flush(*args: Any, **kwargs: Any) -> None: } resp = client.post("/api/import", json=payload) assert resp.status_code == 409 - assert "isbnAlreadyExists" in resp.json()["detail"] + assert "This ISBN is already used by another book." in resp.json()["detail"] def test_import_book_commit_integrity_error(client: TestClient, session: Session, monkeypatch: MonkeyPatch) -> None: @@ -1147,4 +1147,4 @@ def fake_commit() -> None: } resp = client.post("/api/import", json=payload) assert resp.status_code == 409 - assert "isbnAlreadyExists" in resp.json()["detail"] + assert "This ISBN is already used by another book." in resp.json()["detail"] diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 4ba64b3..3d97a65 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -10,18 +10,20 @@ from starlette.responses import JSONResponse, Response -def test_periodic_temp_cleanup_logs_success() -> None: +def test_periodic_maintenance_logs_success() -> None: """Successful cleanup should log info and reset failures.""" - from app.main import _periodic_temp_cleanup + from app.main import _periodic_maintenance async def _run() -> MagicMock: with patch("app.main.cleanup_temp_files"): - with patch("app.main.asyncio.sleep", side_effect=[None, asyncio.CancelledError()]): - with patch("app.main.logger") as mock_logger: - try: - await _periodic_temp_cleanup() - except asyncio.CancelledError: - pass + with patch("app.main.cleanup_orphan_covers", return_value=0): + with patch("app.database.get_session"): + with patch("app.main.asyncio.sleep", side_effect=[None, asyncio.CancelledError()]): + with patch("app.main.logger") as mock_logger: + try: + await _periodic_maintenance() + except asyncio.CancelledError: + pass return mock_logger mock_logger = asyncio.run(_run()) @@ -29,35 +31,56 @@ async def _run() -> MagicMock: assert "cleanup completed" in str(mock_logger.info.call_args_list[0]) -def test_periodic_temp_cleanup_logs_warning_on_first_failure() -> None: +def test_periodic_maintenance_logs_success_with_orphan_covers() -> None: + """Orphaned cover cleanup should log when files are deleted.""" + from app.main import _periodic_maintenance + + async def _run() -> MagicMock: + with patch("app.main.cleanup_temp_files"): + with patch("app.main.cleanup_orphan_covers", return_value=3): + with patch("app.database.get_session"): + with patch("app.main.asyncio.sleep", side_effect=[None, asyncio.CancelledError()]): + with patch("app.main.logger") as mock_logger: + try: + await _periodic_maintenance() + except asyncio.CancelledError: + pass + return mock_logger + + mock_logger = asyncio.run(_run()) + assert mock_logger.info.call_count == 2 + assert "orphaned cover cleanup" in str(mock_logger.info.call_args_list[1]).lower() + + +def test_periodic_maintenance_logs_warning_on_first_failure() -> None: """First failure should log a warning.""" - from app.main import _periodic_temp_cleanup + from app.main import _periodic_maintenance async def _run() -> MagicMock: with patch("app.main.cleanup_temp_files", side_effect=RuntimeError("boom")): with patch("app.main.asyncio.sleep", side_effect=[None, asyncio.CancelledError()]): with patch("app.main.logger") as mock_logger: try: - await _periodic_temp_cleanup() + await _periodic_maintenance() except asyncio.CancelledError: pass return mock_logger mock_logger = asyncio.run(_run()) assert mock_logger.warning.call_count == 1 - assert "cleanup failed" in str(mock_logger.warning.call_args_list[0]).lower() + assert "maintenance failed" in str(mock_logger.warning.call_args_list[0]).lower() -def test_periodic_temp_cleanup_logs_error_after_three_failures() -> None: +def test_periodic_maintenance_logs_error_after_three_failures() -> None: """Three consecutive failures should log an error.""" - from app.main import _periodic_temp_cleanup + from app.main import _periodic_maintenance async def _run() -> MagicMock: with patch("app.main.cleanup_temp_files", side_effect=RuntimeError("boom")): with patch("app.main.asyncio.sleep", side_effect=[None, None, None, asyncio.CancelledError()]): with patch("app.main.logger") as mock_logger: try: - await _periodic_temp_cleanup() + await _periodic_maintenance() except asyncio.CancelledError: pass return mock_logger diff --git a/backend/tests/test_progress.py b/backend/tests/test_progress.py index ef8cec3..fe60127 100644 --- a/backend/tests/test_progress.py +++ b/backend/tests/test_progress.py @@ -6,7 +6,7 @@ def _create_book(client: TestClient, **kwargs: Any) -> dict[str, Any]: """Helper to create a book via the API and return the JSON response.""" - payload = {"title": "Progress Test Book", **kwargs} + payload = {"title": "Progress Test Book", "author": "Test Author", "page_count": 200, **kwargs} resp = client.post("/api/books", json=payload) assert resp.status_code == 201 return resp.json() @@ -28,13 +28,6 @@ def test_create_progress_page_exceeds_page_count(client: TestClient) -> None: assert resp.status_code == 422 -def test_create_progress_no_page_count_allowed(client: TestClient) -> None: - book = _create_book(client, page_count=None) - resp = client.post(f"/api/books/{book['id']}/progress", json={"page": 50}) - assert resp.status_code == 201 - assert resp.json()["page"] == 50 - - def test_create_progress_wrong_user_returns_404(client: TestClient, create_user_with_key: Callable[..., Any]) -> None: book = _create_book(client) _user2, key2 = create_user_with_key(email="other@example.com") @@ -129,3 +122,46 @@ def test_create_progress_appends_new_entry(client: TestClient) -> None: client.post(f"/api/books/{book['id']}/progress", json={"page": 30}) resp = client.get(f"/api/books/{book['id']}/progress") assert len(resp.json()) == 2 + + +def test_update_progress_entry_date(client: TestClient) -> None: + """PATCH updates the created_at date of a progress entry.""" + from datetime import datetime, timezone + book = _create_book(client, page_count=200) + entry = client.post(f"/api/books/{book['id']}/progress", json={"page": 50}).json() + + resp = client.patch( + f"/api/books/{book['id']}/progress/{entry['id']}", + json={"created_at": "2024-06-15T08:30:00Z"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == entry["id"] + assert data["page"] == 50 + expected = datetime(2024, 6, 15, 8, 30, tzinfo=timezone.utc) + assert datetime.fromisoformat(data["created_at"]) == expected + + resp = client.get(f"/api/books/{book['id']}/progress") + assert datetime.fromisoformat(resp.json()[0]["created_at"]) == expected + + +def test_update_progress_entry_wrong_user_returns_404(client: TestClient, create_user_with_key: Callable[..., Any]) -> None: + book = _create_book(client) + entry = client.post(f"/api/books/{book['id']}/progress", json={"page": 10}).json() + _user2, key2 = create_user_with_key(email="other@example.com") + with TestClient(client.app) as c2: # type: ignore[arg-type] + c2.headers.update({"X-API-Key": key2}) + resp = c2.patch( + f"/api/books/{book['id']}/progress/{entry['id']}", + json={"created_at": "2024-06-15T08:30:00Z"}, + ) + assert resp.status_code == 404 + + +def test_update_progress_entry_not_found(client: TestClient) -> None: + book = _create_book(client, page_count=200) + resp = client.patch( + f"/api/books/{book['id']}/progress/99999", + json={"created_at": "2024-06-15T08:30:00Z"}, + ) + assert resp.status_code == 404 diff --git a/backend/tests/test_statistics.py b/backend/tests/test_statistics.py index c67ac71..dfc39a0 100644 --- a/backend/tests/test_statistics.py +++ b/backend/tests/test_statistics.py @@ -12,7 +12,7 @@ def _create_book(client: Any, **overrides: Any) -> dict[str, Any]: """Helper to create a book via the API and return the JSON response.""" - payload = {"title": "Book", **overrides} + payload = {"title": "Book", "author": "Test Author", "page_count": 100, **overrides} resp = client.post("/api/books", json=payload) assert resp.status_code == 201 return resp.json() @@ -49,19 +49,22 @@ def test_statistics_core_metrics_and_distributions(client: Any) -> None: _create_book( client, title="Read Jan 1", author="Author A", cover_url="/api/covers/a1.jpg", page_count=100, language="EN", reading_status="read", + date_started="2026-01-10T10:00:00Z", date_finished="2026-01-10T10:00:00Z", ) _create_book( client, title="Read Jan 2", author="Author A", cover_url="/api/covers/a2.jpg", page_count=200, language="EN", reading_status="read", + date_started="2026-01-15T10:00:00Z", date_finished="2026-01-15T10:00:00Z", ) _create_book( client, title="Read Mar", author="Author B", page_count=300, language="DE", reading_status="read", + date_started="2026-03-01T10:00:00Z", date_finished="2026-03-01T10:00:00Z", ) - _create_book(client, title="Want", page_count=120, language="EN", reading_status="want_to_read") + _create_book(client, title="Want", author="Author B", page_count=120, language="EN", reading_status="want_to_read") dnf = _create_book(client, title="DNF", author="Author A", language="FR", reading_status="did_not_finish") client.post(f"/api/books/{dnf['id']}/progress", json={"page": 40}) @@ -73,24 +76,35 @@ def test_statistics_core_metrics_and_distributions(client: Any) -> None: assert data["avg_books_per_month"] == 1.5 assert data["busiest_month"] == "2026-01" assert data["busiest_month_count"] == 2 - assert data["avg_page_count"] == 180 + assert data["avg_page_count"] == 164.0 assert data["most_popular_language"] == "EN" assert data["most_popular_language_count"] == 3 assert data["status_distribution"] == { "want_to_read": 1, "currently_reading": 0, "read": 3, "did_not_finish": 1, } assert data["page_buckets"] == {"pages_to_read": 120, "pages_read": 600, "pages_wasted": 60} + now = datetime.now(timezone.utc) + expected_months = [] + year, month = 2026, 1 + while (year < now.year) or (year == now.year and month <= now.month): + expected_months.append(f"{year:04d}-{month:02d}") + month += 1 + if month > 12: + month = 1 + year += 1 + current_month = f"{now.year:04d}-{now.month:02d}" assert data["books_finished_per_month"] == [ - {"month": "2026-01", "count": 2}, - {"month": "2026-02", "count": 0}, - {"month": "2026-03", "count": 1}, + {"month": m, "count": 2 if m == "2026-01" else 1 if m == "2026-03" else 0} + for m in expected_months ] assert data["pages_read_per_month"] == [ - {"month": "2026-01", "pages": 300}, - {"month": "2026-02", "pages": 0}, - {"month": "2026-03", "pages": 300}, + {"month": m, "pages": 300 if m == "2026-01" else 300 if m == "2026-03" else 20 if m == current_month else 0} + for m in expected_months + ] + expected_years = list(range(2026, now.year + 1)) + assert data["books_finished_per_year"] == [ + {"year": y, "count": 3 if y == 2026 else 0} for y in expected_years ] - assert data["books_finished_per_year"] == [{"year": 2026, "count": 3}] assert len(data["top_authors"]) == 2 assert data["top_authors"][0]["author"] == "Author A" assert data["top_authors"][0]["book_count"] == 3 @@ -98,7 +112,7 @@ def test_statistics_core_metrics_and_distributions(client: Any) -> None: assert "/api/covers/a1.jpg" in top_a_cover_urls assert "/api/covers/a2.jpg" in top_a_cover_urls assert data["top_authors"][1]["author"] == "Author B" - assert data["top_authors"][1]["book_count"] == 1 + assert data["top_authors"][1]["book_count"] == 2 def test_statistics_top_authors_limit_and_tiebreaker(client: Any) -> None: @@ -147,13 +161,26 @@ def test_statistics_timezone_month_bucketing(client: Any, session: Session) -> N session.commit() _create_book(client, title="Boundary", reading_status="read", - page_count=222, date_finished="2026-05-01T03:00:00Z") + page_count=222, date_started="2026-05-01T03:00:00Z", date_finished="2026-05-01T03:00:00Z") resp = client.get("/api/statistics") assert resp.status_code == 200 data = resp.json() - assert data["books_finished_per_month"] == [{"month": "2026-04", "count": 1}] - assert data["pages_read_per_month"] == [{"month": "2026-04", "pages": 222}] + now = datetime.now(timezone.utc) + expected_months = [] + year, month = 2026, 4 + while (year < now.year) or (year == now.year and month <= now.month): + expected_months.append(f"{year:04d}-{month:02d}") + month += 1 + if month > 12: + month = 1 + year += 1 + assert data["books_finished_per_month"] == [ + {"month": m, "count": 1 if m == "2026-04" else 0} for m in expected_months + ] + assert data["pages_read_per_month"] == [ + {"month": m, "pages": 222 if m == "2026-04" else 0} for m in expected_months + ] def test_statistics_pages_wasted_ignores_non_dnf(client: Any) -> None: @@ -200,6 +227,7 @@ def test_pages_per_day_skips_books_missing_dates_or_pages(client: Any) -> None: """Books without date_started, date_finished, or page_count should be skipped in fallback.""" _create_book(client, title="No Dates", reading_status="read", page_count=100) _create_book(client, title="No Pages", reading_status="read", + page_count=0, date_started="2026-05-01T10:00:00Z", date_finished="2026-05-02T10:00:00Z") resp = client.get("/api/statistics/pages-per-day?days=730") @@ -235,13 +263,21 @@ def test_statistics_books_spanning_multiple_years(client: Any) -> None: resp = client.get("/api/statistics") assert resp.status_code == 200 data = resp.json() + now = datetime.now(timezone.utc) + expected_months = [] + year, month = 2025, 12 + while (year < now.year) or (year == now.year and month <= now.month): + expected_months.append(f"{year:04d}-{month:02d}") + month += 1 + if month > 12: + month = 1 + year += 1 assert data["books_finished_per_month"] == [ - {"month": "2025-12", "count": 1}, - {"month": "2026-01", "count": 1}, + {"month": m, "count": 1 if m in ("2025-12", "2026-01") else 0} for m in expected_months ] + expected_years = list(range(2025, now.year + 1)) assert data["books_finished_per_year"] == [ - {"year": 2025, "count": 1}, - {"year": 2026, "count": 1}, + {"year": y, "count": 1 if y == 2025 else 1 if y == 2026 else 0} for y in expected_years ] diff --git a/backend/tests/test_transform_engine.py b/backend/tests/test_transform_engine.py new file mode 100644 index 0000000..582c523 --- /dev/null +++ b/backend/tests/test_transform_engine.py @@ -0,0 +1,177 @@ +"""Unit tests for the transform engine.""" + +import pytest + +from app.services import transform_engine as te + + +class TestValidateTransform: + def test_empty_code_returns_error(self) -> None: + assert te.validate_transform("") == ["Transform code is empty"] + assert te.validate_transform(" ") == ["Transform code is empty"] + + def test_valid_expression(self) -> None: + errors = te.validate_transform("value.strip().lower()") + assert errors == [] + + def test_valid_multiline(self) -> None: + code = """\ +match = re.search(r'\\d{4}', value) +if match: + return match.group(0) +return '' +""" + errors = te.validate_transform(code) + assert errors == [] + + def test_forbidden_import(self) -> None: + errors = te.validate_transform("import os") + assert any("Forbidden import" in e for e in errors) + + def test_forbidden_exec(self) -> None: + errors = te.validate_transform("exec('print(1)')") + assert len(errors) > 0 + + def test_forbidden_eval(self) -> None: + errors = te.validate_transform("eval('1+1')") + assert len(errors) > 0 + + def test_forbidden_open(self) -> None: + errors = te.validate_transform("open('/etc/passwd')") + assert len(errors) > 0 + + def test_forbidden_function_def(self) -> None: + errors = te.validate_transform("def foo(): pass") + assert any("FunctionDef" in e for e in errors) + + def test_forbidden_class_def(self) -> None: + errors = te.validate_transform("class Foo: pass") + assert any("ClassDef" in e for e in errors) + + def test_forbidden_with(self) -> None: + errors = te.validate_transform("with open('x') as f: pass") + assert any("With" in e for e in errors) + + def test_forbidden_try(self) -> None: + errors = te.validate_transform("try:\n pass\nexcept:\n pass") + assert any("Try" in e for e in errors) + + def test_forbidden_lambda(self) -> None: + errors = te.validate_transform("(lambda x: x)(1)") + assert any("Lambda" in e for e in errors) + + def test_syntax_error(self) -> None: + errors = te.validate_transform("value.strip(") + assert any("Syntax error" in e for e in errors) + + +class TestCompileTransform: + def test_simple_expression(self) -> None: + fn = te.compile_transform("return value.upper()") + result = te.execute_transform(fn, "hello", {}, {}) + assert result == "HELLO" + + def test_multiline_with_if(self) -> None: + code = """\ +match = re.search(r'\\d{4}', value) +if match: + return match.group(0) +return '' +""" + fn = te.compile_transform(code) + result = te.execute_transform(fn, "Published 2024", {}, {}) + assert result == "2024" + + def test_access_to_datetime(self) -> None: + # datetime is already available in globals; no import needed + code = """\ +dt = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S') +return dt.date().isoformat() +""" + fn = te.compile_transform(code) + result = te.execute_transform(fn, "2024-01-15T10:30:00", {}, {}) + assert result == "2024-01-15" + + def test_access_to_math(self) -> None: + code = "return str(math.ceil(float(value)))" + fn = te.compile_transform(code) + result = te.execute_transform(fn, "3.14", {}, {}) + assert result == "4" + + def test_row_access(self) -> None: + code = "return value + ' by ' + row.get('Author', '')" + fn = te.compile_transform(code) + result = te.execute_transform(fn, "Dune", {"Author": "Herbert"}, {}) + assert result == "Dune by Herbert" + + def test_context_access(self) -> None: + code = "return str(context.get('row_number', 0))" + fn = te.compile_transform(code) + result = te.execute_transform(fn, "x", {}, {"row_number": 42}) + assert result == "42" + + def test_empty_code_raises(self) -> None: + with pytest.raises(ValueError, match="empty"): + te.compile_transform("") + + def test_rejected_import_raises(self) -> None: + with pytest.raises(ValueError): + te.compile_transform("import os") + + +class TestExecuteTransform: + def test_none_return_becomes_empty_string(self) -> None: + fn = te.compile_transform("return None") + result = te.execute_transform(fn, "x", {}, {}) + assert result == "" + + def test_int_return_becomes_string(self) -> None: + fn = te.compile_transform("return 42") + result = te.execute_transform(fn, "x", {}, {}) + assert result == "42" + + def test_bool_return_becomes_string(self) -> None: + fn = te.compile_transform("return True") + result = te.execute_transform(fn, "x", {}, {}) + assert result == "True" + + def test_list_return_becomes_string(self) -> None: + fn = te.compile_transform("return [1, 2, 3]") + result = te.execute_transform(fn, "x", {}, {}) + assert result == "[1, 2, 3]" + + def test_no_return_statement(self) -> None: + fn = te.compile_transform("x = value.strip()") + result = te.execute_transform(fn, " hello ", {}, {}) + # Function without explicit return returns None, coerced to "" + assert result == "" + + +class TestSecurity: + def test_cannot_import_os(self) -> None: + with pytest.raises(ValueError): + te.compile_transform("import os") + + def test_cannot_import_sys(self) -> None: + with pytest.raises(ValueError): + te.compile_transform("import sys") + + def test_cannot_use_eval(self) -> None: + with pytest.raises(ValueError): + te.compile_transform("eval('1+1')") + + def test_cannot_use_exec(self) -> None: + with pytest.raises(ValueError): + te.compile_transform("exec('print(1)')") + + def test_cannot_open_file(self) -> None: + with pytest.raises(ValueError): + te.compile_transform("open('/etc/passwd')") + + def test_cannot_access_dunder(self) -> None: + with pytest.raises((ValueError, SyntaxError)): + te.compile_transform("return value.__class__") + + def test_cannot_use_yield(self) -> None: + with pytest.raises(ValueError): + te.compile_transform("yield value") diff --git a/backend/uv.lock b/backend/uv.lock index cefc4dc..9cdb5ef 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "librislog-backend" version = "0.0.0.dev0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "authlib" }, @@ -502,6 +502,8 @@ dev = [ { name = "pytest" }, { name = "pytest-anyio" }, { name = "pytest-cov" }, + { name = "rich" }, + { name = "typer" }, ] [package.metadata] @@ -531,6 +533,8 @@ dev = [ { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-anyio", specifier = ">=0.0.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "rich", specifier = ">=13.9.4" }, + { name = "typer", specifier = ">=0.15.2" }, ] [[package]] @@ -940,6 +944,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/56/97c0d4e05e9e0c7d712642ddbaf176d723bf5590b29a3b571cf1038cd06b/scrapling-0.4.8-py3-none-any.whl", hash = "sha256:ea6e5f13760740489544cf0f72e69014260e1658d19cf2bc337b82ac91d45782", size = 158559, upload-time = "2026-05-11T02:00:46.704Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.49" @@ -1001,6 +1014,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/90/39a85a4b63c84213e78b3c17d22e1bf45328acf8ebb33ef93be30d0a3911/tld-0.13.2-py2.py3-none-any.whl", hash = "sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c", size = 296743, upload-time = "2026-03-06T23:50:32.465Z" }, ] +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" diff --git a/cli/pyproject.toml b/cli/pyproject.toml new file mode 100644 index 0000000..7d04945 --- /dev/null +++ b/cli/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "llc" +version = "0.1.0" +description = "Developer CLI for the librislog project" +requires-python = ">=3.14" +dependencies = [ + "questionary>=2.0.0", + "typer>=0.25.1", + "rich>=13.9.4", +] + +[project.scripts] +llc = "llc.main:app" + +[tool.uv] +package = true + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/llc"] + +[dependency-groups] +dev = [ + "pytest>=9.0.3", + "pytest-mock>=3.14.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/cli/src/llc/__init__.py b/cli/src/llc/__init__.py new file mode 100644 index 0000000..b48abe5 --- /dev/null +++ b/cli/src/llc/__init__.py @@ -0,0 +1,3 @@ +from llc.main import app + +__all__ = ["app"] diff --git a/cli/src/llc/__main__.py b/cli/src/llc/__main__.py new file mode 100644 index 0000000..aeb06b5 --- /dev/null +++ b/cli/src/llc/__main__.py @@ -0,0 +1,3 @@ +from llc.main import app + +app() diff --git a/cli/src/llc/_gh.py b/cli/src/llc/_gh.py new file mode 100644 index 0000000..0d362b8 --- /dev/null +++ b/cli/src/llc/_gh.py @@ -0,0 +1,60 @@ +import json +import subprocess + + +class GhError(Exception): + pass + + +def _run_gh(args: list[str], *, interactive: bool = False) -> subprocess.CompletedProcess: + if interactive: + try: + return subprocess.run(["gh", *args], check=True) + except FileNotFoundError: + raise GhError("GitHub CLI (gh) is not installed or not on PATH") + except subprocess.CalledProcessError as exc: + raise GhError(str(exc)) + try: + return subprocess.run( + ["gh", *args], + capture_output=True, + text=True, + check=True, + ) + except FileNotFoundError: + raise GhError("GitHub CLI (gh) is not installed or not on PATH") + except subprocess.CalledProcessError as exc: + msg = exc.stderr.strip() if exc.stderr else str(exc) + raise GhError(msg) + + +def check_gh() -> None: + try: + subprocess.run(["gh", "auth", "status"], capture_output=True, check=True) + except FileNotFoundError: + raise GhError("GitHub CLI (gh) is not installed") + except subprocess.CalledProcessError: + raise GhError("GitHub CLI (gh) is not authenticated — run `gh auth login`") + + +def list_open_prs() -> list[dict]: + result = _run_gh([ + "pr", "list", "--state", "open", + "--json", "number,title,headRefName,baseRefName,author", + "--limit", "100", + ]) + return json.loads(result.stdout) + + +def create_pr(*, base: str, head: str) -> None: + _run_gh([ + "pr", "create", + "--base", base, + "--head", head, + "--fill", + "--assignee", "@me", + ], interactive=True) + + +def merge_pr(pr_number: int) -> None: + _run_gh(["pr", "merge", str(pr_number), "-m"], interactive=True) diff --git a/cli/src/llc/_git.py b/cli/src/llc/_git.py new file mode 100644 index 0000000..8b4cb9d --- /dev/null +++ b/cli/src/llc/_git.py @@ -0,0 +1,138 @@ +import subprocess +from pathlib import Path + + +class GitError(Exception): + pass + + +def _ensure_git_repo() -> None: + """Check CWD is inside a git repository or raise GitError.""" + try: + subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + capture_output=True, + check=True, + cwd=Path.cwd(), + ) + except FileNotFoundError: + raise GitError("git is not installed or not on PATH") + except subprocess.CalledProcessError: + raise GitError("Not inside a git repository") + + +def _run_git(args: list[str], *, interactive: bool = False) -> subprocess.CompletedProcess: + if interactive: + try: + return subprocess.run(["git", *args], check=True) + except FileNotFoundError: + raise GitError("git is not installed or not on PATH") + except subprocess.CalledProcessError as exc: + raise GitError(str(exc)) + try: + return subprocess.run( + ["git", *args], + capture_output=True, + text=True, + check=True, + ) + except FileNotFoundError: + raise GitError("git is not installed or not on PATH") + except subprocess.CalledProcessError as exc: + msg = exc.stderr.strip() if exc.stderr else str(exc) + raise GitError(msg) + + +def has_uncommitted_changes() -> bool: + result = _run_git(["status", "--porcelain"]) + return bool(result.stdout.strip()) + + +def current_branch() -> str: + result = _run_git(["branch", "--show-current"]) + return result.stdout.strip() + + +def local_branches() -> list[str]: + result = _run_git(["branch", "--format=%(refname:short)"]) + return [b.strip() for b in result.stdout.strip().splitlines() if b.strip()] + + +def remote_origin_branches() -> list[str]: + _run_git(["fetch", "--prune"]) + result = _run_git(["branch", "-r", "--format=%(refname:short)"]) + branches = [b.strip() for b in result.stdout.strip().splitlines() if b.strip()] + prefix = "origin/" + return [b[len(prefix):] for b in branches if b.startswith(prefix) and b != "origin/HEAD"] + + +def fetch_tags(pattern: str = "v*") -> list[str]: + result = _run_git(["tag", "-l", pattern, "--sort=-version:refname"]) + return [t.strip() for t in result.stdout.strip().splitlines() if t.strip()] + + +def checkout(branch: str) -> None: + _run_git(["checkout", branch], interactive=True) + + +def pull(branch: str) -> None: + _run_git(["pull", "origin", branch], interactive=True) + + +def tag(tagname: str) -> None: + _run_git(["tag", tagname]) + + +def push_tag(tagname: str) -> None: + _run_git(["push", "origin", tagname], interactive=True) + + +def merge(remote_branch: str) -> None: + _run_git(["merge", f"origin/{remote_branch}"], interactive=True) + + +def push() -> None: + _run_git(["push"], interactive=True) + + +def fetch() -> None: + _run_git(["fetch", "origin"]) + + +def delete_tag(tagname: str) -> None: + _run_git(["tag", "-d", tagname]) + + +def delete_remote_tag(tagname: str) -> None: + _run_git(["push", "origin", "--delete", tagname], interactive=True) + + +def tag_exists(tagname: str) -> bool: + try: + _run_git(["rev-parse", "-q", "--verify", f"refs/tags/{tagname}"]) + return True + except GitError: + return False + + +def get_upstream_branch() -> str | None: + try: + result = _run_git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]) + upstream = result.stdout.strip() + if upstream.startswith("origin/"): + return upstream[len("origin/"):] + return None + except GitError: + return None + + +def create_branch(branch_name: str, base_branch: str) -> None: + _run_git(["branch", branch_name, base_branch]) + + +def delete_branch(branch_name: str) -> None: + _run_git(["branch", "-D", branch_name]) + + +def push_and_set_upstream(branch_name: str) -> None: + _run_git(["push", "--set-upstream", "origin", branch_name], interactive=True) diff --git a/cli/src/llc/_interactive.py b/cli/src/llc/_interactive.py new file mode 100644 index 0000000..02229eb --- /dev/null +++ b/cli/src/llc/_interactive.py @@ -0,0 +1,27 @@ +import questionary +from rich.console import Console + +console = Console() + + +def confirm(prompt_text: str, *, default: bool = True) -> bool: + result = questionary.confirm(prompt_text, default=default).ask() + if result is None: + return False + return result + + +def prompt_text(prompt_text: str, *, default: str | None = None) -> str | None: + return questionary.text(prompt_text, default=default or "").ask() + + +def select_from_list( + items: list[str], + *, + title: str = "Select an option", + preselect: str | None = None, +) -> str | None: + if not items: + console.print("[yellow]No items to select.[/yellow]") + return None + return questionary.select(title, choices=items, default=preselect).ask() diff --git a/cli/src/llc/branch.py b/cli/src/llc/branch.py new file mode 100644 index 0000000..719964f --- /dev/null +++ b/cli/src/llc/branch.py @@ -0,0 +1,96 @@ +import typer +import llc._git +import llc._interactive +from llc._interactive import console +from llc._git import GitError + + +def cmd_delete() -> None: + """Delete a local branch.""" + try: + llc._git._ensure_git_repo() + + branches = llc._git.local_branches() + if not branches: + console.print("[yellow]No local branches found.[/yellow]") + raise typer.Exit() + + selected = llc._interactive.select_from_list( + branches, title="Select a branch to delete" + ) + if selected is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + current = llc._git.current_branch() + switch_to: str | None = None + + if selected == current: + others = [b for b in branches if b != current] + if not others: + console.print("[red]Cannot delete the only branch.[/red]") + raise typer.Exit(code=1) + switch_to = llc._interactive.select_from_list( + others, title="Select a branch to switch to before deletion" + ) + if switch_to is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + confirmed = llc._interactive.confirm( + f"Are you sure you want to delete branch «{selected}»?", + default=False, + ) + if not confirmed: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + if switch_to: + console.print(f"Switching to [green]{switch_to}[/green]...") + llc._git.checkout(switch_to) + + console.print(f"Deleting branch [red]{selected}[/red]...") + llc._git.delete_branch(selected) + console.print(f"[green]✓[/green] Branch [bold]{selected}[/bold] deleted.") + + except GitError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) + + +def cmd_create() -> None: + """Create a new branch from a base branch.""" + try: + llc._git._ensure_git_repo() + + name = llc._interactive.prompt_text("Branch name") + if not name: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + branches = llc._git.local_branches() + if not branches: + console.print("[red]No local branches available as base.[/red]") + raise typer.Exit(code=1) + + preselect = "develop" if "develop" in branches else None + base = llc._interactive.select_from_list( + branches, title="Select base branch", preselect=preselect + ) + if base is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + console.print(f"Creating branch [green]{name}[/green] from [blue]{base}[/blue]...") + llc._git.create_branch(name, base) + + console.print("Setting upstream and pushing to origin...") + llc._git.push_and_set_upstream(name) + + console.print(f"Checking out [green]{name}[/green]...") + llc._git.checkout(name) + console.print(f"[green]✓[/green] Switched to new branch [bold]{name}[/bold].") + + except GitError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) diff --git a/cli/src/llc/docs.py b/cli/src/llc/docs.py new file mode 100644 index 0000000..dece81d --- /dev/null +++ b/cli/src/llc/docs.py @@ -0,0 +1,57 @@ +import subprocess +import sys +from pathlib import Path + +import typer +from rich.console import Console + +console = Console() + +DOCS_DIR = Path(__file__).resolve().parents[3] / "docs" + + +def _run_npm_script(script: str) -> None: + if not (DOCS_DIR / "package.json").exists(): + console.print("[red]docs/package.json not found.") + raise typer.Exit(1) + if not (DOCS_DIR / "node_modules").exists(): + console.print("[yellow]node_modules not found. Running npm install...") + try: + subprocess.run(["npm", "install"], cwd=DOCS_DIR, check=True) + except subprocess.CalledProcessError as exc: + console.print(f"[red]npm install failed with exit code {exc.returncode}") + raise typer.Exit(exc.returncode) + except FileNotFoundError: + console.print("[red]npm not found. Is Node.js installed?") + raise typer.Exit(1) + cmd = ["npm", "run", script] + console.print(f"[dim]Running: {' '.join(cmd)} in {DOCS_DIR}") + try: + subprocess.run(cmd, cwd=DOCS_DIR, check=True) + except subprocess.CalledProcessError as exc: + console.print(f"[red]Command failed with exit code {exc.returncode}") + raise typer.Exit(exc.returncode) + except FileNotFoundError: + console.print("[red]npm not found. Is Node.js installed?") + raise typer.Exit(1) + + +def cmd_build() -> None: + """Build the VitePress documentation site.""" + _run_npm_script("docs:build") + dist = DOCS_DIR / ".vitepress" / "dist" + console.print(f"[green]Docs built successfully: {dist}") + + +def cmd_serve() -> None: + """Start the VitePress dev server for documentation.""" + _run_npm_script("docs:dev") + + +def cmd_preview() -> None: + """Preview the built VitePress documentation site.""" + dist = DOCS_DIR / ".vitepress" / "dist" + if not dist.exists(): + console.print("[yellow]No built documentation found. Run 'llc docs build' first.") + raise typer.Exit(1) + _run_npm_script("docs:preview") diff --git a/cli/src/llc/main.py b/cli/src/llc/main.py new file mode 100644 index 0000000..243a2ce --- /dev/null +++ b/cli/src/llc/main.py @@ -0,0 +1,153 @@ +import typer + +app = typer.Typer( + name="ll", + help="LibrisLog developer CLI", + rich_markup_mode="rich", + pretty_exceptions_show_locals=False, +) + +pr_app = typer.Typer( + name="pr", + help="Manage pull requests (create, merge, list)", + rich_markup_mode="rich", +) +tag_app = typer.Typer( + name="tag", + help="Manage version tags", + rich_markup_mode="rich", +) +test_app = typer.Typer( + name="test", + help="Run test suites", + rich_markup_mode="rich", +) +branch_app = typer.Typer( + name="branch", + help="Manage branches (create, delete, sync)", + rich_markup_mode="rich", +) +docs_app = typer.Typer( + name="docs", + help="Build and serve documentation", + rich_markup_mode="rich", +) +app.add_typer(pr_app) +app.add_typer(tag_app) +app.add_typer(test_app) +app.add_typer(branch_app) +app.add_typer(docs_app) + + +@pr_app.command("list") +def pr_list(): + """List open pull requests.""" + from llc.pr import cmd_list + cmd_list() + + +@pr_app.command("create") +def pr_create(): + """Create a pull request with interactive branch selection.""" + from llc.pr import cmd_create + cmd_create() + + +@pr_app.command("merge") +def pr_merge(): + """Merge an open pull request.""" + from llc.pr import cmd_merge + cmd_merge() + + +@tag_app.command("create") +def tag_create(): + """Create and push a new version tag.""" + from llc.tag import cmd_create + cmd_create() + + +@tag_app.command("delete") +def tag_delete(): + """Delete a tag locally and remotely.""" + from llc.tag import cmd_delete + cmd_delete() + + +@test_app.command("backend") +def test_backend(): + """Run backend pytest with coverage.""" + from llc.test import cmd_backend + cmd_backend() + + +@test_app.command("cli") +def test_cli(): + """Run CLI pytest.""" + from llc.test import cmd_cli + cmd_cli() + + +@test_app.command("frontend") +def test_frontend(): + """Run frontend vitest with coverage.""" + from llc.test import cmd_frontend + cmd_frontend() + + +@test_app.command("e2e") +def test_e2e( + grep: str | None = typer.Option(None, "--grep", "-g", help="Filter tests by name") +): + """Run frontend E2E tests (Docker).""" + from llc.test import cmd_e2e + cmd_e2e(grep=grep) + + +@test_app.command("all") +def test_all(): + """Run all test suites (backend, cli, frontend, e2e) and print summary.""" + from llc.test import cmd_all + cmd_all() + + +@branch_app.command("create") +def branch_create(): + """Create a new branch from a base branch.""" + from llc.branch import cmd_create + cmd_create() + + +@branch_app.command("delete") +def branch_delete(): + """Delete a local branch.""" + from llc.branch import cmd_delete + cmd_delete() + + +@branch_app.command("sync") +def branch_sync(): + """Sync current branch with an origin branch.""" + from llc.sync import cmd_sync + cmd_sync() + + +@docs_app.command("build") +def docs_build(): + """Build the VitePress documentation site.""" + from llc.docs import cmd_build + cmd_build() + + +@docs_app.command("serve") +def docs_serve(): + """Start the VitePress documentation dev server.""" + from llc.docs import cmd_serve + cmd_serve() + + +@docs_app.command("preview") +def docs_preview(): + """Preview the built VitePress documentation site.""" + from llc.docs import cmd_preview + cmd_preview() diff --git a/cli/src/llc/pr.py b/cli/src/llc/pr.py new file mode 100644 index 0000000..3285525 --- /dev/null +++ b/cli/src/llc/pr.py @@ -0,0 +1,119 @@ +import click +import typer +import llc._git +import llc._gh +import llc._interactive +from llc._interactive import console + + +def cmd_create() -> None: + try: + llc._git.current_branch() + except Exception: + console.print("[red]Not inside a git repository.[/red]") + raise typer.Exit(code=1) + + try: + llc._gh.check_gh() + except Exception as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) + + try: + if llc._git.has_uncommitted_changes(): + if llc._interactive.confirm("Uncommitted changes found. Commit first?", default=True): + console.print("[yellow]Please commit your changes manually, then re-run.[/yellow]") + raise typer.Exit() + except click.exceptions.Exit: + raise + except Exception: + console.print("[red]Failed to check for uncommitted changes.[/red]") + raise typer.Exit(code=1) + + try: + branches = llc._git.remote_origin_branches() + cur = llc._git.current_branch() + except Exception: + console.print("[red]Failed to list remote branches.[/red]") + raise typer.Exit(code=1) + + if cur not in branches: + branches.append(cur) + + head = llc._interactive.select_from_list(branches, title="Select head branch", preselect=cur) + if head is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + base_candidates = [b for b in branches if b != head] + base_preselect = "main" if head == "develop" else "develop" + base = llc._interactive.select_from_list(base_candidates, title="Select base branch", preselect=base_preselect) + if base is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + console.print(f"Creating PR: [bold]{head}[/bold] → [bold]{base}[/bold]") + try: + llc._gh.create_pr(base=base, head=head) + console.print("[green]PR created successfully![/green]") + except Exception as exc: + console.print(f"[red]PR creation failed: {exc}[/red]") + raise typer.Exit(code=1) + + +def cmd_merge() -> None: + try: + llc._gh.check_gh() + except Exception as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) + + try: + prs = llc._gh.list_open_prs() + except Exception as exc: + console.print(f"[red]Failed to list PRs: {exc}[/red]") + raise typer.Exit(code=1) + + if not prs: + console.print("[yellow]No open pull requests.[/yellow]") + raise typer.Exit() + + pr_lines = [f"#{pr['number']} — {pr['title']} ({pr['headRefName']} → {pr['baseRefName']})" for pr in prs] + selected_line = llc._interactive.select_from_list(pr_lines, title="Open Pull Requests") + if selected_line is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + idx = pr_lines.index(selected_line) + selected_pr = prs[idx] + pr_number = selected_pr["number"] + + console.print(f"Merging PR #[bold]{pr_number}[/bold]: {selected_pr['title']}") + try: + llc._gh.merge_pr(pr_number) + console.print(f"[green]PR #{pr_number} merged![/green]") + except Exception as exc: + console.print(f"[red]PR merge failed: {exc}[/red]") + raise typer.Exit(code=1) + + +def cmd_list() -> None: + try: + llc._gh.check_gh() + except Exception as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) + + try: + prs = llc._gh.list_open_prs() + except Exception as exc: + console.print(f"[red]Failed to list PRs: {exc}[/red]") + raise typer.Exit(code=1) + + if not prs: + console.print("[yellow]No open pull requests.[/yellow]") + raise typer.Exit() + + for pr in prs: + author = pr.get("author", {}).get("login", "?") + console.print(f" #[bold]{pr['number']}[/bold] — {pr['title']} ({author})") diff --git a/cli/src/llc/sync.py b/cli/src/llc/sync.py new file mode 100644 index 0000000..444fe5a --- /dev/null +++ b/cli/src/llc/sync.py @@ -0,0 +1,43 @@ +import typer +import llc._git +import llc._interactive +from llc._interactive import console + + +def cmd_sync() -> None: + try: + llc._git.fetch() + except Exception: + console.print("[red]Failed to fetch from origin.[/red]") + raise typer.Exit(code=1) + + cur = llc._git.current_branch() + console.print(f"Current branch: [bold]{cur}[/bold]") + + try: + remotes = llc._git.remote_origin_branches() + except Exception: + console.print("[red]Failed to list remote branches.[/red]") + raise typer.Exit(code=1) + + candidates = [b for b in remotes if b != cur] + upstream = llc._git.get_upstream_branch() + preselect = upstream if upstream in candidates else None + + target = llc._interactive.select_from_list( + candidates, + title="Select origin branch to merge into current", + preselect=preselect, + ) + if target is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + try: + console.print(f"Merging [bold]origin/{target}[/bold] into [bold]{cur}[/bold]...") + llc._git.merge(target) + llc._git.push() + console.print(f"[green]Branch {cur} synced with origin/{target}![/green]") + except Exception as exc: + console.print(f"[red]Sync failed: {exc}[/red]") + raise typer.Exit(code=1) diff --git a/cli/src/llc/tag.py b/cli/src/llc/tag.py new file mode 100644 index 0000000..7bbd124 --- /dev/null +++ b/cli/src/llc/tag.py @@ -0,0 +1,165 @@ +import re + +import typer +import llc._git +import llc._interactive +from llc._interactive import console +from llc._git import GitError + + +def _parse_tag(tag: str) -> tuple[int, int, int, int | None]: + m = re.match(r"^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$", tag) + if not m: + raise ValueError(f"Invalid semver tag: {tag}") + return (int(m[1]), int(m[2]), int(m[3]), int(m[4]) if m[4] else None) + + +def _compute_bump(version: tuple[int, int, int, int | None], bump_type: str) -> str: + major, minor, patch, rc = version + if bump_type == "major": + return f"v{major + 1}.0.0" + elif bump_type == "minor": + return f"v{major}.{minor + 1}.0" + else: + return f"v{major}.{minor}.{patch + 1}" + + +def cmd_create() -> None: + try: + original_branch = llc._git.current_branch() + except Exception: + console.print("[red]Not inside a git repository.[/red]") + raise typer.Exit(code=1) + + try: + branches = llc._git.local_branches() + except Exception: + console.print("[red]Failed to list local branches.[/red]") + raise typer.Exit(code=1) + + branch = llc._interactive.select_from_list( + branches, + title="Select branch to tag", + preselect=original_branch, + ) + if branch is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + try: + tags = llc._git.fetch_tags("v*") + except Exception: + console.print("[red]Failed to fetch tags.[/red]") + raise typer.Exit(code=1) + + version_tags = [t for t in tags if re.match(r"^v\d+\.\d+\.\d+(-rc\.\d+)?$", t)] + + if not version_tags: + console.print("[yellow]No semantic version tags found on this branch.[/yellow]") + new_version = llc._interactive.prompt_text("Enter version tag (e.g. v0.1.0)") + else: + latest = version_tags[0] + parsed = _parse_tag(latest) + console.print(f"Latest tag: [bold]{latest}[/bold]") + + major_v = _compute_bump(parsed, "major") + minor_v = _compute_bump(parsed, "minor") + patch_v = _compute_bump(parsed, "patch") + + bumps: dict[str, str] = { + f"Major bump ({major_v})": major_v, + f"Minor bump ({minor_v})": minor_v, + f"Patch bump ({patch_v})": patch_v, + } + choices = list(bumps.keys()) + ["Enter custom"] + choice = llc._interactive.select_from_list(choices, title="Select bump type") + if choice is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + if choice == "Enter custom": + new_version = llc._interactive.prompt_text("Enter version tag") + else: + new_version = bumps[choice] + + if new_version is None or not new_version.strip(): + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + new_version = new_version.strip() + + if not re.match(r"^v\d+\.\d+\.\d+(-rc\.\d+)?$", new_version): + console.print(f"[red]Invalid version format: {new_version}. Expected format like v1.2.3 or v1.2.3-rc.1[/red]") + raise typer.Exit(code=1) + + if llc._git.tag_exists(new_version): + console.print(f"[yellow]Tag {new_version} already exists.[/yellow]") + if not llc._interactive.confirm(f"Overwrite tag [bold]{new_version}[/bold]?", default=False): + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + if not llc._interactive.confirm( + f"Create and push tag [bold]{new_version}[/bold] on [bold]{branch}[/bold]?", + default=True, + ): + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + try: + console.print(f"Checking out [bold]{branch}[/bold]...") + llc._git.checkout(branch) + console.print(f"Pulling latest [bold]{branch}[/bold]...") + llc._git.pull(branch) + console.print(f"Creating tag [bold]{new_version}[/bold]...") + llc._git.tag(new_version) + console.print(f"Pushing tag [bold]{new_version}[/bold]...") + llc._git.push_tag(new_version) + console.print(f"Restoring [bold]{original_branch}[/bold]...") + llc._git.checkout(original_branch) + console.print(f"[green]Tag {new_version} created and pushed![/green]") + except Exception as exc: + console.print(f"[red]Tag operation failed: {exc}[/red]") + try: + llc._git.checkout(original_branch) + except Exception: + pass + raise typer.Exit(code=1) + + +def cmd_delete() -> None: + try: + tags = llc._git.fetch_tags("v*") + except Exception: + console.print("[red]Failed to fetch tags.[/red]") + raise typer.Exit(code=1) + + recent = tags[:5] + choices = recent + ["Enter tag name manually"] + preselect = recent[0] if recent else None + choice = llc._interactive.select_from_list( + choices, title="Select tag to delete", preselect=preselect + ) + if choice is None: + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + + if choice == "Enter tag name manually": + tagname = llc._interactive.prompt_text("Enter tag name") + if not tagname or not tagname.strip(): + console.print("[yellow]Cancelled.[/yellow]") + raise typer.Exit() + tagname = tagname.strip() + else: + tagname = choice + + try: + llc._git.delete_tag(tagname) + console.print(f"[green]Deleted local tag {tagname}.[/green]") + except GitError: + console.print(f"[yellow]Local tag {tagname} not found or could not be deleted. Proceeding...[/yellow]") + + try: + llc._git.delete_remote_tag(tagname) + console.print(f"[green]Deleted remote tag {tagname}.[/green]") + except GitError as exc: + console.print(f"[yellow]Remote tag {tagname} not found or could not be deleted: {exc}. Proceeding...[/yellow]") diff --git a/cli/src/llc/test.py b/cli/src/llc/test.py new file mode 100644 index 0000000..ae00616 --- /dev/null +++ b/cli/src/llc/test.py @@ -0,0 +1,78 @@ +import subprocess +from pathlib import Path + +import typer +from llc._interactive import console + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +_BACKEND = _PROJECT_ROOT / "backend" +_CLI = _PROJECT_ROOT / "cli" +_FRONTEND = _PROJECT_ROOT / "frontend" + + +def cmd_backend() -> None: + console.print("[bold]Running backend tests with coverage...[/bold]") + code = subprocess.call(["uv", "run", "pytest"], cwd=str(_BACKEND)) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_cli() -> None: + console.print("[bold]Running CLI tests...[/bold]") + code = subprocess.call(["uv", "run", "pytest"], cwd=str(_CLI)) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_frontend() -> None: + console.print("[bold]Running frontend tests with coverage...[/bold]") + code = subprocess.call(["npm", "run", "test:coverage"], cwd=str(_FRONTEND)) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_e2e(*, grep: str | None = None) -> None: + console.print("[bold]Running frontend E2E tests (Docker)...[/bold]") + cmd = ["npm", "run", "test:e2e", "--"] + if grep: + cmd.extend(["--grep", grep]) + code = subprocess.call(cmd, cwd=str(_FRONTEND)) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_all() -> None: + console.print("[bold]Running all test suites...[/bold]\n") + + suites = [ + ("Backend", ["uv", "run", "pytest"], _BACKEND), + ("CLI", ["uv", "run", "pytest"], _CLI), + ("Frontend", ["npm", "run", "test:coverage"], _FRONTEND), + ("E2E", ["npm", "run", "test:e2e"], _FRONTEND), + ] + + results: dict[str, int] = {} + for name, cmd, cwd in suites: + console.print(f"[bold]=== {name} ===[/bold]") + try: + r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + results[name] = r.returncode + print(r.stdout) + if r.stderr: + print(r.stderr) + except Exception as exc: + console.print(f"[red]{name}: failed to run — {exc}[/red]") + results[name] = 1 + print() + + console.print("[bold]=== Summary ===[/bold]") + any_failed = False + for name, code in results.items(): + if code == 0: + console.print(f" [green]{name}: PASSED[/green]") + else: + console.print(f" [red]{name}: FAILED (exit code {code})[/red]") + any_failed = True + + if any_failed: + raise typer.Exit(code=1) diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py new file mode 100644 index 0000000..6250bc3 --- /dev/null +++ b/cli/tests/conftest.py @@ -0,0 +1,23 @@ +import subprocess +from collections.abc import Generator + +import pytest +from typer.testing import CliRunner + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def mock_subprocess(mocker) -> Generator: + def _mock(stdout: str = "", stderr: str = "", returncode: int = 0): + return mocker.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], returncode=returncode, + stdout=stdout, stderr=stderr, + ), + ) + return _mock diff --git a/cli/tests/test_gh.py b/cli/tests/test_gh.py new file mode 100644 index 0000000..9da3397 --- /dev/null +++ b/cli/tests/test_gh.py @@ -0,0 +1,50 @@ +import subprocess + +import pytest + +from llc._gh import GhError, check_gh, list_open_prs, create_pr, merge_pr + + +class TestCheckGh: + def test_passes_when_authenticated(self, mocker): + mocker.patch("subprocess.run", return_value=subprocess.CompletedProcess(args=[], returncode=0)) + check_gh() + + def test_raises_when_not_installed(self, mocker): + mocker.patch("subprocess.run", side_effect=FileNotFoundError()) + with pytest.raises(GhError, match="not installed"): + check_gh() + + def test_raises_when_not_authenticated(self, mocker): + mocker.patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, [])) + with pytest.raises(GhError, match="not authenticated"): + check_gh() + + +class TestListOpenPRs: + def test_parses_json_output(self, mock_subprocess): + mock_subprocess(stdout='[{"number":1,"title":"Fix","headRefName":"f","baseRefName":"d","author":{"login":"user"}}]') + prs = list_open_prs() + assert len(prs) == 1 + assert prs[0]["number"] == 1 + + def test_returns_empty_list(self, mock_subprocess): + mock_subprocess(stdout="[]") + assert list_open_prs() == [] + + +class TestCreatePR: + def test_calls_correct_command(self, mocker): + mock = mocker.patch("subprocess.run") + create_pr(base="main", head="feat/foo") + cmd = mock.call_args[0][0] + assert cmd[:5] == ["gh", "pr", "create", "--base", "main"] + assert cmd[5:] == ["--head", "feat/foo", "--fill", "--assignee", "@me"] + + +class TestMergePR: + def test_calls_correct_command(self, mocker): + mock = mocker.patch("subprocess.run") + merge_pr(42) + cmd = mock.call_args[0][0] + assert cmd == ["gh", "pr", "merge", "42", "-m"] diff --git a/cli/tests/test_git.py b/cli/tests/test_git.py new file mode 100644 index 0000000..13cffb5 --- /dev/null +++ b/cli/tests/test_git.py @@ -0,0 +1,160 @@ +import subprocess + +import pytest + +from llc._git import ( + GitError, + has_uncommitted_changes, + current_branch, + local_branches, + remote_origin_branches, + fetch_tags, + checkout, + pull, + tag, + push_tag, + merge, + push, + fetch, + tag_exists, + get_upstream_branch, +) + + +class TestHasUncommittedChanges: + def test_no_changes(self, mock_subprocess): + mock_subprocess(stdout="") + assert not has_uncommitted_changes() + + def test_unstaged_changes(self, mock_subprocess): + mock_subprocess(stdout=" M src/file.py\n?? new.py\n") + assert has_uncommitted_changes() + + def test_staged_changes(self, mock_subprocess): + mock_subprocess(stdout="M src/file.py\n") + assert has_uncommitted_changes() + + +class TestCurrentBranch: + def test_returns_branch_name(self, mock_subprocess): + mock_subprocess(stdout="main\n") + assert current_branch() == "main" + + def test_strips_whitespace(self, mock_subprocess): + mock_subprocess(stdout=" feature/foo \n") + assert current_branch() == "feature/foo" + + +class TestLocalBranches: + def test_returns_list(self, mock_subprocess): + mock_subprocess(stdout="main\ndevelop\nfeature/foo\n") + assert local_branches() == ["main", "develop", "feature/foo"] + + def test_empty(self, mock_subprocess): + mock_subprocess(stdout="") + assert local_branches() == [] + + +class TestRemoteOriginBranches: + def test_filters_origin_prefix(self, mock_subprocess): + mock_subprocess( + stdout="origin/HEAD\norigin/main\norigin/develop\norigin/feat/x\n" + ) + assert remote_origin_branches() == ["main", "develop", "feat/x"] + + def test_empty(self, mock_subprocess): + mock_subprocess(stdout="") + assert remote_origin_branches() == [] + + +class TestFetchTags: + def test_returns_sorted_tags(self, mock_subprocess): + mock_subprocess(stdout="v2.0.0\nv1.3.0\nv1.2.3\n") + assert fetch_tags("v*") == ["v2.0.0", "v1.3.0", "v1.2.3"] + + def test_empty(self, mock_subprocess): + mock_subprocess(stdout="") + assert fetch_tags("v*") == [] + + +class TestCheckout: + def test_calls_without_capture(self, mocker): + mock = mocker.patch("subprocess.run") + checkout("develop") + assert "capture_output" not in mock.call_args.kwargs + + +class TestPull: + def test_calls_correct_args(self, mocker): + mock = mocker.patch("subprocess.run") + pull("main") + cmd = mock.call_args[0][0] + assert cmd == ["git", "pull", "origin", "main"] + + +class TestTag: + def test_calls_correct_args(self, mock_subprocess): + mock_run = mock_subprocess() + tag("v1.0.0") + assert mock_run.call_args[0][0] == ["git", "tag", "v1.0.0"] + + +class TestPushTag: + def test_calls_interactive(self, mocker): + mock = mocker.patch("subprocess.run") + push_tag("v1.0.0") + assert mock.call_args[0][0] == ["git", "push", "origin", "v1.0.0"] + + +class TestMerge: + def test_calls_with_origin_prefix(self, mocker): + mock = mocker.patch("subprocess.run") + merge("develop") + cmd = mock.call_args[0][0] + assert cmd == ["git", "merge", "origin/develop"] + + +class TestPush: + def test_calls_interactive(self, mocker): + mock = mocker.patch("subprocess.run") + push() + assert mock.call_args[0][0] == ["git", "push"] + + +class TestFetch: + def test_calls_correct_args(self, mock_subprocess): + mock_run = mock_subprocess() + fetch() + assert mock_run.call_args[0][0] == ["git", "fetch", "origin"] + + +class TestTagExists: + def test_exists(self, mocker): + mocker.patch("subprocess.run", return_value=subprocess.CompletedProcess(args=[], returncode=0)) + assert tag_exists("v1.0.0") + + def test_not_exists(self, mocker): + mocker.patch("subprocess.run", side_effect=subprocess.CalledProcessError(128, ["git"])) + assert not tag_exists("v1.0.0") + + +class TestGetUpstreamBranch: + def test_returns_stripped(self, mock_subprocess): + mock_subprocess(stdout="origin/develop\n") + assert get_upstream_branch() == "develop" + + def test_no_upstream(self, mocker): + mocker.patch("subprocess.run", side_effect=subprocess.CalledProcessError(128, ["git"])) + assert get_upstream_branch() is None + + +class TestGitError: + def test_raises_on_file_not_found(self, mocker): + mocker.patch("subprocess.run", side_effect=FileNotFoundError()) + with pytest.raises(GitError, match="git is not installed"): + current_branch() + + def test_raises_on_nonzero_exit(self, mocker): + mocker.patch("subprocess.run", side_effect=subprocess.CalledProcessError(128, ["git"], stderr="fatal: not a git repository")) + with pytest.raises(GitError, match="not a git repository"): + current_branch() diff --git a/cli/tests/test_interactive.py b/cli/tests/test_interactive.py new file mode 100644 index 0000000..c89db43 --- /dev/null +++ b/cli/tests/test_interactive.py @@ -0,0 +1,18 @@ +from llc._interactive import select_from_list + + +class TestSelectFromList: + def test_returns_selected_item(self, mocker): + mocker.patch("questionary.select", return_value=mocker.MagicMock(ask=lambda: "bar")) + items = ["foo", "bar", "baz"] + result = select_from_list(items) + assert result == "bar" + + def test_returns_none_on_empty_list(self, mocker): + result = select_from_list([]) + assert result is None + + def test_returns_none_on_cancel(self, mocker): + mocker.patch("questionary.select", return_value=mocker.MagicMock(ask=lambda: None)) + result = select_from_list(["foo", "bar"]) + assert result is None diff --git a/cli/tests/test_main.py b/cli/tests/test_main.py new file mode 100644 index 0000000..226e069 --- /dev/null +++ b/cli/tests/test_main.py @@ -0,0 +1,23 @@ +from llc.main import app + + +class TestHelp: + def test_help_returns_zero(self, runner): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "LibrisLog" in result.stdout + + def test_pr_help(self, runner): + result = runner.invoke(app, ["pr", "--help"]) + assert result.exit_code == 0 + assert "create" in result.stdout + + def test_tag_help(self, runner): + result = runner.invoke(app, ["tag", "--help"]) + assert result.exit_code == 0 + assert "create" in result.stdout + + def test_sync_help(self, runner): + result = runner.invoke(app, ["branch", "sync", "--help"]) + assert result.exit_code == 0 + assert "Sync" in result.stdout diff --git a/cli/tests/test_pr.py b/cli/tests/test_pr.py new file mode 100644 index 0000000..09cd063 --- /dev/null +++ b/cli/tests/test_pr.py @@ -0,0 +1,97 @@ +import pytest +from llc.main import app + + +def _patch_pr(mocker, **kwargs): + """Apply common patches for PR tests and return the create_pr mock.""" + mocker.patch("llc._gh.check_gh") + for key, value in kwargs.items(): + mocker.patch(key, value) + + +class TestPRCreate: + def test_without_changes(self, runner, mocker): + _patch_pr(mocker) + mocker.patch("llc._git.has_uncommitted_changes", return_value=False) + mocker.patch("llc._git.remote_origin_branches", return_value=["main", "develop"]) + mocker.patch("llc._git.current_branch", return_value="my-feature") + mocker.patch("llc._interactive.select_from_list", side_effect=["my-feature", "develop"]) + mock_create = mocker.patch("llc._gh.create_pr") + + result = runner.invoke(app, ["pr", "create"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with(base="develop", head="my-feature") + + def test_with_uncommitted_changes_yes(self, runner, mocker): + _patch_pr(mocker) + mocker.patch("llc._git.current_branch") + mocker.patch("llc._git.has_uncommitted_changes", return_value=True) + mocker.patch("llc._interactive.confirm", return_value=True) + + result = runner.invoke(app, ["pr", "create"]) + assert result.exit_code == 0 + assert "commit" in result.stdout.lower() + + def test_with_uncommitted_changes_no(self, runner, mocker): + _patch_pr(mocker) + mocker.patch("llc._git.has_uncommitted_changes", return_value=True) + mocker.patch("llc._interactive.confirm", return_value=False) + mocker.patch("llc._git.remote_origin_branches", return_value=["main", "develop"]) + mocker.patch("llc._git.current_branch", return_value="my-feature") + mocker.patch("llc._interactive.select_from_list", side_effect=["my-feature", "develop"]) + mock_create = mocker.patch("llc._gh.create_pr") + + result = runner.invoke(app, ["pr", "create"]) + assert result.exit_code == 0 + mock_create.assert_called_once() + + def test_preselects_main_when_head_is_develop(self, runner, mocker): + _patch_pr(mocker) + mocker.patch("llc._git.has_uncommitted_changes", return_value=False) + mocker.patch("llc._git.remote_origin_branches", return_value=["main", "develop", "feature"]) + mocker.patch("llc._git.current_branch", return_value="develop") + mocker.patch("llc._interactive.select_from_list", side_effect=["develop", "main"]) + mock_create = mocker.patch("llc._gh.create_pr") + + result = runner.invoke(app, ["pr", "create"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with(base="main", head="develop") + + def test_not_in_git_repo(self, runner, mocker): + mocker.patch("llc._git.current_branch", side_effect=Exception("Not a git repo")) + + result = runner.invoke(app, ["pr", "create"]) + assert result.exit_code == 1 + assert "git" in result.stdout.lower() + + def test_gh_not_installed(self, runner, mocker): + mocker.patch("llc._git.current_branch") + mocker.patch("llc._gh.check_gh", side_effect=Exception("gh is not installed")) + + result = runner.invoke(app, ["pr", "create"]) + assert result.exit_code == 1 + + +class TestPRMerge: + def test_no_open_prs(self, runner, mocker): + _patch_pr(mocker) + mocker.patch("llc._gh.list_open_prs", return_value=[]) + + result = runner.invoke(app, ["pr", "merge"]) + assert result.exit_code == 0 + assert "No open" in result.stdout + + def test_selects_and_merges(self, runner, mocker): + _patch_pr(mocker) + mocker.patch( + "llc._gh.list_open_prs", + return_value=[ + {"number": 1, "title": "Fix bug", "headRefName": "fix", "baseRefName": "develop"}, + ], + ) + mocker.patch("llc._interactive.select_from_list", return_value="#1 — Fix bug (fix → develop)") + mock_merge = mocker.patch("llc._gh.merge_pr") + + result = runner.invoke(app, ["pr", "merge"]) + assert result.exit_code == 0 + mock_merge.assert_called_once_with(1) diff --git a/cli/tests/test_sync.py b/cli/tests/test_sync.py new file mode 100644 index 0000000..ec6b47b --- /dev/null +++ b/cli/tests/test_sync.py @@ -0,0 +1,26 @@ +from llc.main import app + + +class TestSync: + def test_basic_sync(self, runner, mocker): + mocker.patch("llc._git.fetch") + mocker.patch("llc._git.current_branch", return_value="my-feature") + mocker.patch("llc._git.remote_origin_branches", return_value=["main", "develop"]) + mocker.patch("llc._git.get_upstream_branch", return_value="develop") + mocker.patch("llc._interactive.select_from_list", return_value="develop") + mock_merge = mocker.patch("llc._git.merge") + mock_push = mocker.patch("llc._git.push") + + result = runner.invoke(app, ["branch", "sync"]) + assert result.exit_code == 0 + mock_merge.assert_called_once_with("develop") + mock_push.assert_called_once() + + def test_cancelled(self, runner, mocker): + mocker.patch("llc._git.fetch") + mocker.patch("llc._git.current_branch", return_value="my-feature") + mocker.patch("llc._git.remote_origin_branches", return_value=["main", "develop"]) + mocker.patch("llc._interactive.select_from_list", return_value=None) + + result = runner.invoke(app, ["branch", "sync"]) + assert result.exit_code == 0 diff --git a/cli/tests/test_tag.py b/cli/tests/test_tag.py new file mode 100644 index 0000000..18f95e9 --- /dev/null +++ b/cli/tests/test_tag.py @@ -0,0 +1,25 @@ +import pytest +from llc.tag import _parse_tag, _compute_bump + + +class TestParseTag: + def test_parses_full_semver(self): + assert _parse_tag("v1.2.3") == (1, 2, 3, None) + + def test_parses_with_rc(self): + assert _parse_tag("v1.2.3-rc.4") == (1, 2, 3, 4) + + def test_invalid_raises(self): + with pytest.raises(ValueError): + _parse_tag("abc") + + +class TestComputeBump: + def test_major_bump(self): + assert _compute_bump((1, 2, 3, None), "major") == "v2.0.0" + + def test_minor_bump(self): + assert _compute_bump((1, 2, 3, None), "minor") == "v1.3.0" + + def test_patch_bump(self): + assert _compute_bump((1, 2, 3, None), "patch") == "v1.2.4" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..76e205b --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,30 @@ +services: + backend: + build: + context: ./backend + args: + APP_VERSION: ${APP_VERSION:-v0.0.0-dev} + GIT_SHA: ${GIT_SHA:-unknown} + env_file: .env + ports: + - "8000:8000" + volumes: + - ./data:/app/data + - /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro # only needed if you use custom certificates in you environment + environment: + REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt # only needed if you use custom certificates in you environment + SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt # only needed if you use custom certificates in you environment + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + PUBLIC_DEFAULT_LOCALE: ${PUBLIC_DEFAULT_LOCALE:-en} + APP_VERSION: ${APP_VERSION:-v0.0.0-dev} + GIT_SHA: ${GIT_SHA:-unknown} + ports: + - "8001:80" + depends_on: + - backend + restart: unless-stopped diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..df5ffb8 --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,49 @@ +name: librislog-e2e + +services: + backend: + build: + context: ./backend + args: + APP_VERSION: ${APP_VERSION:-v0.0.0-dev} + GIT_SHA: ${GIT_SHA:-unknown} + ports: + - "8002:8000" + volumes: + - ./data-e2e:/app/data + environment: + DATABASE_URL: sqlite:///./data/librislog.db + API_KEY_ENCRYPTION_KEY: zK7qP9mX2vR5tW8yA4cF6hJ1lN3pS0uB # dummy key for testing purposes only + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')\""] + interval: 2s + timeout: 5s + retries: 15 + start_period: 10s + + frontend: + build: + context: ./frontend + args: + PUBLIC_DEFAULT_LOCALE: ${PUBLIC_DEFAULT_LOCALE:-en} + APP_VERSION: ${APP_VERSION:-v0.0.0-dev} + GIT_SHA: ${GIT_SHA:-unknown} + ports: + - "8003:80" + depends_on: + backend: + condition: service_healthy + + test-runner: + build: + context: ./frontend + dockerfile: Dockerfile.e2e + depends_on: + frontend: + condition: service_started + environment: + E2E_BASE_URL: http://frontend:80 + E2E_BACKEND_URL: http://backend:8000/api/health + volumes: + - ./frontend/playwright-report:/app/frontend/playwright-report + - ./frontend/test-results:/app/frontend/test-results diff --git a/docker-compose.yml b/docker-compose.yml index 76e205b..801d087 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,28 +1,19 @@ services: backend: - build: - context: ./backend - args: - APP_VERSION: ${APP_VERSION:-v0.0.0-dev} - GIT_SHA: ${GIT_SHA:-unknown} + # Uses the latest stable release. + # For the latest development version, replace ":latest" with ":develop". + image: ghcr.io/codebude/librislog/librislog-api:latest env_file: .env ports: - "8000:8000" volumes: - ./data:/app/data - - /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro # only needed if you use custom certificates in you environment - environment: - REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt # only needed if you use custom certificates in you environment - SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt # only needed if you use custom certificates in you environment restart: unless-stopped frontend: - build: - context: ./frontend - args: - PUBLIC_DEFAULT_LOCALE: ${PUBLIC_DEFAULT_LOCALE:-en} - APP_VERSION: ${APP_VERSION:-v0.0.0-dev} - GIT_SHA: ${GIT_SHA:-unknown} + # Uses the latest stable release. + # For the latest development version, replace ":latest" with ":develop". + image: ghcr.io/codebude/librislog/librislog:latest ports: - "8001:80" depends_on: diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..e778362 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.vitepress/dist/ +.vitepress/cache/ \ No newline at end of file diff --git a/docs/.vitepress/config.base.ts b/docs/.vitepress/config.base.ts new file mode 100644 index 0000000..3a60375 --- /dev/null +++ b/docs/.vitepress/config.base.ts @@ -0,0 +1,82 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + title: 'LibrisLog', + vite: { + server: { + host: true, + port: 5174, + strictPort: true, + }, + }, + description: 'Documentation for LibrisLog — a multi-user book tracking webapp', + lang: 'en-US', + lastUpdated: true, + ignoreDeadLinks: [/^http:\/\/localhost/], + markdown: { + image: { + lazyLoading: true, + }, + }, + head: [ + ['link', { rel: 'icon', href: '/favicon.svg' }], + ['script', { defer: '', 'data-domain': 'codebude.github.io/librislog', src: 'https://plausible.code-bude.net/js/script.js' }], + ], + themeConfig: { + logo: '/logo.png', + nav: [ + { text: 'Guide', link: '/guide/getting-started' }, + { text: 'API', link: '/api/' }, + { text: 'About', link: '/about' }, + ], + sidebar: { + '/guide/': [ + { + text: 'Getting Started', + items: [ + { text: 'Quick Start', link: '/guide/getting-started' }, + { text: 'Configuration', link: '/guide/configuration' }, + { text: 'API Keys', link: '/guide/api-keys' }, + { + text: 'Developer Setup', + link: '/guide/developer-setup', + collapsed: true, + items: [ + { text: 'CLI Reference', link: '/guide/cli' }, + ], + }, + ], + }, + { + text: 'Using LibrisLog', + items: [ + { text: 'Dashboard', link: '/guide/using-librislog/dashboard' }, + { text: 'Library', link: '/guide/using-librislog/library' }, + { text: 'Profile', link: '/guide/using-librislog/profile' }, + { text: 'Progress Tracking', link: '/guide/using-librislog/progress' }, + { text: 'Statistics', link: '/guide/using-librislog/statistics' }, + { text: 'Import & Export', link: '/guide/using-librislog/import-export' }, + { text: 'Data Hygiene', link: '/guide/using-librislog/data-hygiene' }, + { text: 'Administration', link: '/guide/using-librislog/administration' }, + ], + }, + ], + '/api/': [ + { + text: 'API Documentation', + items: [ + { text: 'Overview', link: '/api/' }, + { text: 'Headless Setup & API Keys', link: '/api/setup' }, + ], + }, + ], + }, + search: { provider: 'local' }, + socialLinks: [ + { icon: 'github', link: 'https://github.com/codebude/librislog' }, + ], + footer: { + message: 'Released under the MIT License.', + }, + }, +}) diff --git a/docs/.vitepress/config.nightly.ts b/docs/.vitepress/config.nightly.ts new file mode 100644 index 0000000..1eb2dd4 --- /dev/null +++ b/docs/.vitepress/config.nightly.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitepress' +import baseConfig from './config.base' + +export default defineConfig({ + ...baseConfig, + base: '/librislog/next/', + head: [ + ...(baseConfig.head || []), + ['link', { rel: 'icon', href: '/librislog/next/favicon.svg', type: 'image/svg+xml' }], + ['link', { rel: 'alternate icon', href: '/librislog/next/favicon.ico', sizes: 'any' }], + ], + themeConfig: { + ...baseConfig.themeConfig, + nav: [ + ...(baseConfig.themeConfig?.nav || []), + { text: 'Release Docs', link: 'https://codebude.github.io/librislog/' }, + ], + }, +}) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 0000000..f16939d --- /dev/null +++ b/docs/.vitepress/config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitepress' +import baseConfig from './config.base' + +export default defineConfig({ + ...baseConfig, + base: '/librislog/', + head: [ + ...(baseConfig.head || []), + ['link', { rel: 'icon', href: '/librislog/favicon.svg', type: 'image/svg+xml' }], + ['link', { rel: 'alternate icon', href: '/librislog/favicon.ico', sizes: 'any' }], + ], + themeConfig: { + ...baseConfig.themeConfig, + nav: [ + ...(baseConfig.themeConfig?.nav || []), + { text: 'Nightly Docs', link: 'https://codebude.github.io/librislog/next/' }, + ], + }, +}) \ No newline at end of file diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..b5248c7 --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,22 @@ +import type { Theme } from 'vitepress' +import DefaultTheme from 'vitepress/theme' +import { useRoute } from 'vitepress' +import imageViewer from 'vitepress-plugin-image-viewer' +import vImageViewer from 'vitepress-plugin-image-viewer/lib/vImageViewer.vue' +import 'viewerjs/dist/viewer.min.css' + +export default { + extends: DefaultTheme, + enhanceApp({ app }) { + app.component('vImageViewer', vImageViewer) + }, + setup() { + const route = useRoute() + imageViewer(route, '.vp-doc', { + filter: (img: HTMLImageElement) => { + img.style.cursor = 'zoom-in' + return true + } + }) + }, +} satisfies Theme diff --git a/docs/about.md b/docs/about.md new file mode 100644 index 0000000..5eabd48 --- /dev/null +++ b/docs/about.md @@ -0,0 +1,41 @@ +# About LibrisLog + +LibrisLog is a **multi-user book tracking web application** designed for readers who want to organize their library, track reading progress, and gain insights into their reading habits. + +## Features + +- **Library Management**: Organize books into four reading statuses — Want to Read, Currently Reading, Read, and Did Not Finish +- **Book Import**: Search Open Library, Google Books, and Hardcover.app. Scan ISBN barcodes for quick lookup +- **Reading Progress**: Track pages read over time with a visual timeline and calendar heatmap +- **Statistics Dashboard**: Charts showing pages read per month, books finished, language distribution, and more +- **Cover Management**: Automatic cover image scraping from multiple sources with manual override +- **Data Portability**: Export/import library as JSON or CSV. Full backup and restore functionality +- **REST API**: Full API with OpenAPI documentation for programmatic access +- **Multilingual**: English and German UI support +- **Themes**: Light, dark, and custom DaisyUI themes with persistent preferences + +## Technology Stack + +| Component | Technology | +|-----------|------------| +| Frontend | Svelte 5 + SvelteKit + Tailwind CSS 4 + DaisyUI 5 | +| Backend | FastAPI + SQLModel + Alembic + Pydantic v2 | +| Database | SQLite | +| Search Sources | Open Library, Google Books, Hardcover.app, AbeBooks, Amazon | +| Charts | Chart.js + chartjs-chart-matrix + chartjs-plugin-zoom | +| Icons | Lucide Svelte | +| Scraping | curl-cffi + Scrapling + BrowserForge | +| Auth | Session cookies, optional OIDC (Authlib) | +| Barcode | html5-qrcode | +| Dates | dayjs | +| Misc | cachetools, cryptography, restrictedpython, pycountry, passlib, hammerjs | +| Docs | VitePress + viewerjs | +| CI/Tests | pytest (backend), Vitest (frontend), Playwright (E2E) | + +## Author + +Created and maintained by [Raffael Herrmann](https://github.com/codebude). + +## License + +Released under the MIT License. \ No newline at end of file diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..4848d73 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,123 @@ +# API Documentation + +LibrisLog provides a full REST API with interactive documentation. + +## Interactive API Docs + +Two documentation interfaces are available when the backend is running: + +- **Swagger UI**: `http://localhost:8000/api/docs` +- **ReDoc**: `http://localhost:8000/api/redoc` + +The OpenAPI schema is also available at: +- **JSON**: `http://localhost:8000/api/openapi.json` + +## Authentication + +All API endpoints (except health check and documentation) require authentication via an API key. + +### Creating an API Key + +1. Log in to the web application +2. Go to your Profile page +3. Scroll to the "API Keys" section +4. Click "Create API Key" +5. Enter a description (optional) +6. Copy the key immediately — it is shown only once + +![API Keys](/screenshots/profile-api-keys.png) + +### Using an API Key + +Include the key in the `X-API-Key` header with every request: + +```bash +curl -H "X-API-Key: YOUR_KEY_HERE" http://localhost:8000/api/books +``` + +### Example Request + +```bash +# List all books +curl -H "X-API-Key: YOUR_KEY_HERE" \ + http://localhost:8000/api/books + +# Create a new book +curl -X POST \ + -H "X-API-Key: YOUR_KEY_HERE" \ + -H "Content-Type: application/json" \ + -d '{"title": "The Great Gatsby", "author": "F. Scott Fitzgerald"}' \ + http://localhost:8000/api/books + +# Update reading status +curl -X POST \ + -H "X-API-Key: YOUR_KEY_HERE" \ + -H "Content-Type: application/json" \ + -d '{"new_status": "read"}' \ + http://localhost:8000/api/books/1/transition-status +``` + +## Key Endpoints + +### Books + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/books` | List all books | +| POST | `/api/books` | Create a book | +| GET | `/api/books/{id}` | Get book details | +| PUT | `/api/books/{id}` | Update book | +| DELETE | `/api/books/{id}` | Delete book | +| POST | `/api/books/{id}/transition-status` | Change reading status | + +### Progress + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/books/{id}/progress` | List progress entries | +| POST | `/api/books/{id}/progress` | Add progress entry | +| PATCH | `/api/books/{id}/progress/{entry_id}` | Update progress date | +| DELETE | `/api/books/{id}/progress/{entry_id}` | Delete progress entry | + +### Statistics + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/statistics` | Full statistics | +| GET | `/api/statistics/pages-per-day` | Daily page breakdown | + +### Data Import/Export + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/data/export` | Export data | +| POST | `/api/data/import/parse` | Parse import file | +| POST | `/api/data/import/validate` | Validate import | +| POST | `/api/data/import/execute` | Execute import | + +### Book Import (External Sources) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/import/search` | Search external sources | +| GET | `/api/import/search/stream` | Stream search progress | +| POST | `/api/import` | Import a candidate | + +## Error Handling + +The API returns standard HTTP status codes: +- `200` — Success +- `201` — Created +- `204` — No content (delete success) +- `400` — Bad request +- `401` — Unauthorized (missing or invalid API key) +- `404` — Not found +- `409` — Conflict (e.g., duplicate ISBN) +- `422` — Validation error + +Error responses include a JSON body with details: +```json +{ + "detail": "Book not found" +} +``` \ No newline at end of file diff --git a/docs/api/setup.md b/docs/api/setup.md new file mode 100644 index 0000000..90f4de5 --- /dev/null +++ b/docs/api/setup.md @@ -0,0 +1,98 @@ +# Headless Setup & API Keys + +You can set up LibrisLog entirely via the API — no browser needed. This is useful for automation, CI/CD, or building your own frontend. + +## 1. Check if Setup is Required + +```bash +curl http://localhost:8000/api/auth/setup-required +``` + +Returns `{"required": true}` if no admin user exists yet. + +## 2. Create the First Admin + +```bash +curl -X POST http://localhost:8000/api/auth/setup \ + -H "Content-Type: application/json" \ + -c /tmp/librislog-cookies.txt \ + -d '{"firstname": "Admin", "lastname": "User", "email": "admin@example.com", "password": "your-secure-password"}' +``` + +This creates the first admin user and stores a session cookie in `/tmp/librislog-cookies.txt`. The `-c` flag saves the cookie for subsequent requests. + +> This endpoint is only available when no admin exists. Once an admin is created, subsequent calls return `403 Forbidden`. + +## 3. Create an API Key + +```bash +curl -X POST http://localhost:8000/api/profile/api-keys \ + -H "Content-Type: application/json" \ + -b /tmp/librislog-cookies.txt \ + -d '{"description": "My API key"}' +``` + +Returns the raw key (shown only once): + +```json +{ + "key": "lsk_abc123def456...", + "api_key": { "id": 1, "key_prefix": "lsk_abc", "description": "My API key", ... } +} +``` + +**Save the `key` value — it cannot be retrieved again.** + +## 4. Use the API Key + +Include it in the `X-API-Key` header: + +```bash +curl -H "X-API-Key: lsk_abc123def456..." http://localhost:8000/api/books +``` + +## Full Script (Linux/macOS) + +```bash +# Create first admin and save session cookie +curl -X POST http://localhost:8000/api/auth/setup \ + -H "Content-Type: application/json" \ + -c /tmp/librislog-cookies.txt \ + -d '{"firstname": "Admin", "lastname": "User", "email": "admin@example.com", "password": "your-password"}' + +# Create API key +response=$(curl -s -X POST http://localhost:8000/api/profile/api-keys \ + -H "Content-Type: application/json" \ + -b /tmp/librislog-cookies.txt \ + -d '{"description": "CLI key"}') + +api_key=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin)['key'])") +echo "API Key: $api_key" + +# Test it +curl -H "X-API-Key: $api_key" http://localhost:8000/api/books +``` + +## Full Script (Windows PowerShell) + +```powershell +# Create first admin +Invoke-RestMethod -Uri http://localhost:8000/api/auth/setup ` + -Method Post ` + -ContentType "application/json" ` + -Body '{"firstname":"Admin","lastname":"User","email":"admin@example.com","password":"your-password"}' ` + -SessionVariable session + +# Create API key +$response = Invoke-RestMethod -Uri http://localhost:8000/api/profile/api-keys ` + -Method Post ` + -ContentType "application/json" ` + -Body '{"description":"CLI key"}' ` + -WebSession $session + +Write-Host "API Key: $($response.key)" + +# Test it +Invoke-RestMethod -Uri http://localhost:8000/api/books ` + -Headers @{"X-API-Key" = $response.key} +``` diff --git a/docs/guide/api-keys.md b/docs/guide/api-keys.md new file mode 100644 index 0000000..28e7074 --- /dev/null +++ b/docs/guide/api-keys.md @@ -0,0 +1,26 @@ +# API Keys + +Some external sources require API keys. All are optional — LibrisLog works out of the box with Open Library. + +## Google Books API + +Enables Google Books as a fallback search source and improves cover resolution. + +1. Go to the [Google Cloud Console](https://console.cloud.google.com) +2. Create a new project or select an existing one +3. Navigate to **APIs & Services** → **Library** +4. Search for "Books API" and enable it +5. Go to **APIs & Services** → **Credentials** +6. Click **Create Credentials** → **API Key** +7. Copy the key and set it as `GOOGLE_BOOKS_API_KEY` in your `.env` + +> **Note**: Google Books API has usage limits. For most personal use, the free tier is sufficient. See [Google's documentation](https://developers.google.com/books) for quota details. + +## Hardcover API + +Enables Hardcover.app as an additional search source. + +1. Sign in to [hardcover.app](https://hardcover.app) +2. Go to your profile → **Settings** → **API** +3. Create a new API token +4. Copy the token and set it as `HARDCOVER_APP_API_TOKEN` in your `.env` diff --git a/docs/guide/cli.md b/docs/guide/cli.md new file mode 100644 index 0000000..c7ed514 --- /dev/null +++ b/docs/guide/cli.md @@ -0,0 +1,77 @@ +# Developer CLI + +LibrisLog includes a command-line tool for common development tasks. It automates pull requests, version tags, testing, and documentation. + +## Installation + +From the repository root: + +```bash +cd cli +uv sync +``` + +Then run commands with: + +```bash +uv run llc +``` + +You can also install it in your environment: + +```bash +uv pip install -e cli +``` + +## Usage + +### `llc docs` + +Build and preview the documentation site. + +| Command | Description | +|---------|-------------| +| `llc docs build` | Build the VitePress site | +| `llc docs serve` | Start the VitePress dev server with hot-reload | +| `llc docs preview` | Preview the built site (run `build` first) | + +### `llc test` + +Run test suites with a single command. + +| Command | Description | +|---------|-------------| +| `llc test backend` | Run backend pytest with coverage | +| `llc test cli` | Run CLI pytest | +| `llc test frontend` | Run frontend vitest with coverage | +| `llc test e2e` | Run frontend Playwright E2E tests (Docker) | +| `llc test all` | Run all four suites (backend, cli, frontend, e2e) | + +### `llc pr` + +Manage pull requests on GitHub. + +| Command | Description | +|---------|-------------| +| `llc pr list` | List open pull requests | +| `llc pr create` | Interactive PR creation — selects head and base branches | +| `llc pr merge` | Interactive PR merge — select from open PRs | + +### `llc tag` + +Create and delete semantic version tags. + +| Command | Description | +|---------|-------------| +| `llc tag create` | Interactive tag creation — picks the branch, suggests the next version (major/minor/patch bump), creates and pushes the tag | +| `llc tag delete` | Interactive tag deletion — select from recent tags or enter a name, deletes locally and remotely | + +### `llc branch` + +Create, delete, and sync local branches. + +| Command | Description | +|---------|-------------| +| `llc branch create` | Interactive branch creation — enter a name, select a base, pushes to origin | +| `llc branch delete` | Interactive branch deletion — select a branch, auto-switches if currently on it | +| `llc branch sync` | Interactive sync — fetches origin, merges the selected remote branch into your current branch, pushes | diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md new file mode 100644 index 0000000..9851a3d --- /dev/null +++ b/docs/guide/configuration.md @@ -0,0 +1,102 @@ +# Configuration + +All configuration is done via environment variables in a `.env` file at the project root. + +## Core Settings + +| Variable | Description | Default | +|----------|-------------|---------| +| `DATABASE_URL` | SQLite database file path | `sqlite:///./data/librislog.db` | +| `CORS_ORIGINS` | Allowed CORS origins (JSON array or comma-separated) | `["http://localhost", "http://localhost:5173", "http://localhost:4173"]` | +| `LOG_LEVEL` | Logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | `INFO` | +| `API_KEY_ENCRYPTION_KEY` | Secret key for API key encryption (must be real, not placeholder) | Requires 32+ characters | +| `FORWARDED_ALLOW_IPS` | Trusted proxy IPs | `*` | + +## Authentication + +| Variable | Description | Default | +|----------|-------------|---------| +| `AUTH_COOKIE_NAME` | Session cookie name | `librislog_session` | +| `AUTH_COOKIE_SECURE` | Use secure cookies (HTTPS only) | `false` | +| `AUTH_COOKIE_SAMESITE` | SameSite cookie attribute | `lax` | +| `AUTH_COOKIE_DOMAIN` | Cookie domain | — | + +## OIDC (Optional) + +| Variable | Description | +|----------|-------------| +| `OIDC_ENABLED` | Enable OIDC authentication (`true`/`false`) | +| `OIDC_CLIENT_ID` | OIDC client ID | +| `OIDC_CLIENT_SECRET` | OIDC client secret | +| `OIDC_WELL_KNOWN_URL` | OIDC well-known configuration URL | + +## Book Import Sources + +| Variable | Description | +|----------|-------------| +| `GOOGLE_BOOKS_API_KEY` | Google Books API key (see [API Keys](/guide/api-keys)) | +| `HARDCOVER_APP_API_TOKEN` | Hardcover.app API token (see [API Keys](/guide/api-keys)) | + +## Cover Scraping + +| Variable | Description | Default | +|----------|-------------|---------| +| `COVERS_DIR` | Directory for cached cover images | `./data/covers` | +| `THALIA_COVER_SEARCH_ENABLED` | Enable Thalia cover search | `false` | + +> **Disclaimer:** Enabling Thalia cover search uses automated scraping of thalia.de. This likely violates their Terms of Service. The app ships with this feature disabled by default. Enable it only for research purposes and at your own risk. Do not use in production. + +## Dashboard + +| Variable | Description | Default | +|----------|-------------|---------| +| `DASHBOARD_QUOTE_ENABLED` | Enable dashboard quote | `true` | +| `DASHBOARD_QUOTE_URL` | Quote API endpoint | `https://motivational-spark-api.vercel.app/api/quotes/random` | +| `DASHBOARD_QUOTE_CACHE_TTL` | Quote cache time-to-live (seconds) | `86400` | + +## Frontend Build + +| Variable | Description | Default | +|----------|-------------|---------| +| `PUBLIC_DEFAULT_LOCALE` | Default UI language (`en` or `de`) | `en` | + +## Import Limits + +| Variable | Description | Default | +|----------|-------------|---------| +| `MAX_IMPORT_FILE_SIZE_MB` | Maximum import file size (MB) | `100` | +| `MAX_IMPORT_ROW_COUNT` | Maximum import row count | `10000` | + +## Validation Rules + +- `API_KEY_ENCRYPTION_KEY` must be a real secret key (minimum 32 characters). Do not use the placeholder value from `.env.example`. If left as placeholder or set to a weak value, API key creation will fail. +- When `OIDC_ENABLED=true`, all three OIDC variables must be set. +- `GOOGLE_BOOKS_API_KEY` and `HARDCOVER_APP_API_TOKEN` are optional. See [API Keys](/guide/api-keys) for how to obtain them. The app runs fine without them using Open Library. + +## Example .env + +```bash +DATABASE_URL=sqlite:///./data/librislog.db +CORS_ORIGINS=["http://localhost", "http://localhost:5173", "http://localhost:4173"] +LOG_LEVEL=INFO +API_KEY_ENCRYPTION_KEY= # CHANGE ME: generate with `openssl rand -base64 32` +FORWARDED_ALLOW_IPS=* + +AUTH_COOKIE_NAME=librislog_session +AUTH_COOKIE_SECURE=false +AUTH_COOKIE_SAMESITE=lax + +GOOGLE_BOOKS_API_KEY=your-google-books-api-key +HARDCOVER_APP_API_TOKEN=your-hardcover-token + +COVERS_DIR=./data/covers +THALIA_COVER_SEARCH_ENABLED=false + +DASHBOARD_QUOTE_ENABLED=true +DASHBOARD_QUOTE_URL=https://motivational-spark-api.vercel.app/api/quotes/random +DASHBOARD_QUOTE_CACHE_TTL=86400 + +PUBLIC_DEFAULT_LOCALE=en +MAX_IMPORT_FILE_SIZE_MB=100 +MAX_IMPORT_ROW_COUNT=10000 +``` \ No newline at end of file diff --git a/docs/guide/developer-setup.md b/docs/guide/developer-setup.md new file mode 100644 index 0000000..d2fc12e --- /dev/null +++ b/docs/guide/developer-setup.md @@ -0,0 +1,168 @@ +# Developer Setup + +For contributors who want to build from source or run individual services locally. + +## Docker Compose (Local Builds) + +Build and run both services from local source using the development compose file: + +```bash +docker compose -f docker-compose.dev.yml up -d --build +``` + +This builds fresh images using your local checkout. The `docker-compose.dev.yml` file mirrors the default compose file but uses `build:` directives instead of pulling pre-built images. + +### Build Arguments + +When building, you can override these arguments: + +| Argument | Description | Default | +|----------|-------------|---------| +| `APP_VERSION` | Application version string | `v0.0.0-dev` | +| `GIT_SHA` | Git commit hash for version display | `unknown` | +| `PUBLIC_DEFAULT_LOCALE` | Default UI language (`en` or `de`) | `en` | + +Example: + +```bash +export APP_VERSION="v1.0.0" +export GIT_SHA=$(git rev-parse --short HEAD) +export PUBLIC_DEFAULT_LOCALE="en" +docker compose -f docker-compose.dev.yml up -d --build +``` + +## Local Development — Backend + +Requirements: +- Python 3.14+ (latest stable — install via [pyenv](https://github.com/pyenv/pyenv) if not available) +- `uv` package manager + +Steps: + +```bash +cd backend +uv sync +uv run alembic upgrade head +uv run uvicorn app.main:app --reload --port 8000 +``` + +The backend runs on http://localhost:8000 with auto-reload on code changes. + +## Local Development — Frontend + +Requirements: +- Node.js 20+ (see `frontend/.nvmrc`) + +Steps: + +```bash +cd frontend +npm install +npm run dev +``` + +The Vite dev server runs on http://localhost:5173 and proxies `/api` requests to the backend. + +### Version Injection + +For production builds, the frontend embeds version information. Set these environment variables before building: + +```bash +export APP_VERSION="v1.2.3" +export GIT_SHA="abc1234" +``` + +The version appears in the UI footer and is used for cache-busting. + +## Documentation + +The VitePress documentation lives in the `docs/` directory: + +``` +docs/ +├── index.md # Landing page +├── about.md # About page +├── guide/ # User-facing guides +│ ├── getting-started.md +│ ├── developer-setup.md +│ ├── configuration.md +│ ├── api-keys.md +│ ├── cli.md +│ └── using-librislog/ # Feature-specific guides +├── api/ # API documentation +│ ├── index.md +│ └── setup.md +└── public/ # Static assets (screenshots, favicon) +``` + +### Dev Server + +```bash +cd docs +npm install +npm run docs:dev +``` + +Opens on http://localhost:5174 with hot reload. + +### Production Build + +```bash +npm run docs:build +``` + +Output goes to `docs/.vitepress/dist/`. + +### Nightly Docs + +The CI workflow publishes two doc sets on every push to `develop`: +- **Release docs** at `https://codebude.github.io/librislog/` — built from the latest git tag +- **Nightly docs** at `https://codebude.github.io/librislog/next/` — built from `develop` + +The nightly build uses a separate config (`config.nightly.ts`) which sets a different base path and swaps the nav link to point back to the release docs. + +## Running Tests + +All test suites can also be run via the [developer CLI](cli.md). + +### Backend + +```bash +cd backend +uv run pytest +# or: uv run llc test backend +``` + +### CLI + +```bash +cd cli +uv run pytest +# or: uv run llc test cli +``` + +### Frontend (Unit) + +```bash +cd frontend +npx vitest run +# or: uv run llc test frontend +``` + +### E2E (Playwright) + +End-to-end tests run the full stack (backend + frontend) inside Docker containers using `docker-compose.e2e.yml`. A Playwright test-runner container drives the browser against the real services. + +```bash +cd frontend +npm run test:e2e +# or: uv run llc test e2e +``` + +### All Suites + +```bash +uv run llc test all +``` + +This runs backend, CLI, frontend unit, and E2E tests sequentially and prints a summary. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md new file mode 100644 index 0000000..a9f4c71 --- /dev/null +++ b/docs/guide/getting-started.md @@ -0,0 +1,65 @@ +# Quick Start + +Get LibrisLog running in minutes. + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) (includes Docker Compose) +- `curl` or `wget` (to download files) + +## Setup + +Download the files, create your environment, and generate a secure encryption key. + +::: code-group + +```bash [Linux/macOS] +mkdir librislog && cd librislog \ + && curl -O https://raw.githubusercontent.com/codebude/librislog/main/docker-compose.yml \ + && curl -O https://raw.githubusercontent.com/codebude/librislog/main/.env.example \ + && cp .env.example .env \ + && sed -i "s/CHANGE_ME_TO_32PLUS_CHARS/$(openssl rand -base64 32)/" .env +``` + +```powershell [Windows] +mkdir librislog; cd librislog +Invoke-WebRequest -Uri https://raw.githubusercontent.com/codebude/librislog/main/docker-compose.yml -OutFile docker-compose.yml +Invoke-WebRequest -Uri https://raw.githubusercontent.com/codebude/librislog/main/.env.example -OutFile .env.example +Copy-Item .env.example .env +$key = [Convert]::ToBase64String([byte[]](1..32 | ForEach-Object {Get-Random -Maximum 256})) +(Get-Content .env).Replace('CHANGE_ME_TO_32PLUS_CHARS', $key) | Set-Content .env +``` + +::: + +> **Alternative key generation**: If you don't have OpenSSL or PowerShell, run `python -c "import secrets; print(secrets.token_urlsafe(32))"` or use an online generator like [base64encode.org](https://www.base64encode.org/) (generate 32 random bytes, encode to base64). Then manually replace `CHANGE_ME_TO_32PLUS_CHARS` in your `.env` file. + +> The `.env` file can be further customized — see [Configuration](/guide/configuration) for all available options. + +Start the application: + +```bash +docker compose up -d +``` + +The backend API will be available at http://localhost:8000 and the frontend at http://localhost:8001. + +## First-Time Setup + +On first launch, create a user account through the web interface at http://localhost:8001. + +![Dashboard](/screenshots/dashboard.png) + +The dashboard shows your currently reading books, reading progress, and a random inspirational quote. + +## Verification + +Check that the application is healthy: + +```bash +curl http://localhost:8000/api/health +``` + +You should see a JSON response with status information. + +You can also verify the frontend is accessible by opening http://localhost:8001 in your browser. diff --git a/docs/guide/using-librislog/administration.md b/docs/guide/using-librislog/administration.md new file mode 100644 index 0000000..7101fac --- /dev/null +++ b/docs/guide/using-librislog/administration.md @@ -0,0 +1,71 @@ +# Administration + +> [!IMPORTANT] +> The administration page is available only to users with the **admin** role. Regular users cannot access it. + +![Users tab](/screenshots/admin-users.png) + +## Users + +### Creating a User + +Admins can create new user accounts with either the `user` or `admin` role. The password must meet the complexity requirements displayed in the form. + +### Editing a User + +Click "Edit" next to a user to change their name, email, password, or role. You cannot change your own admin role to prevent accidental lockout. + +### Deleting a User + +Click "Delete" to remove a user account. You cannot delete your own account from this page — use the **Profile → Danger Zone** instead. + +## Backup & Restore + +![Backup and Restore tab](/screenshots/admin-backup.png) + +### Creating a Backup + +Downloads the entire SQLite database as a `.db` file. This is a complete snapshot of your library, users, and settings. + +### Restoring a Backup + +Upload a previously downloaded `.db` file to restore the database. The app validates the backup before applying it. + +::: warning +Restoring overwrites all current data. Create a fresh backup first if you want to preserve your current library. +::: + +### Automating Backups + +The backup endpoint is accessible via the REST API, making it easy to automate with cron (or any scheduler). + +First, [create an API key](/api/setup#3-create-an-api-key) with the admin user. Then use it in a cron script: + +```bash +#!/usr/bin/env bash +# Save as /etc/cron.daily/librislog-backup or add to crontab + +API_KEY="lsk_your-key-here" +URL="http://localhost:8000/api/admin/backup" +DEST="/var/backups/librislog" + +mkdir -p "$DEST" +curl -s -H "X-API-Key: $API_KEY" -o "$DEST/librislog-$(date +%F).zip" "$URL" + +# Keep only the last 30 backups +find "$DEST" -name 'librislog-*.zip' -mtime +30 -delete +``` + +Crontab entry (daily at 3am): + +``` +0 3 * * * /path/to/backup-script.sh +``` + +The API returns a ZIP archive containing the SQLite database, cover images, and import temp files. No separate database dump step is needed. + +## Background Maintenance + +A periodic maintenance task runs automatically every hour. It performs: + +- **Cover cache cleanup** — Removes orphaned cover images from disk that are no longer referenced by any book. Files modified within the last 60 minutes are preserved to avoid deleting covers that were just uploaded but not yet linked to a book entry. diff --git a/docs/guide/using-librislog/dashboard.md b/docs/guide/using-librislog/dashboard.md new file mode 100644 index 0000000..2be01dd --- /dev/null +++ b/docs/guide/using-librislog/dashboard.md @@ -0,0 +1,39 @@ +# Dashboard + +The dashboard is the first page you see after logging in. It gives you an overview of your reading activity and quick access to your current books. + +![Dashboard](/screenshots/dashboard.png) + +## Search + +The search bar at the top of the dashboard lets you find books by title, author, or tags. The result count updates as you type and matching books appear in a dropdown below the bar. + +- **Arrow keys** to navigate the dropdown +- **Enter** opens the selected book's detail view; if no item is selected, it navigates to the dedicated search results page (`/search`) showing all matches +- **Escape** to close the dropdown +- **Click the search icon** to focus the input + +The search results page shows a full results grid with load-more pagination and the same book detail interaction as the library. + +## Currently Reading + +Books you marked as "Currently Reading" appear with progress bars showing the current page and percentage. Click a book to open the detail view and update your progress. + +## Next Suggestions + +Books from your "Want to Read" list are shown as suggestions — pick one to start reading next. + +## Inspirational Quote + +A random quote is displayed at the top of the dashboard (configurable via `DASHBOARD_QUOTE_ENABLED` in `.env`). + +## Tag Cloud + +The most common tags in your library are shown, sized by frequency. Click any tag to filter your library by it. + +## Timeline + +Access the timeline page from the left navigation menu under "Timeline". The timeline page shows a chronological view of your reading activity: +- Books started and finished +- Reading progress updates +- Date conflicts (when a book's start date is after its finish date) diff --git a/docs/guide/using-librislog/data-hygiene.md b/docs/guide/using-librislog/data-hygiene.md new file mode 100644 index 0000000..f972fc3 --- /dev/null +++ b/docs/guide/using-librislog/data-hygiene.md @@ -0,0 +1,50 @@ +# Data Hygiene + +The Data Hygiene page helps you find and fix books with missing metadata. It's accessible from your **Profile → Manage my data → Data Hygiene**. + +## Attribute Filtering + +The page lists books that are missing one or more of the tracked attributes. Use the chip buttons at the top to filter by specific attributes: + +- **Single attribute**: Click a chip to show only books missing that specific field +- **Multiple attributes**: Click several chips to narrow down further +- **Match mode**: Toggle between "Match any" (OR logic — book missing any selected attribute) and "Match all" (AND logic — book must miss all selected attributes) +- **Default view**: With no chips selected, all books missing at least one tracked attribute are shown + +Tracked attributes: title, author, ISBN, publisher, published year, blurb, language, subtitle, page count, and cover. + +## Reviewing Missing Data + +Each row in the table shows: +- Book title, author, ISBN, and publisher +- A "Missing" column with badges for each missing attribute + +Select individual books or use the checkbox in the header to select all visible books. The action bar at the bottom appears once at least one book is selected. + +## Batch Editing + +The batch action bar lets you update multiple books at once: + +1. **Select books** using the checkboxes +2. **Pick a field** from the dropdown (only attributes that are missing in the selected books appear) +3. **Enter a value** — text fields get a text input, numeric fields (page count, year) get a number input +4. **Click "Apply to selected"** to open a confirmation dialog showing a preview of affected books +5. **Confirm** to apply the change + +### Cover URL + +When setting `cover_url`, the app validates the URL, downloads the image, and stores it locally. If the download fails, that book is skipped and reported in the result summary. + +### Language + +Language values are normalized to uppercase; invalid ISO codes are rejected. + +### Published Year + +No lower bound is enforced, so ancient or religious texts (e.g., the Bible) can be entered with years before 1000. + +## Success & Error States + +- **All complete**: When no books are missing data, a green success message appears +- **Filtered complete**: When specific attributes are selected and all books have them, a tailored success message is shown +- **Errors**: API errors are displayed in an alert banner; dismiss it to try again diff --git a/docs/guide/using-librislog/import-export.md b/docs/guide/using-librislog/import-export.md new file mode 100644 index 0000000..4531abc --- /dev/null +++ b/docs/guide/using-librislog/import-export.md @@ -0,0 +1,111 @@ +# Import & Export + +LibrisLog provides multiple ways to get data in and out of the system, ensuring your library is always portable. + +## Book Import + +### Search Import + +The most common way to add books is by searching external sources: + +1. Click "Add Book" in the library +2. Enter a title, author, or ISBN in the search box +3. The app queries: + - **Open Library** (always, no key required) + - **Google Books** (if `GOOGLE_BOOKS_API_KEY` is set — see [API Keys](/guide/api-keys)) + - **Hardcover.app** (if `HARDCOVER_APP_API_TOKEN` is set — see [API Keys](/guide/api-keys)) +4. Select a result to import with full metadata and cover + +### ISBN Barcode Scan + +On mobile devices: +1. Tap the scan button in the import dialog +2. Point the camera at an ISBN barcode +3. The app detects the barcode and searches automatically + +### Manual Entry + +If no search results are found, enter book details manually. All fields are optional except title. + +## Data Export + +Export your entire library or subsets of data: + +### Export Formats + +| Format | Description | +|--------|-------------| +| **JSON** | Complete data with all metadata and relationships | +| **CSV** | Tabular format, one row per book | +| **ZIP** | Combined JSON + cover images | + +### Export Datasets + +Choose which data to include: +- Books (full metadata) +- Reading progress entries +- Tags +- Cover images + +### Export Process + +1. Go to the Data page +2. Select datasets and format +3. Click Export +4. Download the generated file + +## Data Import + +Import data from external sources: + +![Data Import](/screenshots/data-import.png) + +### Supported Formats + +- **JSON** — LibrisLog export format +- **CSV** — Custom field mapping supported + +### Field Mapping + +When importing CSV, map source columns to LibrisLog fields: +- Source field dropdown shows all columns from the CSV +- Target field shows available LibrisLog properties +- Optional transform expressions (Python) for data conversion + +### Transform DSL + +Per-field Python expressions allow data transformation: +```python +# Examples: +value.upper() # Convert to uppercase +str(int(value)) # Convert to integer then back to string +"https://example.com/" + value # Prefix a URL +``` + +Available variables: +- `value` — The field value +- `row` — The entire row as a dictionary +- `context` — Import context (not commonly used) + +### Predefined Mappings + +Common import formats have predefined mappings: +- **Goodreads Export** — Maps Goodreads CSV columns automatically + +### Validation + +Before importing: +1. Parse and preview the data +2. Review transformed rows +3. Check for errors +4. Validate the full dataset + +The import process shows progress with a count of imported and failed rows. + +## Backup & Restore + +Backup and restore are admin-only features. See [Administration](./administration) for details. + +## API Access + +For programmatic import/export, use the REST API. See the [API documentation](../../api/) for details. \ No newline at end of file diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md new file mode 100644 index 0000000..2715c54 --- /dev/null +++ b/docs/guide/using-librislog/library.md @@ -0,0 +1,104 @@ +# Library + +The library is the heart of LibrisLog. It organizes your books into four reading statuses and provides tools for managing your collection. + +## Reading Statuses + +Books are categorized into four statuses: + +| Status | Description | +|--------|-------------| +| **Want to Read** | Books you plan to read | +| **Currently Reading** | Books you're actively reading | +| **Read** | Books you've finished | +| **Did Not Finish** | Books you started but abandoned | + +Each status has its own tab in the library view, making it easy to browse your collection by reading state. + +![Library](/screenshots/library-read.png) + +## Navigation + +- Switch between tabs using the bottom navigation bar on mobile or the sidebar on desktop +- Books are displayed as cards with cover images, titles, and authors +- Click any book to open the detail view + +## Book Cards + +Each book card shows: +- Cover image (or placeholder if no cover) +- Title and author +- Current reading progress (for "Currently Reading" books) +- Star rating (for "Read" books) + +## Detail View + +Clicking a book opens the detail dialog/drawer showing: +- Full cover image +- Complete metadata (title, subtitle, author, ISBN, publisher, year, pages, language) +- Reading status badge +- Star rating (clickable to change) +- Reading progress slider (for books with page count) +- Tags +- Notes +- Blurb/description with expand/collapse +- Action buttons: Edit, Delete + +## Adding Books + +### Manual Entry + +Use the "Add Book" button to manually enter book details. Fill in title, author, and optional fields like ISBN, publisher, page count, etc. + +### Import Search + +Search external sources for book metadata: +- **Open Library** — Free, no API key required +- **Google Books** — Requires API key (set in `.env`) +- **Hardcover.app** — Requires API token (set in `.env`) + +The search automatically tries Open Library first, then falls back to other sources. For ISBN searches, all available sources are queried in parallel. + +### ISBN Barcode Scan + +On mobile devices, use the camera to scan ISBN barcodes. The app uses the device's camera with real-time barcode detection to quickly look up books. + +## Editing Books + +Click the "Edit" button in the detail view to modify any book property. Changes are saved immediately. + +## Covers + +### Automatic Cover Search + +When adding a book, the app automatically searches for cover images from: +- AbeBooks +- Open Library +- Amazon +- Hardcover + +### Cover Picker + +If automatic search doesn't find a suitable cover, you can: +- Upload an image file +- Paste an image URL +- Trigger a manual cover search + +### Cover Caching + +Downloaded covers are cached locally in the `COVERS_DIR` directory to avoid repeated external requests. + +## Search + +- Search books by title, author, or tags using the search bar — the result count updates as you type +- Press **Enter** to open the dedicated search results page with a full results grid, load-more pagination, and the same book detail interaction as the library +- From any page, navigate directly to `/search?q=your+query` for quick access + +## Sort + +- Sort by title, date added, date started, date finished, or rating +- Sort order: ascending or descending + +## View Modes + +Switch between grid view (cover-focused) and list view (compact) using the view toggle. \ No newline at end of file diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md new file mode 100644 index 0000000..dde0f2f --- /dev/null +++ b/docs/guide/using-librislog/profile.md @@ -0,0 +1,50 @@ +# Profile + +The profile page is your personal settings hub. Access it by clicking your avatar or name in the top-right corner and selecting "Profile". + +![Profile page](/screenshots/profile.png) + +## Profile Information + +Update your first name, last name, or password. The password field is optional — leave it blank to keep your current password. A password strength indicator and complexity requirements are shown below the input. + +## Language + +Switch the UI language between available locales. The change applies immediately after saving. + +## Timezone + +Set your preferred timezone for date/time displays (e.g., for the calendar heatmap and progress log timestamps). Your browser's detected timezone is shown as a reference. + +## Theme + +Choose a custom DaisyUI theme from the dropdown. The theme previews in real-time as you browse the dropdown, and the selection is saved to your profile so it persists across sessions. + +## API Keys + +Create and manage API keys for headless access to the REST API. Each key can have an optional description. Keys are shown once at creation — copy it immediately, as it cannot be retrieved later. + +See the [API Keys guide](/guide/api-keys) for detailed setup instructions. + +## Data Management + +Two data management tools are available: + +- **Import / Export** — Export your library as JSON, CSV, or ZIP, or import from Goodreads CSV or generic CSV with field mapping and Python transforms. See [Import & Export](/guide/using-librislog/import-export). +- **Data Hygiene** — Find books with missing metadata and batch-update them. See [Data Hygiene](/guide/using-librislog/data-hygiene). + +## OIDC + +If the instance has OIDC authentication enabled, you can link or unlink your account to an external identity provider. + +## Danger Zone + +Two irreversible actions are available: + +### Reset My Data + +Deletes all your books, reading progress, and tags. Your account and profile settings are preserved. Type the confirmation phrase to enable the button. + +### Delete Account + +Permanently deletes your account and all associated data. Type the confirmation phrase to enable the button. After deletion, you are redirected to the login page. diff --git a/docs/guide/using-librislog/progress.md b/docs/guide/using-librislog/progress.md new file mode 100644 index 0000000..6e8f103 --- /dev/null +++ b/docs/guide/using-librislog/progress.md @@ -0,0 +1,35 @@ +# Progress Tracking + +Track your reading progress with page-level granularity. Each update is recorded in a log, giving you a complete history of your reading journey. + +## Updating Progress + +For books with a page count, open the book detail view and drag the progress slider or type the current page number. Progress is saved automatically when you release the slider or blur the input. + +![Progress slider and percentage in the book detail view](/screenshots/progress-detail.png) + +## Progress Log + +Each book maintains a progress log showing the history of page updates: + +- Date and time of each entry +- Page number reached +- Actions to edit or delete individual entries + +The log is append-only — each update adds a new entry rather than modifying the previous one. + +![Progress log with entry history](/screenshots/progress-log.png) + +### Editing an Entry + +Click the edit button next to a progress log entry to change its date. This is useful if you forgot to log progress on the correct day. The page number cannot be changed — instead, add a new entry with the corrected page. + +![Editing a progress entry date](/screenshots/progress-entry-edit.png) + +### Deleting an Entry + +Click the delete button next to an entry to remove it from the log. A confirmation prompt appears before deletion. + +## Reading Percentage + +For books with a page count, the detail view shows your current page, total pages, reading percentage, and a visual progress bar. diff --git a/docs/guide/using-librislog/statistics.md b/docs/guide/using-librislog/statistics.md new file mode 100644 index 0000000..a29d504 --- /dev/null +++ b/docs/guide/using-librislog/statistics.md @@ -0,0 +1,81 @@ +# Statistics + +The statistics page provides insights into your reading habits with charts, totals, and visualizations. + +## Overview Cards + +At the top of the statistics page, four key metrics are displayed: + +![Statistics Overview](/screenshots/statistics.png) + +| Metric | Description | +|--------|-------------| +| **Avg Books/Month** | Average number of books finished per month | +| **Busiest Month** | The month with the most books finished | +| **Avg Page Count** | Average number of pages across all books | +| **Most Popular Language** | The language you read most (based on book count) | + +## Distribution Charts + +### Language Distribution + +A stacked bar showing the proportion of books by language. Colors represent different languages, with a legend below the chart. + +### Status Distribution + +A stacked bar showing how your library is divided among the four reading statuses: +- Want to Read (blue) +- Currently Reading (yellow) +- Read (green) +- Did Not Finish (red) + +### Page Buckets + +A stacked bar showing: +- **Pages to Read** — Total pages in "Want to Read" books +- **Pages Read** — Total pages in "Read" books +- **Pages Wasted** — Pages read in "Did Not Finish" books + +## Bar Charts + +Three bar charts show trends over time: + +### Pages Read Per Month + +Bar chart showing total pages read each month. Based on page counts of books marked as "Read" in that month. + +### Books Finished Per Month + +Bar chart showing the number of books finished each month. + +### Books Finished Per Year + +Bar chart showing the number of books finished each year. + +All bar charts support zoom and pan interactions. Click the reset button to restore the default view. + +## Calendar Heatmap + +A GitHub-style calendar heatmap shows daily reading activity: +- Each square represents one day +- Color intensity indicates pages read that day +- Darker colors = more pages +- Hover for exact page count + +The heatmap is based on reading progress entries, not just book finish dates, so you can see daily reading patterns. + +## Top Authors + +The top 3 authors by book count are displayed with: +- Author name and rank badge +- Book count +- Up to 5 cover images from their books (click to open book details) + +## Data Sources + +Statistics are computed from: +- Book metadata (page counts, languages, statuses) +- Reading progress entries (for calendar heatmap and timeline) +- Book finish dates (for monthly/yearly charts) + +All statistics are calculated server-side and updated in real-time when you modify your library. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..1cf5c24 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,38 @@ +--- +layout: home + +hero: + name: "LibrisLog" + text: "Your Personal Book Library" + tagline: Track reading progress, discover new books, and gain insights into your reading habits. + image: + src: /logo.png + alt: LibrisLog + actions: + - theme: brand + text: Get Started + link: /guide/getting-started + - theme: alt + text: View on GitHub + link: https://github.com/codebude/librislog + +features: + - icon: 📚 + title: Track Your Library + details: Organize books into Want to Read, Currently Reading, Read, and Did Not Finish statuses. + - icon: 🔍 + title: Import & Discover + details: Search Open Library, Google Books, and Hardcover.app. Scan ISBN barcodes for quick lookup. + - icon: 📊 + title: Insights & Statistics + details: Beautiful charts, reading timeline, and calendar heatmap to visualize your reading habits. + - icon: 🔄 + title: Import & Export + details: Full data portability with JSON/CSV export, backup/restore, and custom field mapping. + - icon: 🌐 + title: API Access + details: Full REST API with OpenAPI documentation. Create API keys for programmatic access. + - icon: 🎨 + title: Customizable Themes + details: Light, dark, and custom DaisyUI themes with persistent user preferences. +--- \ No newline at end of file diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 0000000..26a3f57 --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,2569 @@ +{ + "name": "librislog-docs", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "librislog-docs", + "dependencies": { + "viewerjs": "^1.11.7", + "vitepress-plugin-image-viewer": "^1.1.6" + }, + "devDependencies": { + "vitepress": "^1.6.4" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.1.tgz", + "integrity": "sha512-aehCadlWOGvrT91KUIZpC0MbB8KBW9yUuvTJFd2xesR7le/IsT4nJUnjCCZ4ZqZCeTcPHPV5mo//fZ5oxcSVYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.52.1.tgz", + "integrity": "sha512-HmXOGBOAOJPounpBzBpuY0zDYeiCpxgHnQmuA7JO6ScukcBdGp3/XM9zJk5pJx/xNGD68mbPGXWpDxGtl6BwDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.52.1.tgz", + "integrity": "sha512-5oo4+I8iixie9vXhCyNFCzeIr8pqA3FQ//VsLHTDvZAV4ttYOPGvYHGQq5NSalrLx5Jc3dRro/5uDOlnUMcBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.52.1.tgz", + "integrity": "sha512-qCDoZfx5MpX7XQzvQ3bC4tSEMkQWQMaF/ABtLuoze03Y/flR563CCSws02qIJ23oX7lxl92LsilZjINVyTdtLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.52.1.tgz", + "integrity": "sha512-hnGs0/lsFJ2PWDxNBz7pxreXo/Xz7gxYRcfePBUjsH26ad0kU/sgnVZd9LwWBpsQv65z2jlb5dkyaB9WE9M9FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.52.1.tgz", + "integrity": "sha512-2VxxNc/uBysyKvGeBdSM5n9eIDKH8kWD7wd9/yqbJAiVwU4Yv6tU1LSJusHKrXV/aCu1KW7t9Gug9QyeEmtn/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.52.1.tgz", + "integrity": "sha512-O6mPtsw3xEfNOe6gWFpYLeAZAIljNa4Hgna3bq15PwyN7nbjTY0wXJFRbzs/0YVf75Br+SbOQUmjKxXYjDiSiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.52.1.tgz", + "integrity": "sha512-gA8oJOV1LnQQkDf91iebNnFInHuW0gRPEgLSOQ7EfipCEjYTHm5swm1DlH9H5RaRw4RrHuzHBegnlzc0MAstcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.52.1", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.52.1.tgz", + "integrity": "sha512-U9zZfc5xIu9wRxZkt+HceJUAD4VKHKbAyLSloJdEyMRmphXeibfrY9cxqIXBcmPeZzGhn3Imb35Dq8l19PkJhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.52.1", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.52.1.tgz", + "integrity": "sha512-a3SGNceHmkQfq77iG8Ka+w1pvwfZa/0lzEIgse30fL0kD+yKnd/dg0dQvSfFPAEt2f21DMcGkDSSeJlO3KdQjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.52.1.tgz", + "integrity": "sha512-z98QEguCFDpxb4S/PyrUK1igqF8tPsdbqOUUO6ON91vJ58w+Gwa6ncrI0oNXSFcrkxA5EqPKPQ2A1PBCn08TYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.52.1.tgz", + "integrity": "sha512-CI7+/0I11QeZM59Uc8whd2or0kqzFVjpaPn9Qpwll/krHcBAxk24WkAQ6WX+IwDVMfpont4YGbKwAmCre3vE8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.52.1.tgz", + "integrity": "sha512-S6bDuw9byfOvm3T71cgdoZgrgnZq6hpdMLkx52Louh57nUAmvGQESz2aojOynQHjbTiV55smvAFbgn0qT4tJrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.52.1.tgz", + "integrity": "sha512-tqZXM+54rWo4mk5jL5Z/flE11nPmNEdXwFBM5py9DkOmbjeCNemfVd45FyM97XdzfZ0dl9uOJC6PYn1FpkeyQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.84", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.84.tgz", + "integrity": "sha512-v4JVu6xIewGoETD4mm2k6UAdFAbTlY1duw5ZNSxYORfs2yFsHDhoU9Omn/BgrV0nR/ptWkF3ZIr/ZHoYXI/6Jw==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "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/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "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/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/algoliasearch": { + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.52.1.tgz", + "integrity": "sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.18.1", + "@algolia/client-abtesting": "5.52.1", + "@algolia/client-analytics": "5.52.1", + "@algolia/client-common": "5.52.1", + "@algolia/client-insights": "5.52.1", + "@algolia/client-personalization": "5.52.1", + "@algolia/client-query-suggestions": "5.52.1", + "@algolia/client-search": "5.52.1", + "@algolia/ingestion": "1.52.1", + "@algolia/monitoring": "1.52.1", + "@algolia/recommend": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "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/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "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/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "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/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "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.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "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/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/viewerjs": { + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.11.7.tgz", + "integrity": "sha512-0JuVqOmL5v1jmEAlG5EBDR3XquxY8DWFQbFMprOXgaBB0F7Q/X9xWdEaQc59D8xzwkdUgXEMSSknTpriq95igg==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vitepress-plugin-image-viewer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/vitepress-plugin-image-viewer/-/vitepress-plugin-image-viewer-1.1.6.tgz", + "integrity": "sha512-ZfYy1s/bXju6vGVsE/5gPXT449+KaXZ6ZIPAvcmPM07VmC7Bv57bCvFar2II7C2EjexxfPQsxUh70vSBbe14kQ==", + "license": "MIT", + "dependencies": { + "viewerjs": "^1.11.6" + } + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..fc1bc97 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,17 @@ +{ + "name": "librislog-docs", + "private": true, + "type": "module", + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "^1.6.4" + }, + "dependencies": { + "viewerjs": "^1.11.7", + "vitepress-plugin-image-viewer": "^1.1.6" + } +} diff --git a/docs/public/favicon.ico b/docs/public/favicon.ico new file mode 100644 index 0000000..55c7531 Binary files /dev/null and b/docs/public/favicon.ico differ diff --git a/docs/public/favicon.svg b/docs/public/favicon.svg new file mode 100644 index 0000000..d83298e --- /dev/null +++ b/docs/public/favicon.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/logo.png b/docs/public/logo.png new file mode 100644 index 0000000..592e072 Binary files /dev/null and b/docs/public/logo.png differ diff --git a/docs/public/screenshots/admin-backup-thumb.png b/docs/public/screenshots/admin-backup-thumb.png new file mode 100644 index 0000000..424ac25 Binary files /dev/null and b/docs/public/screenshots/admin-backup-thumb.png differ diff --git a/docs/public/screenshots/admin-backup.png b/docs/public/screenshots/admin-backup.png new file mode 100644 index 0000000..53e5928 Binary files /dev/null and b/docs/public/screenshots/admin-backup.png differ diff --git a/docs/public/screenshots/admin-users-thumb.png b/docs/public/screenshots/admin-users-thumb.png new file mode 100644 index 0000000..a52c82a Binary files /dev/null and b/docs/public/screenshots/admin-users-thumb.png differ diff --git a/docs/public/screenshots/admin-users.png b/docs/public/screenshots/admin-users.png new file mode 100644 index 0000000..4e484ed Binary files /dev/null and b/docs/public/screenshots/admin-users.png differ diff --git a/docs/public/screenshots/dashboard-thumb.png b/docs/public/screenshots/dashboard-thumb.png new file mode 100644 index 0000000..be58afa Binary files /dev/null and b/docs/public/screenshots/dashboard-thumb.png differ diff --git a/docs/public/screenshots/dashboard.png b/docs/public/screenshots/dashboard.png new file mode 100644 index 0000000..34f5d5d Binary files /dev/null and b/docs/public/screenshots/dashboard.png differ diff --git a/docs/public/screenshots/data-import-thumb.png b/docs/public/screenshots/data-import-thumb.png new file mode 100644 index 0000000..82829fb Binary files /dev/null and b/docs/public/screenshots/data-import-thumb.png differ diff --git a/docs/public/screenshots/data-import.png b/docs/public/screenshots/data-import.png new file mode 100644 index 0000000..8bece01 Binary files /dev/null and b/docs/public/screenshots/data-import.png differ diff --git a/docs/public/screenshots/library-read-thumb.png b/docs/public/screenshots/library-read-thumb.png new file mode 100644 index 0000000..1d30c25 Binary files /dev/null and b/docs/public/screenshots/library-read-thumb.png differ diff --git a/docs/public/screenshots/library-read.png b/docs/public/screenshots/library-read.png new file mode 100644 index 0000000..c7d88b3 Binary files /dev/null and b/docs/public/screenshots/library-read.png differ diff --git a/docs/public/screenshots/profile-api-keys-thumb.png b/docs/public/screenshots/profile-api-keys-thumb.png new file mode 100644 index 0000000..0c7ce3f Binary files /dev/null and b/docs/public/screenshots/profile-api-keys-thumb.png differ diff --git a/docs/public/screenshots/profile-api-keys.png b/docs/public/screenshots/profile-api-keys.png new file mode 100644 index 0000000..6817eb0 Binary files /dev/null and b/docs/public/screenshots/profile-api-keys.png differ diff --git a/docs/public/screenshots/profile-thumb.png b/docs/public/screenshots/profile-thumb.png new file mode 100644 index 0000000..4633843 Binary files /dev/null and b/docs/public/screenshots/profile-thumb.png differ diff --git a/docs/public/screenshots/profile.png b/docs/public/screenshots/profile.png new file mode 100644 index 0000000..cb2d94f Binary files /dev/null and b/docs/public/screenshots/profile.png differ diff --git a/docs/public/screenshots/progress-detail-thumb.png b/docs/public/screenshots/progress-detail-thumb.png new file mode 100644 index 0000000..e1144d5 Binary files /dev/null and b/docs/public/screenshots/progress-detail-thumb.png differ diff --git a/docs/public/screenshots/progress-detail.png b/docs/public/screenshots/progress-detail.png new file mode 100644 index 0000000..7742bd5 Binary files /dev/null and b/docs/public/screenshots/progress-detail.png differ diff --git a/docs/public/screenshots/progress-entry-edit-thumb.png b/docs/public/screenshots/progress-entry-edit-thumb.png new file mode 100644 index 0000000..e9dd2fd Binary files /dev/null and b/docs/public/screenshots/progress-entry-edit-thumb.png differ diff --git a/docs/public/screenshots/progress-entry-edit.png b/docs/public/screenshots/progress-entry-edit.png new file mode 100644 index 0000000..0b64a67 Binary files /dev/null and b/docs/public/screenshots/progress-entry-edit.png differ diff --git a/docs/public/screenshots/progress-log-thumb.png b/docs/public/screenshots/progress-log-thumb.png new file mode 100644 index 0000000..dab97e7 Binary files /dev/null and b/docs/public/screenshots/progress-log-thumb.png differ diff --git a/docs/public/screenshots/progress-log.png b/docs/public/screenshots/progress-log.png new file mode 100644 index 0000000..47ea4b7 Binary files /dev/null and b/docs/public/screenshots/progress-log.png differ diff --git a/docs/public/screenshots/statistics-thumb.png b/docs/public/screenshots/statistics-thumb.png new file mode 100644 index 0000000..a2f4eea Binary files /dev/null and b/docs/public/screenshots/statistics-thumb.png differ diff --git a/docs/public/screenshots/statistics.png b/docs/public/screenshots/statistics.png new file mode 100644 index 0000000..d4355b4 Binary files /dev/null and b/docs/public/screenshots/statistics.png differ diff --git a/frontend/.dockerignore b/frontend/.dockerignore index 18774d6..b02d062 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -2,3 +2,6 @@ node_modules .svelte-kit build .env +playwright-report +test-results +e2e-results diff --git a/frontend/.gitignore b/frontend/.gitignore index 37e9332..bc9480c 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,6 +22,10 @@ Thumbs.db vite.config.js.timestamp-* vite.config.ts.timestamp-* +# E2E test artifacts +playwright-report/ +test-results/ + # Coverage coverage/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile index e1b66ef..03c231d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -11,6 +11,7 @@ COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN echo "export const version = '${APP_VERSION}'; export const gitSha = '${GIT_SHA}';" > src/lib/version.ts +RUN echo "{\"version\":\"${APP_VERSION}\",\"sha\":\"${GIT_SHA}\"}" > static/version.json RUN npm run build # Stage 2: serve diff --git a/frontend/Dockerfile.e2e b/frontend/Dockerfile.e2e new file mode 100644 index 0000000..6cca985 --- /dev/null +++ b/frontend/Dockerfile.e2e @@ -0,0 +1,9 @@ +FROM mcr.microsoft.com/playwright:v1.60.0-noble + +WORKDIR /app/frontend +COPY package.json package-lock.json ./ +RUN npm ci +RUN npx playwright install chromium +COPY . . + +CMD ["npx", "playwright", "test", "--config=playwright.config.ts"] diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index e9728fc..0000000 --- a/frontend/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.15.3 create --template minimal --types ts --no-install frontend -``` - -## 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/frontend/e2e/config/global-setup.ts b/frontend/e2e/config/global-setup.ts new file mode 100644 index 0000000..1082449 --- /dev/null +++ b/frontend/e2e/config/global-setup.ts @@ -0,0 +1,21 @@ +import type { FullConfig } from '@playwright/test'; + +const BACKEND_URL = process.env.E2E_BACKEND_URL || 'http://backend:8000/api/health'; + +async function globalSetup(_config: FullConfig) { + const healthy = await fetch(BACKEND_URL) + .then(r => r.ok) + .catch(() => false); + + if (!healthy) { + throw new Error( + `Backend not reachable at ${BACKEND_URL}.\n` + + 'Ensure the E2E Docker stack is running:\n' + + ' docker compose -f docker-compose.e2e.yml up --build' + ); + } + + console.log('✓ Backend is healthy'); +} + +export default globalSetup; diff --git a/frontend/e2e/config/global-teardown.ts b/frontend/e2e/config/global-teardown.ts new file mode 100644 index 0000000..b1960d5 --- /dev/null +++ b/frontend/e2e/config/global-teardown.ts @@ -0,0 +1,6 @@ +async function globalTeardown() { + // Cleanup is handled by docker compose --abort-on-container-exit + console.log('✓ E2E tests finished'); +} + +export default globalTeardown; diff --git a/frontend/e2e/fixtures/auth.fixture.ts b/frontend/e2e/fixtures/auth.fixture.ts new file mode 100644 index 0000000..e73fa21 --- /dev/null +++ b/frontend/e2e/fixtures/auth.fixture.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +export async function loginViaUi(page: Page, email: string, password: string): Promise { + await page.goto('/login'); + await page.waitForLoadState('networkidle'); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', password); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/(dashboard|library)/); +} diff --git a/frontend/e2e/fixtures/import-test-data.csv b/frontend/e2e/fixtures/import-test-data.csv new file mode 100644 index 0000000..446cebd --- /dev/null +++ b/frontend/e2e/fixtures/import-test-data.csv @@ -0,0 +1,3 @@ +title,author,isbn,pages,status +"The Imported Book","Import Author","1234567890",300,want_to_read +"Second Imported","Another Author","9876543210",250,read diff --git a/frontend/e2e/fixtures/pages/add-book-modal.page.ts b/frontend/e2e/fixtures/pages/add-book-modal.page.ts new file mode 100644 index 0000000..006ec7c --- /dev/null +++ b/frontend/e2e/fixtures/pages/add-book-modal.page.ts @@ -0,0 +1,25 @@ +import type { Page } from '@playwright/test'; + +export class AddBookModalPage { + constructor(private page: Page) {} + + async open() { + await this.page.locator('button').filter({ hasText: /add book/i }).first().click(); + await this.page.waitForTimeout(500); + } + + async fillTitle(title: string) { + const input = this.page.locator('[role="dialog"] input[name="title"], [role="dialog"] input[placeholder*="Title"]').first(); + await input.fill(title); + } + + async fillAuthor(author: string) { + const input = this.page.locator('[role="dialog"] input[name="author"], [role="dialog"] input[placeholder*="Author"]').first(); + await input.fill(author); + } + + async clickSave() { + await this.page.locator('[role="dialog"] button[type="submit"]').first().click(); + await this.page.waitForTimeout(500); + } +} diff --git a/frontend/e2e/fixtures/pages/book-detail.page.ts b/frontend/e2e/fixtures/pages/book-detail.page.ts new file mode 100644 index 0000000..81eef81 --- /dev/null +++ b/frontend/e2e/fixtures/pages/book-detail.page.ts @@ -0,0 +1,33 @@ +import type { Page } from '@playwright/test'; + +export class BookDetailPage { + constructor(private page: Page) {} + + async waitForDialog() { + await this.page.waitForSelector('[role="dialog"]'); + } + + async clickEdit() { + const editBtn = this.page.locator('[role="dialog"] button, [role="dialog"] a') + .filter({ hasText: /edit/i }).first(); + await editBtn.click(); + } + + async clickDelete() { + const deleteBtn = this.page.locator('[role="dialog"] button, [role="dialog"] a') + .filter({ hasText: /delete/i }).first(); + await deleteBtn.click(); + } + + async confirmDelete() { + const confirmBtn = this.page.locator('[role="dialog"] button, [role="alertdialog"] button') + .filter({ hasText: /confirm|delete|yes/i }).first(); + if (await confirmBtn.isVisible()) { + await confirmBtn.click(); + } + } + + async getTitle() { + return this.page.locator('[role="dialog"] h2, [role="dialog"] h3').first().textContent(); + } +} diff --git a/frontend/e2e/fixtures/pages/book-drawer.page.ts b/frontend/e2e/fixtures/pages/book-drawer.page.ts new file mode 100644 index 0000000..4ef1ec9 --- /dev/null +++ b/frontend/e2e/fixtures/pages/book-drawer.page.ts @@ -0,0 +1,32 @@ +import type { Page } from '@playwright/test'; + +export class BookDrawerPage { + constructor(private page: Page) {} + + async waitForDrawer() { + await this.page.waitForTimeout(500); + } + + async fillTitle(title: string) { + const input = this.page.locator('input[name="title"], input[placeholder*="Title"]').first(); + await input.fill(title); + } + + async fillAuthor(author: string) { + const input = this.page.locator('input[name="author"], input[placeholder*="Author"]').first(); + await input.fill(author); + } + + async clickSave() { + const saveBtn = this.page.locator('button').filter({ hasText: /save/i }).first(); + await saveBtn.click(); + await this.page.waitForTimeout(500); + } + + async close() { + const closeBtn = this.page.locator('button').filter({ hasText: /cancel|close/i }).first(); + if (await closeBtn.isVisible()) { + await closeBtn.click(); + } + } +} diff --git a/frontend/e2e/fixtures/pages/dashboard.page.ts b/frontend/e2e/fixtures/pages/dashboard.page.ts new file mode 100644 index 0000000..880144f --- /dev/null +++ b/frontend/e2e/fixtures/pages/dashboard.page.ts @@ -0,0 +1,29 @@ +import type { Page } from '@playwright/test'; + +export class DashboardPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/dashboard'); + await this.page.waitForSelector('h1'); + } + + async navigateToLibrary() { + await this.page.click('a[href="/library"]'); + await this.page.waitForURL('/library'); + } + + async navigateToTimeline() { + await this.page.click('a[href="/timeline"]'); + await this.page.waitForURL('/timeline'); + } + + async navigateToStatistics() { + await this.page.click('a[href="/statistics"]'); + await this.page.waitForURL('/statistics'); + } + + async getHeading() { + return this.page.locator('h1').textContent(); + } +} diff --git a/frontend/e2e/fixtures/pages/library.page.ts b/frontend/e2e/fixtures/pages/library.page.ts new file mode 100644 index 0000000..8f756cb --- /dev/null +++ b/frontend/e2e/fixtures/pages/library.page.ts @@ -0,0 +1,35 @@ +import type { Page, Locator } from '@playwright/test'; + +export class LibraryPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/library'); + await this.page.waitForSelector('h1'); + } + + async switchTab(status: string) { + const tab = this.page.locator(`[role="tab"]`).filter({ hasText: new RegExp(status, 'i') }); + await tab.click(); + await this.page.waitForTimeout(500); + } + + getBookCards(): Locator { + return this.page.locator('button.card'); + } + + async getBookCount(): Promise { + return this.getBookCards().count(); + } + + async openBookDetail(title: string) { + await this.page.locator(`text="${title}"`).first().click(); + await this.page.waitForSelector('[role="dialog"]'); + } + + async search(query: string) { + const searchInput = this.page.locator('input[type="search"], input[placeholder*="Search"]').first(); + await searchInput.fill(query); + await this.page.waitForTimeout(500); + } +} diff --git a/frontend/e2e/fixtures/pages/login.page.ts b/frontend/e2e/fixtures/pages/login.page.ts new file mode 100644 index 0000000..535ad92 --- /dev/null +++ b/frontend/e2e/fixtures/pages/login.page.ts @@ -0,0 +1,23 @@ +import type { Page } from '@playwright/test'; + +export class LoginPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/login'); + await this.page.waitForSelector('h1'); + } + + async login(email: string, password: string) { + await this.page.fill('input[type="email"]', email); + await this.page.fill('input[type="password"]', password); + await this.page.click('button[type="submit"]'); + await this.page.waitForURL(/\/(dashboard|library)/); + } + + async getErrorMessage() { + const alert = this.page.locator('[role="alert"]'); + const text = await alert.textContent(); + return text ?? null; + } +} diff --git a/frontend/e2e/fixtures/pages/setup.page.ts b/frontend/e2e/fixtures/pages/setup.page.ts new file mode 100644 index 0000000..74390b0 --- /dev/null +++ b/frontend/e2e/fixtures/pages/setup.page.ts @@ -0,0 +1,19 @@ +import type { Page } from '@playwright/test'; + +export class SetupPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/setup'); + await this.page.waitForSelector('h1'); + } + + async setupAdmin(firstname: string, lastname: string, email: string, password: string) { + await this.page.fill('input[autocomplete="given-name"]', firstname); + await this.page.fill('input[autocomplete="family-name"]', lastname); + await this.page.fill('input[type="email"]', email); + await this.page.fill('input[autocomplete="new-password"]', password); + await this.page.click('button[type="submit"]'); + await this.page.waitForURL(/\/(dashboard|library)/); + } +} diff --git a/frontend/e2e/fixtures/seed-data.ts b/frontend/e2e/fixtures/seed-data.ts new file mode 100644 index 0000000..f536505 --- /dev/null +++ b/frontend/e2e/fixtures/seed-data.ts @@ -0,0 +1,29 @@ +export const SEED_USER = { + email: 'e2e@test.local', + password: 'TestPassword123!', + firstname: 'E2E', + lastname: 'Tester', +}; + +export interface SeedBook { + title: string; + author: string; + isbn?: string; + page_count?: number; + reading_status: 'want_to_read' | 'currently_reading' | 'read' | 'did_not_finish'; + rating?: number; + tags?: string; + date_started?: string; + date_finished?: string; +} + +export const SEED_BOOKS: SeedBook[] = [ + { title: 'The Great Gatsby', author: 'F. Scott Fitzgerald', isbn: '9780743273565', reading_status: 'want_to_read', rating: 4, page_count: 180 }, + { title: 'Dune', author: 'Frank Herbert', isbn: '9780441013593', reading_status: 'want_to_read', page_count: 412 }, + { title: 'Neuromancer', author: 'William Gibson', isbn: '9780441569595', reading_status: 'want_to_read', page_count: 271 }, + { title: 'The Three-Body Problem', author: 'Liu Cixin', isbn: '9780765377067', reading_status: 'currently_reading', page_count: 400, date_started: '2025-01-15' }, + { title: 'To Kill a Mockingbird', author: 'Harper Lee', isbn: '9780061120084', reading_status: 'read', rating: 5, page_count: 281, date_started: '2024-11-01', date_finished: '2024-12-15' }, + { title: '1984', author: 'George Orwell', isbn: '9780451524935', reading_status: 'read', rating: 5, page_count: 328, date_started: '2024-10-01', date_finished: '2024-10-20' }, + { title: 'Brave New World', author: 'Aldous Huxley', isbn: '9780060850524', reading_status: 'read', rating: 4, page_count: 311, date_started: '2024-09-01', date_finished: '2024-09-18' }, + { title: 'Atlas Shrugged', author: 'Ayn Rand', reading_status: 'did_not_finish', page_count: 1168 }, +]; diff --git a/frontend/e2e/fixtures/seed.api.ts b/frontend/e2e/fixtures/seed.api.ts new file mode 100644 index 0000000..9262191 --- /dev/null +++ b/frontend/e2e/fixtures/seed.api.ts @@ -0,0 +1,41 @@ +import type { Page } from '@playwright/test'; +import type { SeedBook } from './seed-data'; + +function bookApiPath(): string { + return '/api/books'; +} + +function csrfPath(): string { + return '/api/auth/csrf'; +} + +async function getCsrfToken(page: Page): Promise { + const resp = await page.request.get(csrfPath()); + const { csrf_token } = await resp.json(); + return csrf_token; +} + +export async function seedBooks(page: Page, books: SeedBook[]): Promise { + for (const book of books) { + const csrf = await getCsrfToken(page); + await page.request.post(bookApiPath(), { + data: book, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf, + }, + }); + } +} + +export async function deleteAllBooks(page: Page): Promise { + const resp = await page.request.get(bookApiPath() + '?limit=200'); + const body = await resp.json(); + const books: { id: number }[] = body.books; + for (const book of books) { + const csrf = await getCsrfToken(page); + await page.request.delete(`${bookApiPath()}/${book.id}`, { + headers: { 'X-CSRF-Token': csrf }, + }); + } +} diff --git a/frontend/e2e/specs/01-setup-and-login.spec.ts b/frontend/e2e/specs/01-setup-and-login.spec.ts new file mode 100644 index 0000000..33b5b67 --- /dev/null +++ b/frontend/e2e/specs/01-setup-and-login.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../fixtures/pages/login.page'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { SEED_USER } from '../fixtures/seed-data'; + +test.describe('Setup & Login', () => { + test('1.1 first-time setup flow creates admin and lands on dashboard', async ({ page }) => { + const resp = await page.request.get('/api/auth/setup-required'); + const { required } = await resp.json(); + test.skip(!required, 'Setup already completed — skipping'); + + await page.goto('/setup'); + await expect(page.locator('h1')).toBeVisible(); + + await page.fill('input[autocomplete="given-name"]', SEED_USER.firstname); + await page.fill('input[autocomplete="family-name"]', SEED_USER.lastname); + await page.fill('input[type="email"]', SEED_USER.email); + await page.fill('input[autocomplete="new-password"]', SEED_USER.password); + await page.click('button[type="submit"]'); + + await page.waitForURL(/\/(dashboard|library)/); + await expect(page.locator('h1')).toBeVisible(); + }); + + test('1.2 login with valid credentials', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login(SEED_USER.email, SEED_USER.password); + await expect(page.locator('h1')).toBeVisible(); + }); + + test('1.3 login with wrong password shows error', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await page.fill('input[type="email"]', SEED_USER.email); + await page.fill('input[type="password"]', 'wrongpassword'); + await page.click('button[type="submit"]'); + await expect(page.locator('[role="alert"]')).toBeVisible(); + await expect(page).toHaveURL(/\/login/); + }); + + test('1.4 unauthenticated user is redirected to login', async ({ page }) => { + await page.goto('/library'); + await expect(page).toHaveURL(/\/login/); + }); + + test('1.5 setup redirects to dashboard when already set up', async ({ page }) => { + const resp = await page.request.get('/api/auth/setup-required'); + const { required } = await resp.json(); + test.skip(required, 'Setup not yet completed — skipping'); + + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + await page.goto('/setup'); + await expect(page).toHaveURL(/\/(dashboard|library)/); + }); +}); diff --git a/frontend/e2e/specs/02-dashboard.spec.ts b/frontend/e2e/specs/02-dashboard.spec.ts new file mode 100644 index 0000000..12d20aa --- /dev/null +++ b/frontend/e2e/specs/02-dashboard.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { seedBooks } from '../fixtures/seed.api'; +import { SEED_USER, SEED_BOOKS } from '../fixtures/seed-data'; + +test.describe('Dashboard', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + await page.goto('/dashboard'); + await page.waitForSelector('h1'); + }); + + test('2.1 dashboard loads stats', async ({ page }) => { + await seedBooks(page, SEED_BOOKS); + await page.reload(); + await page.waitForSelector('h1'); + const body = page.locator('body'); + await expect(body).toContainText(/want to read|currently reading|total/i); + }); + + test('2.2 navigate to library via sidebar', async ({ page }) => { + await page.click('a[href="/library"]'); + await expect(page).toHaveURL('/library'); + }); + + test('2.3 navigate to timeline via sidebar', async ({ page }) => { + await page.click('a[href="/timeline"]'); + await expect(page).toHaveURL('/timeline'); + }); + + test('2.4 navigate to statistics via sidebar', async ({ page }) => { + await page.click('a[href="/statistics"]'); + await expect(page).toHaveURL('/statistics'); + }); + + test('2.5 search on dashboard and navigate to full search results page via Enter', async ({ page }) => { + await seedBooks(page, SEED_BOOKS); + await page.reload(); + await page.waitForSelector('h1'); + + const searchInput = page.locator('input[type="text"]'); + await searchInput.fill('Gatsby'); + await page.waitForTimeout(1000); + + await searchInput.press('Enter'); + await expect(page).toHaveURL(/\/search\?q=Gatsby/); + await expect(page.locator('body')).toContainText(/The Great Gatsby/i); + }); + + test('2.6 arrow key navigation in dropdown opens book detail dialog on Enter', async ({ page }) => { + await seedBooks(page, SEED_BOOKS); + await page.reload(); + await page.waitForSelector('h1'); + + const searchInput = page.locator('input[type="text"]'); + await searchInput.fill('Gatsby'); + await page.waitForTimeout(1000); + + await expect(page.locator('[role="listbox"]')).toBeVisible({ timeout: 5000 }); + + await searchInput.press('ArrowDown'); + await searchInput.press('Enter'); + + await expect(page).toHaveURL('/dashboard'); + await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('[role="dialog"]')).toContainText(/The Great Gatsby/i); + }); +}); diff --git a/frontend/e2e/specs/03-library-browsing.spec.ts b/frontend/e2e/specs/03-library-browsing.spec.ts new file mode 100644 index 0000000..6c68b5a --- /dev/null +++ b/frontend/e2e/specs/03-library-browsing.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { seedBooks, deleteAllBooks } from '../fixtures/seed.api'; +import { SEED_USER, SEED_BOOKS } from '../fixtures/seed-data'; +import { LibraryPage } from '../fixtures/pages/library.page'; + +test.describe('Library Browsing', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + await deleteAllBooks(page); + await seedBooks(page, SEED_BOOKS); + }); + + test('3.1 default tab shows books', async ({ page }) => { + const library = new LibraryPage(page); + await library.goto(); + await page.waitForTimeout(1000); + const count = await library.getBookCount(); + expect(count).toBeGreaterThanOrEqual(0); + }); + + test('3.2 switch reading status tabs', async ({ page }) => { + const library = new LibraryPage(page); + await library.goto(); + await page.waitForTimeout(1000); + + await library.switchTab('currently reading'); + await page.waitForTimeout(500); + const currentlyReadingCount = await library.getBookCount(); + expect(currentlyReadingCount).toBeGreaterThanOrEqual(1); + + await library.switchTab('want to read'); + await page.waitForTimeout(500); + }); + + test('3.3 search in library', async ({ page }) => { + const library = new LibraryPage(page); + await library.goto(); + await page.waitForTimeout(1000); + + await library.search('Gatsby'); + await page.waitForTimeout(1000); + const body = page.locator('body'); + await expect(body).toContainText(/Gatsby/i); + }); + + test('3.4 empty state when no books match', async ({ page }) => { + await deleteAllBooks(page); + const library = new LibraryPage(page); + await library.goto(); + await page.waitForTimeout(1000); + const body = page.locator('body'); + await expect(body).toContainText(/no books|empty/i); + }); +}); diff --git a/frontend/e2e/specs/04-search-page.spec.ts b/frontend/e2e/specs/04-search-page.spec.ts new file mode 100644 index 0000000..b32ba35 --- /dev/null +++ b/frontend/e2e/specs/04-search-page.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { seedBooks, deleteAllBooks } from '../fixtures/seed.api'; +import { SEED_USER, SEED_BOOKS } from '../fixtures/seed-data'; + +test.describe('Search Page', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + await deleteAllBooks(page); + await seedBooks(page, SEED_BOOKS); + }); + + test('4.1 search results page displays matching books from URL param', async ({ page }) => { + await page.goto('/search?q=Gatsby'); + await page.waitForTimeout(1500); + + await expect(page.locator('body')).toContainText(/The Great Gatsby/i); + }); + + test('4.2 empty results page shows no results message', async ({ page }) => { + await page.goto('/search?q=xyznonexistent'); + await page.waitForTimeout(1500); + + await expect(page.locator('body')).toContainText(/No results/i); + }); + + test('4.3 typing in search input triggers live search', async ({ page }) => { + await page.goto('/search'); + await page.waitForTimeout(500); + + const input = page.locator('input[type="text"]'); + await input.fill('Dune'); + await page.waitForTimeout(500); + + await expect(page.locator('body')).toContainText(/Dune/i); + }); + + test('4.4 pressing Enter updates URL and re-searches', async ({ page }) => { + await page.goto('/search'); + await page.waitForTimeout(500); + + const input = page.locator('input[type="text"]'); + await input.fill('Neuromancer'); + await input.press('Enter'); + + await expect(page).toHaveURL(/\/search\?q=Neuromancer/); + await page.waitForTimeout(500); + await expect(page.locator('body')).toContainText(/Neuromancer/i); + }); + + test('4.5 clear button clears input and results', async ({ page }) => { + await page.goto('/search?q=1984'); + await page.waitForTimeout(1500); + + await expect(page.locator('body')).toContainText(/1984/i); + + // Click via JS to bypass any overlaying elements (UserMenu fixed container) + await page.evaluate(() => { + const btn = document.querySelector('button[aria-label="Clear Form"]') as HTMLButtonElement | null; + btn?.click(); + }); + await page.waitForTimeout(500); + + const input = page.locator('input[type="text"]'); + await expect(input).toHaveValue(''); + }); + + test('4.6 back button navigates to previous page', async ({ page }) => { + await page.goto('/dashboard'); + await page.waitForSelector('h1'); + + await page.goto('/search?q=Gatsby'); + await page.waitForTimeout(500); + + const backBtn = page.locator('button[aria-label="Back"]'); + await backBtn.click(); + + await expect(page).toHaveURL('/dashboard'); + }); +}); diff --git a/frontend/e2e/specs/05-edit-book.spec.ts b/frontend/e2e/specs/05-edit-book.spec.ts new file mode 100644 index 0000000..b2395dc --- /dev/null +++ b/frontend/e2e/specs/05-edit-book.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { seedBooks, deleteAllBooks } from '../fixtures/seed.api'; +import { SEED_USER, SEED_BOOKS } from '../fixtures/seed-data'; +import { LibraryPage } from '../fixtures/pages/library.page'; + +test.describe('Edit Book', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + await deleteAllBooks(page); + await seedBooks(page, SEED_BOOKS); + }); + + test('5.1 edit basic fields', async ({ page }) => { + const library = new LibraryPage(page); + await library.goto(); + await page.waitForTimeout(1000); + + await library.switchTab('want to read'); + await page.waitForTimeout(500); + + const cards = library.getBookCards(); + await expect(cards.first()).toBeVisible({ timeout: 5000 }); + + await cards.first().click(); + await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + + const editBtn = page.locator('[role="dialog"] button').filter({ hasText: 'Edit' }); + await expect(editBtn.first()).toBeVisible({ timeout: 5000 }); + await editBtn.first().click(); + + await page.waitForTimeout(500); + const body = page.locator('body'); + await expect(body).toContainText(/title|author|save/i); + }); + + test('5.2 cancel edit does not change values', async ({ page }) => { + const library = new LibraryPage(page); + await library.goto(); + await page.waitForTimeout(1000); + + await library.switchTab('want to read'); + await page.waitForTimeout(500); + + const cards = library.getBookCards(); + await expect(cards.first()).toBeVisible({ timeout: 5000 }); + + await cards.first().click(); + await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + + const editBtn = page.locator('[role="dialog"] button').filter({ hasText: 'Edit' }); + await expect(editBtn.first()).toBeVisible({ timeout: 5000 }); + await editBtn.first().click(); + + const closeBtn = page.locator('button').filter({ hasText: /cancel|close/i }).first(); + if (await closeBtn.isVisible()) { + await closeBtn.click(); + } + await page.waitForTimeout(500); + }); +}); diff --git a/frontend/e2e/specs/06-data-hygiene.spec.ts b/frontend/e2e/specs/06-data-hygiene.spec.ts new file mode 100644 index 0000000..2510ae9 --- /dev/null +++ b/frontend/e2e/specs/06-data-hygiene.spec.ts @@ -0,0 +1,141 @@ +import { test, expect } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { deleteAllBooks } from '../fixtures/seed.api'; +import { SEED_USER } from '../fixtures/seed-data'; + +test.describe('Data Hygiene', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + await deleteAllBooks(page); + + // Seed books with missing attributes. + // author, title, page_count are mandatory in BookCreate, so + // "missing" means empty string for author / 0 for page_count. + const books = [ + { title: 'Complete Book', author: 'Test Author', isbn: '9780000000001', publisher: 'Test Pub', page_count: 200, reading_status: 'want_to_read' as const }, + { title: 'Missing Author', author: '', isbn: '9780000000002', publisher: 'Test Pub', page_count: 150, reading_status: 'want_to_read' as const }, + { title: 'Missing ISBN', author: 'No ISBN', page_count: 300, reading_status: 'want_to_read' as const }, + { title: 'Missing Page Count', author: 'Page Author', page_count: 0, reading_status: 'want_to_read' as const }, + { title: 'Missing Publisher', author: 'Pub Missing', isbn: '9780000000003', page_count: 250, reading_status: 'want_to_read' as const }, + ]; + + for (const book of books) { + const csrfResp = await page.request.get('/api/auth/csrf'); + const { csrf_token } = await csrfResp.json(); + await page.request.post('/api/books', { + data: book, + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf_token }, + }); + } + }); + + test('6.1 shows title and description', async ({ page }) => { + await page.goto('/data-hygiene'); + await expect(page.locator('h1')).toHaveText('Data Hygiene'); + await expect(page.getByText(/missing metadata/)).toBeVisible(); + }); + + test('6.2 shows books with missing attributes', async ({ page }) => { + await page.goto('/data-hygiene'); + await page.waitForTimeout(1000); + + await expect(page.getByText('Missing Author')).toBeVisible(); + await expect(page.getByText('No ISBN')).toBeVisible(); + await expect(page.getByText('Missing Publisher')).toBeVisible(); + await expect(page.getByText('Missing Page Count')).toBeVisible(); + }); + + test('6.3 filtering by attribute shows only relevant books', async ({ page }) => { + await page.goto('/data-hygiene'); + await page.waitForTimeout(1000); + + const isbnChip = page.locator('button').filter({ hasText: 'ISBN' }).first(); + await isbnChip.click(); + await page.waitForTimeout(1000); + + await expect(page.getByText('No ISBN')).toBeVisible(); + await expect(page.getByText('Missing Author')).not.toBeVisible(); + await expect(page.getByText('Missing Publisher')).not.toBeVisible(); + }); + + test('6.4 shows all-complete message when no missing books', async ({ page }) => { + await page.goto('/data-hygiene'); + await page.waitForTimeout(1000); + + // Click all chips to filter to nothing, then verify + // the all-complete state by selecting a specific attribute + // that no book is missing + }); + + test('6.5 rejects empty author on batch update', async ({ page }) => { + await page.goto('/data-hygiene'); + await page.waitForTimeout(1000); + + // Select the book that is missing author + await page.locator('table tbody tr').filter({ hasText: 'Missing Author' }).locator('input[type="checkbox"]').click(); + + // Select author field in batch bar + const fieldSelect = page.getByLabel('Field to update'); + await fieldSelect.selectOption('author'); + + // Leave value empty and click Apply + await page.getByText('Apply to selected').click(); + + // Verify error toast — the frontend must block empty mandatory values + await expect(page.getByText('Author cannot be empty.')).toBeVisible(); + }); + + test('6.6 rejects page_count <= 0 on batch update', async ({ page }) => { + await page.goto('/data-hygiene'); + await page.waitForTimeout(1000); + + // Select the book that has page_count = 0 + const rows = page.locator('table tbody tr'); + await rows.filter({ hasText: 'Missing Page Count' }).locator('input[type="checkbox"]').click(); + + // Select page_count field + const fieldSelect = page.getByLabel('Field to update'); + await fieldSelect.selectOption('page_count'); + + // Enter 0 + const valueInput = page.getByLabel('New value'); + await valueInput.fill('0'); + + // Click Apply + await page.getByText('Apply to selected').click(); + + // Verify error toast + await expect(page.getByText('Page count must be greater than 0.')).toBeVisible(); + }); + + test('6.7 batch updates author successfully', async ({ page }) => { + await page.goto('/data-hygiene'); + await page.waitForTimeout(1000); + + // Select books + const rows = page.locator('table tbody tr'); + const rowCount = await rows.count(); + expect(rowCount).toBeGreaterThanOrEqual(3); + + await rows.nth(0).locator('input[type="checkbox"]').click(); + await rows.nth(1).locator('input[type="checkbox"]').click(); + + // Select author field + const fieldSelect = page.getByLabel('Field to update'); + await fieldSelect.selectOption('author'); + + // Enter a valid value + const valueInput = page.getByLabel('New value'); + await valueInput.fill('Batch Author'); + + // Click Apply + await page.getByText('Apply to selected').click(); + + // Confirm dialog + await page.getByText('Apply update').click(); + await page.waitForTimeout(1000); + + // Verify success toast + await expect(page.getByText(/updated/i)).toBeVisible({ timeout: 3000 }); + }); +}); diff --git a/frontend/e2e/specs/07-timeline.spec.ts b/frontend/e2e/specs/07-timeline.spec.ts new file mode 100644 index 0000000..1fd246f --- /dev/null +++ b/frontend/e2e/specs/07-timeline.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { seedBooks, deleteAllBooks } from '../fixtures/seed.api'; +import { SEED_USER, SEED_BOOKS } from '../fixtures/seed-data'; + +test.describe('Timeline', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + await deleteAllBooks(page); + await seedBooks(page, SEED_BOOKS); + }); + + test('7.1 timeline loads read books sorted by date', async ({ page }) => { + await page.goto('/timeline'); + await page.waitForTimeout(2000); + const body = page.locator('body'); + await expect(body).toContainText(/1984|To Kill a Mockingbird|Brave New World/i); + }); + + test('7.2 open book detail from timeline', async ({ page }) => { + await page.goto('/timeline'); + await page.waitForTimeout(2000); + const bookLink = page.locator('a, button, [role="button"]').filter({ hasText: /1984/i }).first(); + await expect(bookLink).toBeVisible({ timeout: 5000 }); + await bookLink.click(); + await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/frontend/e2e/specs/08-statistics.spec.ts b/frontend/e2e/specs/08-statistics.spec.ts new file mode 100644 index 0000000..591c2d0 --- /dev/null +++ b/frontend/e2e/specs/08-statistics.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { seedBooks, deleteAllBooks } from '../fixtures/seed.api'; +import { SEED_USER, SEED_BOOKS } from '../fixtures/seed-data'; + +test.describe('Statistics', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + await deleteAllBooks(page); + await seedBooks(page, SEED_BOOKS); + }); + + test('8.1 statistics page loads', async ({ page }) => { + await page.goto('/statistics'); + await page.waitForTimeout(2000); + const body = page.locator('body'); + await expect(body).toContainText(/total|books|pages|rating|read/i); + }); +}); diff --git a/frontend/e2e/specs/09-data-import.spec.ts b/frontend/e2e/specs/09-data-import.spec.ts new file mode 100644 index 0000000..62d12d7 --- /dev/null +++ b/frontend/e2e/specs/09-data-import.spec.ts @@ -0,0 +1,105 @@ +import { test, expect } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { SEED_USER } from '../fixtures/seed-data'; + +test.describe('Data Import', () => { + const CSV = `title,author,isbn,pages,status +"The Imported Book","Import Author","1234567890",300,want_to_read +"Second Imported","Another Author","9876543210",250,want_to_read`; + + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + }); + + test('9.1 import page loads', async ({ page }) => { + await page.goto('/data?tab=import'); + await page.waitForTimeout(1000); + const body = page.locator('body'); + await expect(body).toContainText(/Import/i); + }); + + test('9.2 full import flow creates books in library', async ({ page }) => { + await page.goto('/data?tab=import'); + await page.waitForTimeout(1000); + + await page.locator('input[type="file"]').setInputFiles({ + name: 'test-books.csv', + mimeType: 'text/csv', + buffer: Buffer.from(CSV), + }); + + await page.locator('button').filter({ hasText: 'Parse file' }).click(); + await page.waitForTimeout(2000); + + await expect(page.locator('select[name="mapping-target-title"]')).toBeVisible({ timeout: 10000 }); + + await page.locator('select[name="mapping-target-title"]').selectOption('title'); + await page.locator('select[name="mapping-target-author"]').selectOption('author'); + await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); + await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); + await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); + + await page.locator('textarea[name="mapping-transform-title"]').fill('value.strip().upper()'); + + await page.locator('button').filter({ hasText: 'Generate' }).click(); + await page.waitForTimeout(2000); + + const previewBody = page.locator('body'); + await expect(previewBody).toContainText(/THE IMPORTED BOOK|SECOND IMPORTED/i, { timeout: 10000 }); + + await page.locator('button').filter({ hasText: 'Simulate' }).click(); + await page.waitForTimeout(2000); + + const body = page.locator('body'); + const validationOk = body.getByText('Validation passed.'); + const valid = await validationOk.isVisible().catch(() => false); + + if (valid) { + await page.locator('button.btn-secondary.btn-sm').filter({ hasText: 'Import now' }).click(); + await page.locator('dialog.modal-open .btn-secondary').filter({ hasText: 'Import now' }).waitFor({ state: 'visible', timeout: 5000 }); + await page.locator('dialog.modal-open .btn-secondary').filter({ hasText: 'Import now' }).click(); + await page.waitForTimeout(2000); + + await expect(page.locator('dialog.modal-open')).not.toBeVisible({ timeout: 15000 }); + await expect(body).toContainText(/Import complete/i, { timeout: 5000 }); + } + + await page.goto('/library'); + await page.waitForTimeout(1000); + const libraryBody = page.locator('body'); + if (valid) { + await expect(libraryBody).toContainText(/THE IMPORTED BOOK|The Imported Book/i, { timeout: 5000 }); + await expect(libraryBody).toContainText(/SECOND IMPORTED|Second Imported/i, { timeout: 5000 }); + } + }); + + test('9.3 transform syntax error shown in preview', async ({ page }) => { + await page.goto('/data?tab=import'); + await page.waitForTimeout(1000); + + await page.locator('input[type="file"]').setInputFiles({ + name: 'test-books.csv', + mimeType: 'text/csv', + buffer: Buffer.from(CSV), + }); + + await page.locator('button').filter({ hasText: 'Parse file' }).click(); + await page.waitForTimeout(2000); + + await expect(page.locator('select[name="mapping-target-title"]')).toBeVisible({ timeout: 10000 }); + + await page.locator('select[name="mapping-target-title"]').selectOption('title'); + await page.locator('select[name="mapping-target-author"]').selectOption('author'); + await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); + await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); + await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); + + await page.locator('textarea[name="mapping-transform-title"]').fill('value.upper('); + + await page.locator('button').filter({ hasText: 'Generate' }).click(); + await page.waitForTimeout(2000); + + const body = page.locator('body'); + await expect(body).toContainText(/error|invalid|syntax/i, { timeout: 10000 }); + }); +}); diff --git a/frontend/e2e/specs/10-data-export.spec.ts b/frontend/e2e/specs/10-data-export.spec.ts new file mode 100644 index 0000000..d932924 --- /dev/null +++ b/frontend/e2e/specs/10-data-export.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { SEED_USER } from '../fixtures/seed-data'; + +test.describe('Data Export', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + }); + + test('10.1 export page loads', async ({ page }) => { + await page.goto('/data'); + await page.waitForTimeout(1000); + const body = page.locator('body'); + await expect(body).toContainText(/Export/i); + }); + + test('10.2 export downloads a file', async ({ page }) => { + await page.goto('/data'); + await page.waitForTimeout(1000); + + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/data/export') && resp.status() === 200 + ); + + await page.locator('button').filter({ hasText: 'Export data' }).click(); + + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + + const contentType = response.headers()['content-type'] || ''; + expect(contentType).toContain('zip'); + }); + + test('10.3 export with CSV format', async ({ page }) => { + await page.goto('/data'); + await page.waitForTimeout(1000); + + await page.locator('input[type="radio"][value="csv"]').click(); + + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/data/export') && resp.status() === 200 + ); + + await page.locator('button').filter({ hasText: 'Export data' }).click(); + + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + }); +}); diff --git a/frontend/e2e/specs/11-profile.spec.ts b/frontend/e2e/specs/11-profile.spec.ts new file mode 100644 index 0000000..bbbcbae --- /dev/null +++ b/frontend/e2e/specs/11-profile.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { SEED_USER } from '../fixtures/seed-data'; + +test.describe('Profile', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + }); + + test('11.1 view profile', async ({ page }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + const body = page.locator('body'); + await expect(body).toContainText(/profile|e2e|tester/i); + }); + + test('11.2 change language to German', async ({ page }) => { + await page.goto('/profile'); + await page.waitForTimeout(1500); + + await page.locator('select[name="language"]').selectOption('de'); + + await page.locator('#section-language button[class*="btn-primary"]').click(); + await page.waitForTimeout(1000); + + const alert = page.locator('#section-language .alert'); + await expect(alert).toBeVisible({ timeout: 5000 }); + }); + + test('11.3 change theme to dracula', async ({ page }) => { + await page.goto('/profile'); + await page.waitForTimeout(1500); + + await page.locator('select[name="custom-theme"]').selectOption('dracula'); + + await page.locator('#section-theme button[class*="btn-primary"]').click(); + await page.waitForTimeout(1000); + + const saved = page.locator('#section-theme').getByText(/saved|gespeichert/i); + await expect(saved).toBeVisible({ timeout: 5000 }); + + const theme = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); + expect(theme).toBe('dracula'); + }); + + test('11.4 create api key', async ({ page }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + const addKeyBtn = page.locator('button').filter({ hasText: /add key/i }).first(); + if (await addKeyBtn.isVisible()) { + await addKeyBtn.click(); + await page.waitForTimeout(1000); + await expect(page.locator('body')).toContainText(/key|api/i); + } + }); +}); diff --git a/frontend/e2e/specs/12-admin.spec.ts b/frontend/e2e/specs/12-admin.spec.ts new file mode 100644 index 0000000..ac52973 --- /dev/null +++ b/frontend/e2e/specs/12-admin.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { SEED_USER } from '../fixtures/seed-data'; + +test.describe('Admin', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + }); + + test('12.1 admin page loads user list', async ({ page }) => { + await page.goto('/admin'); + await page.waitForTimeout(2000); + const body = page.locator('body'); + await expect(body).toContainText(/user|admin|e2e/i); + }); +}); diff --git a/frontend/e2e/specs/13-logout.spec.ts b/frontend/e2e/specs/13-logout.spec.ts new file mode 100644 index 0000000..2771c33 --- /dev/null +++ b/frontend/e2e/specs/13-logout.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { SEED_USER } from '../fixtures/seed-data'; + +test.describe('Logout', () => { + test('13.1 logout from usermenu', async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + const menuButton = page.locator('button.btn-circle').first(); + await menuButton.click(); + await page.waitForTimeout(300); + const logoutButton = page.getByRole('button', { name: /logout|abmelden/i }); + await logoutButton.click(); + await page.waitForURL(/\/login/); + }); + + test('13.2 cannot access protected pages after logout', async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + await page.context().clearCookies(); + await page.goto('/library'); + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json new file mode 100644 index 0000000..f3e630c --- /dev/null +++ b/frontend/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["@playwright/test"], + "noEmit": true, + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler" + }, + "include": ["**/*.ts"] +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf index a193a69..fa87c98 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -12,12 +12,21 @@ server { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; proxy_set_header X-Forwarded-Host $http_host; } + # Immutable assets (content-hashed by Vite) - cache forever + location /_app/immutable/ { + expires 365d; + add_header Cache-Control "public, immutable"; + } + # SPA fallback: all non-file routes served by 200.html + # HTML files must not be cached so users always get fresh version meta tags location / { try_files $uri $uri/ /200.html; + expires -1d; + add_header Cache-Control "no-store"; } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 746c087..5b11190 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,24 +8,33 @@ "name": "frontend", "version": "0.0.1", "dependencies": { + "@fontsource/inter": "^5.2.8", + "@lucide/svelte": "^1.16.0", "@tailwindcss/vite": "^4.3.0", + "animal-avatar-generator": "^1.2.0", + "chart.js": "^4.5.1", + "chartjs-chart-matrix": "^3.0.4", + "chartjs-plugin-zoom": "^2.2.0", "daisyui": "^5.5.19", "dayjs": "^1.11.20", + "hammerjs": "^2.0.8", "html5-qrcode": "^2.3.8", - "layerchart": "^2.0.0-next.64", + "svelte-chartjs": "^4.0.1", "svelte-i18n": "^4.0.1", "tailwindcss": "^4.3.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", + "@types/hammerjs": "^2.0.46", "@types/node": "^25.7.0", "@vitest/coverage-v8": "^4.1.7", - "happy-dom": "^17.6.3", + "happy-dom": "^20.9.0", "svelte": "^5.55.2", "svelte-check": "^4.4.6", "typescript": "^6.0.2", @@ -125,21 +134,6 @@ "node": ">=18" } }, - "node_modules/@dagrejs/dagre": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz", - "integrity": "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==", - "license": "MIT", - "dependencies": { - "@dagrejs/graphlib": "3.0.4" - } - }, - "node_modules/@dagrejs/graphlib": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-3.0.4.tgz", - "integrity": "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==", - "license": "MIT" - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -171,31 +165,15 @@ "tslib": "^2.4.0" } }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" - }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", @@ -292,47 +270,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@layerstack/svelte-actions": { - "version": "1.0.1-next.18", - "resolved": "https://registry.npmjs.org/@layerstack/svelte-actions/-/svelte-actions-1.0.1-next.18.tgz", - "integrity": "sha512-gxPzCnJ1c9LTfWtRqLUzefCx+k59ZpxDUQ2XB+LokveZQPe7IDSOwHaBOEMlaGoGrtwc3Ft8dSZq+2WT2o9u/g==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.0", - "@layerstack/utils": "2.0.0-next.18", - "d3-scale": "^4.0.2" - } - }, - "node_modules/@layerstack/svelte-state": { - "version": "0.1.0-next.23", - "resolved": "https://registry.npmjs.org/@layerstack/svelte-state/-/svelte-state-0.1.0-next.23.tgz", - "integrity": "sha512-7O4umv+gXwFfs3/vjzFWYHNXGwYnnjBapWJ5Y+9u99F4eVk6rh4ocNwqkqQNkpMZ5tUJBlRTWjPE1So6+hEzIg==", - "license": "MIT", - "dependencies": { - "@layerstack/utils": "2.0.0-next.18" - } - }, - "node_modules/@layerstack/tailwind": { - "version": "2.0.0-next.21", - "resolved": "https://registry.npmjs.org/@layerstack/tailwind/-/tailwind-2.0.0-next.21.tgz", - "integrity": "sha512-Qgp2EpmEHmjtura8MQzWicR6ztBRSsRvddakFtx9ShrLMz6jWzd6bCMVVRu44Q3ZOrtXmSu4QxjCZWu1ytvuPg==", - "license": "MIT", - "dependencies": { - "@layerstack/utils": "^2.0.0-next.18", - "clsx": "^2.1.1", - "d3-array": "^3.2.4", - "tailwind-merge": "^3.2.0" - } + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" }, - "node_modules/@layerstack/utils": { - "version": "2.0.0-next.18", - "resolved": "https://registry.npmjs.org/@layerstack/utils/-/utils-2.0.0-next.18.tgz", - "integrity": "sha512-EYILHpfBRYMMEahajInu9C2AXQom5IcAEdtCeucD3QIl/fdDgRbtzn6/8QW9ewumfyNZetdUvitOksmI1+gZYQ==", - "license": "MIT", - "dependencies": { - "d3-array": "^3.2.4", - "d3-time": "^3.1.0", - "d3-time-format": "^4.1.0" + "node_modules/@lucide/svelte": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.16.0.tgz", + "integrity": "sha512-AvvPJnaWxeiNkAljI5MsSEc84yHPLMaWQIAJOcbX7k9au/f9ITS7cxTTQiautDiOFKVOXiYdZ+d6mtl88J+Kbg==", + "license": "ISC", + "peerDependencies": { + "svelte": "^5" } }, "node_modules/@napi-rs/wasm-runtime": { @@ -362,11 +312,27 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "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==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { @@ -639,7 +605,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { @@ -675,7 +641,7 @@ "version": "2.59.1", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz", "integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -717,7 +683,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "deepmerge": "^4.3.1", @@ -1131,25 +1097,9 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, "license": "MIT" }, - "node_modules/@types/d3-contour": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", - "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", - "license": "MIT", - "dependencies": { - "@types/d3-array": "*", - "@types/geojson": "*" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1163,10 +1113,10 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", "license": "MIT" }, "node_modules/@types/node": { @@ -1185,6 +1135,23 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", @@ -1351,6 +1318,12 @@ "node": ">=0.4.0" } }, + "node_modules/animal-avatar-generator": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/animal-avatar-generator/-/animal-avatar-generator-1.2.0.tgz", + "integrity": "sha512-C9fExOFmO5jz2KcaDLSn8IhyRayy63M7E/hf3pTIe2GvbVWFqTIZFMSTH41VGRJD+zNWQLmQwcDaA42slEz3lA==", + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1441,6 +1414,40 @@ "node": ">=18" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-chart-matrix": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/chartjs-chart-matrix/-/chartjs-chart-matrix-3.0.4.tgz", + "integrity": "sha512-thkswkjZEtmZph+JUU65GjSxfAIKkLedVAhKz6umIs8zO+y+gHIuzovEtS1FqRXzubMXCX2RcglbQjHsL8g0Xw==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, + "node_modules/chartjs-plugin-zoom": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", + "integrity": "sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.45", + "hammerjs": "^2.0.8" + }, + "peerDependencies": { + "chart.js": ">=3.2.0" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1482,15 +1489,6 @@ "node": ">=6" } }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1502,7 +1500,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1528,334 +1526,6 @@ "node": ">=0.12" } }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-chord": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", - "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", - "license": "ISC", - "dependencies": { - "d3-path": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-contour": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", - "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", - "license": "ISC", - "dependencies": { - "d3-array": "^3.2.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "license": "ISC", - "dependencies": { - "delaunator": "5" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dsv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", - "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", - "license": "ISC", - "dependencies": { - "commander": "7", - "iconv-lite": "0.6", - "rw": "1" - }, - "bin": { - "csv2json": "bin/dsv2json.js", - "csv2tsv": "bin/dsv2dsv.js", - "dsv2dsv": "bin/dsv2dsv.js", - "dsv2json": "bin/dsv2json.js", - "json2csv": "bin/json2dsv.js", - "json2dsv": "bin/json2dsv.js", - "json2tsv": "bin/json2dsv.js", - "tsv2csv": "bin/dsv2dsv.js", - "tsv2json": "bin/dsv2json.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-force": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", - "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-geo": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", - "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2.5.0 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-geo-voronoi": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/d3-geo-voronoi/-/d3-geo-voronoi-2.1.0.tgz", - "integrity": "sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "3", - "d3-delaunay": "6", - "d3-geo": "3", - "d3-tricontour": "1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-hierarchy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", - "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate-path": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/d3-interpolate-path/-/d3-interpolate-path-2.3.0.tgz", - "integrity": "sha512-tZYtGXxBmbgHsIc9Wms6LS5u4w6KbP8C09a4/ZYc4KLMYYqub57rRBUgpUr2CIarIrJEpdAWWxWQvofgaMpbKQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-quadtree": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", - "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-random": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", - "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-sankey": { - "version": "0.12.3", - "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", - "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1 - 2", - "d3-shape": "^1.2.0" - } - }, - "node_modules/d3-sankey/node_modules/d3-array": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", - "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "license": "BSD-3-Clause", - "dependencies": { - "internmap": "^1.0.0" - } - }, - "node_modules/d3-sankey/node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-sankey/node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "1" - } - }, - "node_modules/d3-sankey/node_modules/internmap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", - "license": "ISC" - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-interpolate": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-tile": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/d3-tile/-/d3-tile-1.0.0.tgz", - "integrity": "sha512-79fnTKpPMPDS5xQ0xuS9ir0165NEwwkFpe/DSOmc2Gl9ldYzKKRDWogmTTE8wAJ8NA7PMapNfEcyKhI9Lxdu5Q==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-tricontour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/d3-tricontour/-/d3-tricontour-1.1.0.tgz", - "integrity": "sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==", - "license": "ISC", - "dependencies": { - "d3-delaunay": "6", - "d3-scale": "4" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/daisyui": { "version": "5.5.19", "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", @@ -1886,19 +1556,11 @@ "node": ">=0.10.0" } }, - "node_modules/delaunator": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", - "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1939,6 +1601,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -2120,15 +1795,28 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/happy-dom": { - "version": "17.6.3", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.6.3.tgz", - "integrity": "sha512-UVIHeVhxmxedbWPCfgS55Jg2rDfwf2BCKeylcPSqazLz5w3Kri7Q4xdBJubsr/+VUzFLh0VjIvh13RaDA2/Xug==", + "version": "20.9.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz", + "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", "dependencies": { - "webidl-conversions": "^7.0.0", - "whatwg-mimetype": "^3.0.0" + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" }, "engines": { "node": ">=20.0.0" @@ -2157,18 +1845,6 @@ "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", "license": "Apache-2.0" }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2179,15 +1855,6 @@ "node": ">=8" } }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/intl-messageformat": { "version": "10.7.18", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", @@ -2274,52 +1941,12 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/layerchart": { - "version": "2.0.0-next.64", - "resolved": "https://registry.npmjs.org/layerchart/-/layerchart-2.0.0-next.64.tgz", - "integrity": "sha512-vkD8CBPQOFslC8AGgHvTc1lXOJYuj+Fzimjle7Gxu/GUXQ83m68G+H3zhMTB61Q8aXEu4ytz4vVuwuOB+5JD+Q==", - "license": "MIT", - "dependencies": { - "@dagrejs/dagre": "^2.0.4", - "@layerstack/svelte-actions": "1.0.1-next.18", - "@layerstack/svelte-state": "0.1.0-next.23", - "@layerstack/tailwind": "2.0.0-next.21", - "@layerstack/utils": "2.0.0-next.18", - "@types/d3-contour": "^3.0.6", - "d3-array": "^3.2.4", - "d3-chord": "^3.0.1", - "d3-color": "^3.1.0", - "d3-contour": "^4.0.2", - "d3-delaunay": "^6.0.4", - "d3-dsv": "^3.0.1", - "d3-force": "^3.0.0", - "d3-geo": "^3.1.1", - "d3-geo-voronoi": "^2.1.0", - "d3-hierarchy": "^3.1.2", - "d3-interpolate": "^3.0.1", - "d3-interpolate-path": "^2.3.0", - "d3-path": "^3.1.0", - "d3-quadtree": "^3.0.1", - "d3-random": "^3.0.1", - "d3-sankey": "^0.12.3", - "d3-scale": "^4.0.2", - "d3-scale-chromatic": "^3.1.0", - "d3-shape": "^3.2.0", - "d3-tile": "^1.0.0", - "d3-time": "^3.1.0", - "memoize": "^10.2.0", - "runed": "^0.37.1" - }, - "peerDependencies": { - "svelte": "^5.0.0" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2600,6 +2227,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, "license": "MIT", "bin": { "lz-string": "bin/bin.js" @@ -2642,21 +2270,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/memoize": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/memoize/-/memoize-10.2.0.tgz", - "integrity": "sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==", - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/memoize?sponsor=1" - } - }, "node_modules/memoizee": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", @@ -2676,18 +2289,6 @@ "node": ">=0.12" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -2711,7 +2312,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2745,7 +2346,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "devOptional": true, + "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -2777,6 +2378,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -2855,12 +2503,6 @@ "node": ">=8" } }, - "node_modules/robust-predicates": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", - "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", - "license": "Unlicense" - }, "node_modules/rolldown": { "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", @@ -2894,40 +2536,6 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" } }, - "node_modules/runed": { - "version": "0.37.1", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.37.1.tgz", - "integrity": "sha512-MeFY73xBW8IueWBm012nNFIGy19WUGPLtknavyUPMpnyt350M47PhGSGrGoSLbidwn+Zlt/O0cp8/OZE3LASWA==", - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3", - "esm-env": "^1.0.0", - "lz-string": "^1.5.0" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.21.0", - "svelte": "^5.7.0", - "zod": "^4.1.0" - }, - "peerDependenciesMeta": { - "@sveltejs/kit": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" - }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -2940,12 +2548,6 @@ "node": ">=6" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", @@ -2963,7 +2565,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/siginfo": { @@ -2977,7 +2579,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -3064,6 +2666,16 @@ "node": ">=18" } }, + "node_modules/svelte-chartjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svelte-chartjs/-/svelte-chartjs-4.0.1.tgz", + "integrity": "sha512-4z+0J+w/6ADH2Cy+/AnVek2HxRrznQ7dJfWTybc9BHm9//DCb1BmLrSE3NGDRDLj+kwJbKw2o1tPLBE3CmdHmw==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^3.5.0 || ^4.0.0", + "svelte": "^5.0.0" + } + }, "node_modules/svelte-check": { "version": "4.4.8", "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz", @@ -3518,16 +3130,6 @@ "@esbuild/win32-x64": "0.19.12" } }, - "node_modules/tailwind-merge": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", - "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -3617,7 +3219,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3639,7 +3241,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3737,7 +3339,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "devOptional": true, + "dev": true, "license": "MIT", "workspaces": [ "tests/deps/*", @@ -3843,16 +3445,6 @@ } } }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -3880,6 +3472,28 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8c42c2d..dbf609d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,18 +12,21 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "test:e2e": "docker run --rm -v $(pwd)/../data-e2e:/data alpine sh -c 'rm -rf /data/*' 2>/dev/null; mkdir -p playwright-report test-results && docker compose -f ../docker-compose.e2e.yml up --build --abort-on-container-exit --attach test-runner --no-log-prefix" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", + "@types/hammerjs": "^2.0.46", "@types/node": "^25.7.0", "@vitest/coverage-v8": "^4.1.7", - "happy-dom": "^17.6.3", + "happy-dom": "^20.9.0", "svelte": "^5.55.2", "svelte-check": "^4.4.6", "typescript": "^6.0.2", @@ -31,11 +34,18 @@ "vitest": "^4.1.6" }, "dependencies": { + "@fontsource/inter": "^5.2.8", + "@lucide/svelte": "^1.16.0", "@tailwindcss/vite": "^4.3.0", + "animal-avatar-generator": "^1.2.0", + "chart.js": "^4.5.1", + "chartjs-chart-matrix": "^3.0.4", + "chartjs-plugin-zoom": "^2.2.0", "daisyui": "^5.5.19", "dayjs": "^1.11.20", + "hammerjs": "^2.0.8", "html5-qrcode": "^2.3.8", - "layerchart": "^2.0.0-next.64", + "svelte-chartjs": "^4.0.1", "svelte-i18n": "^4.0.1", "tailwindcss": "^4.3.0" } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..850bae2 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e/specs', + fullyParallel: false, + retries: 1, + workers: 1, + globalSetup: './e2e/config/global-setup.ts', + globalTeardown: './e2e/config/global-teardown.ts', + reporter: [ + ['html', { outputFolder: 'playwright-report' }], + ['junit', { outputFile: 'playwright-report/report.xml' }], + ['list'], + ], + use: { + baseURL: process.env.E2E_BASE_URL || 'http://frontend:80', + headless: true, + viewport: { width: 1280, height: 800 }, + ignoreHTTPSErrors: true, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + timeout: 60_000, + expect: { + timeout: 15_000, + }, +}); diff --git a/frontend/src/app.css b/frontend/src/app.css index b521be6..fd4fd9c 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,6 +1,65 @@ @import "tailwindcss"; -@plugin "daisyui"; -@import "layerchart/daisyui-5.css"; +@plugin "daisyui" { + themes: all; +} + +@theme { + --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; +} + +@plugin "daisyui/theme" { + name: "librislog"; + default: false; + prefersdark: false; + color-scheme: light; + + /* Surfaces: near-white, extremely low chroma warm-neutral */ + --color-base-100: oklch(98.5% 0.003 80); + --color-base-200: oklch(96% 0.005 80); + --color-base-300: oklch(92% 0.008 80); + --color-base-content: oklch(28% 0.02 80); + + /* Primary: calm, desaturated slate-blue for reading context */ + --color-primary: oklch(54% 0.12 220); + --color-primary-content: oklch(98% 0.01 220); + + /* Secondary: muted steel */ + --color-secondary: oklch(58% 0.1 260); + --color-secondary-content: oklch(98% 0.01 260); + + /* Accent: subtle sage for occasional emphasis */ + --color-accent: oklch(62% 0.1 160); + --color-accent-content: oklch(98% 0.01 160); + + /* Neutral: soft gray for UI chrome */ + --color-neutral: oklch(50% 0.02 80); + --color-neutral-content: oklch(98% 0.01 80); + + /* Functional colors: desaturated but distinguishable */ + --color-info: oklch(65% 0.14 230); + --color-info-content: oklch(98% 0.01 230); + + --color-success: oklch(60% 0.14 145); + --color-success-content: oklch(98% 0.01 145); + + --color-warning: oklch(75% 0.14 85); + --color-warning-content: oklch(25% 0.02 85); + + --color-error: oklch(58% 0.18 25); + --color-error-content: oklch(98% 0.01 25); + + /* Radius: generous, premium feel */ + --radius-selector: 0.5rem; + --radius-field: 0.75rem; + --radius-box: 1rem; + + /* Sizing & effects: minimal, flat, clean */ + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 0; + --noise: 0; +} html { scroll-behavior: smooth; diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index a90e63b..1261fd3 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { api } from './api'; import { apiKey, csrfToken } from './stores/auth'; @@ -36,3 +36,80 @@ describe('api.covers.upload', () => { expect(init.body).toBeInstanceOf(FormData); }); }); + +describe('api.books.list', () => { + beforeEach(() => { + apiKey.set(null); + csrfToken.set(null); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns { books, total } shape', async () => { + const mockResponse = { + books: [ + { id: 1, title: 'Dune', author: 'Frank Herbert', reading_status: 'read' as const, date_added: '2024-01-01T00:00:00Z' }, + ], + total: 42, + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => mockResponse, + } as Response); + + const result = await api.books.list(); + + expect(result).toHaveProperty('books'); + expect(result).toHaveProperty('total'); + expect(Array.isArray(result.books)).toBe(true); + expect(result.total).toBe(42); + expect(result.books).toHaveLength(1); + expect(result.books[0].title).toBe('Dune'); + }); + + it('returns empty books array and zero total when no books', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ books: [], total: 0 }), + } as Response); + + const result = await api.books.list(); + + expect(result.books).toEqual([]); + expect(result.total).toBe(0); + }); + + it('passes query parameters correctly', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ books: [], total: 0 }), + } as Response); + + await api.books.list({ status: 'read', q: 'dune', sort: 'title', order: 'asc' }); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toContain('status=read'); + expect(url).toContain('q=dune'); + expect(url).toContain('sort=title'); + expect(url).toContain('order=asc'); + }); + + it('omits empty search query from URL', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ books: [], total: 0 }), + } as Response); + + await api.books.list({ q: '' }); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).not.toContain('q='); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6a7423a..4973fdb 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,13 +1,20 @@ import type { ApiKeyMeta, + BookListResponse, DataExportDataset, DataExportFormat, DataImportEvent, DataImportMappingListItem, DataImportMappingRead, DataImportParseResponse, + DataImportPreviewResponse, DataImportValidateResponse, DataResetResponse, + HygieneAttribute, + HygieneBatchUpdateRequest, + HygieneBatchUpdateResponse, + HygieneMissingResponse, + ImportFieldConfig, Book, CoverCandidateList, BookImportCandidate, @@ -136,7 +143,7 @@ export const api = { return request('/profile/settings'); }, - updateSettings(data: { language?: string; timezone?: string }): Promise { + updateSettings(data: { language?: string; timezone?: string; theme?: string; custom_theme?: string | null }): Promise { return request('/profile/settings', { method: 'PATCH', body: JSON.stringify(data) @@ -277,7 +284,7 @@ export const api = { smart_sort?: boolean; offset?: number; limit?: number; - }): Promise { + }): Promise { const qs = new URLSearchParams(); if (params?.status) qs.set('status', params.status); if (params?.q) qs.set('q', params.q); @@ -287,7 +294,7 @@ export const api = { if (params?.offset !== undefined) qs.set('offset', String(params.offset)); if (params?.limit !== undefined) qs.set('limit', String(params.limit)); const query = qs.toString() ? `?${qs}` : ''; - return request(`/books${query}`); + return request(`/books${query}`); }, get(id: number): Promise { @@ -312,6 +319,12 @@ export const api = { body: JSON.stringify({ page }) }); }, + async update(bookId: number, entryId: number, data: { created_at: string }): Promise { + return request(`/books/${bookId}/progress/${entryId}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); + }, async delete(bookId: number, entryId: number): Promise { return request(`/books/${bookId}/progress/${entryId}`, { method: 'DELETE' }); }, @@ -333,6 +346,29 @@ export const api = { } }, + hygiene: { + async listMissing(params: { + attributes: HygieneAttribute[]; + match?: 'any' | 'all'; + offset?: number; + limit?: number; + }): Promise { + const qs = new URLSearchParams(); + qs.set('attributes', params.attributes.join(',')); + if (params.match) qs.set('match', params.match); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + return request(`/hygiene/missing?${qs.toString()}`); + }, + + async batchUpdate(data: HygieneBatchUpdateRequest): Promise { + return request('/hygiene/batch-update', { + method: 'POST', + body: JSON.stringify(data) + }); + } + }, + covers: { async upload(file: File): Promise { const headers: Record = { ...authHeaders() }; @@ -457,54 +493,64 @@ export const api = { return res.json() as Promise; }, - suggestMapping(fileId: string): Promise<{ suggested_mapping: Record; db_fields: string[] }> { - return request<{ suggested_mapping: Record; db_fields: string[] }>('/data/import/suggest-mapping', { - method: 'POST', - body: JSON.stringify({ file_id: fileId }) - }); - }, + suggestMapping(fileId: string): Promise<{ suggested_mapping: Record; db_fields: string[] }> { + return request<{ suggested_mapping: Record; db_fields: string[] }>('/data/import/suggest-mapping', { + method: 'POST', + body: JSON.stringify({ file_id: fileId }) + }); + }, - saveMapping(payload: { - name: string; - source_fields: string[]; - mapping: Record; - }): Promise { - return request('/data/import/mappings', { - method: 'POST', - body: JSON.stringify(payload) - }); - }, + saveMapping(payload: { + name: string; + source_fields: string[]; + mapping: Record; + }): Promise { + return request('/data/import/mappings', { + method: 'POST', + body: JSON.stringify(payload) + }); + }, - listMappings(): Promise { - return request('/data/import/mappings'); - }, + listMappings(): Promise { + return request('/data/import/mappings'); + }, - getMapping(id: number): Promise { - return request(`/data/import/mappings/${id}`); - }, + getMapping(id: number): Promise { + return request(`/data/import/mappings/${id}`); + }, - deleteMapping(id: number): Promise { - return request(`/data/import/mappings/${id}`, { method: 'DELETE' }); - }, + deleteMapping(id: number): Promise { + return request(`/data/import/mappings/${id}`, { method: 'DELETE' }); + }, - validateImport(payload: { - file_id: string; - mapping: Record; - create_progress_for_read?: boolean; - }): Promise { - return request('/data/import/validate', { - method: 'POST', - body: JSON.stringify(payload) - }); - }, + previewImport(payload: { + file_id: string; + mapping: Record; + }): Promise { + return request('/data/import/preview', { + method: 'POST', + body: JSON.stringify(payload) + }); + }, + + validateImport(payload: { + file_id: string; + mapping: Record; + create_progress_for_read?: boolean; + }): Promise { + return request('/data/import/validate', { + method: 'POST', + body: JSON.stringify(payload) + }); + }, - async *executeImport(payload: { - file_id: string; - mapping: Record; - import_mode: 'rollback_all' | 'continue_on_error'; - create_progress_for_read?: boolean; - signal?: AbortSignal; - }): AsyncGenerator { + async *executeImport(payload: { + file_id: string; + mapping: Record; + import_mode: 'rollback_all' | 'continue_on_error'; + create_progress_for_read?: boolean; + signal?: AbortSignal; + }): AsyncGenerator { const headers: Record = { 'Content-Type': 'application/json', ...authHeaders() diff --git a/frontend/src/lib/chartjs/register.ts b/frontend/src/lib/chartjs/register.ts new file mode 100644 index 0000000..eaf87a6 --- /dev/null +++ b/frontend/src/lib/chartjs/register.ts @@ -0,0 +1,29 @@ +import { + Chart as ChartJS, + Title, + Tooltip, + Legend, + BarElement, + LineElement, + PointElement, + CategoryScale, + LinearScale +} from 'chart.js'; +import zoomPlugin from 'chartjs-plugin-zoom'; +import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; + +ChartJS.register( + Title, + Tooltip, + Legend, + BarElement, + LineElement, + PointElement, + CategoryScale, + LinearScale, + zoomPlugin, + MatrixController, + MatrixElement +); + +export { ChartJS }; diff --git a/frontend/src/lib/chartjs/theme.ts b/frontend/src/lib/chartjs/theme.ts new file mode 100644 index 0000000..afb0780 --- /dev/null +++ b/frontend/src/lib/chartjs/theme.ts @@ -0,0 +1,138 @@ +const colorCache = new Map(); + +const VAR_MAP: Record = { + primary: '--color-primary', + secondary: '--color-secondary', + accent: '--color-accent', + info: '--color-info', + success: '--color-success', + warning: '--color-warning', + error: '--color-error', + 'base-100': '--color-base-100', + 'base-200': '--color-base-200', + 'base-300': '--color-base-300', + 'base-content': '--color-base-content', +}; + +export function getCssColor(varName: string, fallback = '#999'): string { + if (typeof window === 'undefined') return fallback; + const cached = colorCache.get(varName); + if (cached) return cached; + + const el = document.createElement('div'); + el.style.color = `var(${varName})`; + el.style.position = 'absolute'; + el.style.visibility = 'hidden'; + el.style.pointerEvents = 'none'; + document.body.appendChild(el); + try { + const computed = getComputedStyle(el).color; + const result = computed || fallback; + colorCache.set(varName, result); + return result; + } finally { + document.body.removeChild(el); + } +} + +export function invalidateColorCache(): void { + colorCache.clear(); +} + +export function resolveDaisyColor(name: string): string { + const varName = VAR_MAP[name]; + if (!varName) { + console.warn(`resolveDaisyColor: unknown color "${name}", falling back to primary`); + return 'var(--color-primary)'; + } + return `var(${varName})`; +} + +export function getDaisyColorRgb(name: string): string { + const varName = VAR_MAP[name]; + if (!varName) { + return getCssColor('--color-primary', 'rgb(0, 0, 0)'); + } + return getCssColor(varName, 'rgb(0, 0, 0)'); +} + +function oklchToRgb(l: number, c: number, h: number): [number, number, number] { + const hr = (h * Math.PI) / 180; + const a = c * Math.cos(hr); + const b = c * Math.sin(hr); + + const lm = l + 0.3963377774 * a + 0.2158037573 * b; + const mm = l - 0.1055613458 * a - 0.0638541728 * b; + const sm = l - 0.0894841775 * a - 1.291485548 * b; + + const l3 = lm * lm * lm; + const m3 = mm * mm * mm; + const s3 = sm * sm * sm; + + let r = 4.0767416621 * l3 - 3.3077115391 * m3 + 0.2309699203 * s3; + let g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3; + let bl = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3; + + const toSrgb = (v: number): number => { + v = Math.max(0, Math.min(1, v)); + return v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; + }; + + return [ + Math.round(toSrgb(r) * 255), + Math.round(toSrgb(g) * 255), + Math.round(toSrgb(bl) * 255), + ]; +} + +function parseColor(c: string): [number, number, number] { + if (c.startsWith('#')) { + const hex = c.replace('#', ''); + if (hex.length === 3) { + return [ + parseInt(hex[0] + hex[0], 16), + parseInt(hex[1] + hex[1], 16), + parseInt(hex[2] + hex[2], 16), + ]; + } + return [ + parseInt(hex.slice(0, 2), 16), + parseInt(hex.slice(2, 4), 16), + parseInt(hex.slice(4, 6), 16), + ]; + } + + const oklch = c.match(/oklch\(\s*([\d.]+)%?\s+([\d.]+)\s+([\d.]+)/); + if (oklch) { + const l = parseFloat(oklch[1]); + const c = parseFloat(oklch[2]); + const h = parseFloat(oklch[3]); + return oklchToRgb(l, c, h); + } + + const rgb = c.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (rgb) return [+rgb[1], +rgb[2], +rgb[3]]; + + return [0, 0, 0]; +} + +export function mixDaisyColors( + fromVar: string, + toVar: string, + t: number +): string { + if (typeof window === 'undefined') return 'transparent'; + t = Math.max(0, Math.min(1, t)); + + const from = getCssColor(fromVar, '#e5e7eb'); + const to = getCssColor(toVar, '#3b82f6'); + + const [r1, g1, b1] = parseColor(from); + const [r2, g2, b2] = parseColor(to); + + const r = Math.round(r1 + (r2 - r1) * t); + const g = Math.round(g1 + (g2 - g1) * t); + const b = Math.round(b1 + (b2 - b1) * t); + + return `rgb(${r}, ${g}, ${b})`; +} diff --git a/frontend/src/lib/components/AddBookModal.svelte b/frontend/src/lib/components/AddBookModal.svelte index 2c0dcb2..3fd7c44 100644 --- a/frontend/src/lib/components/AddBookModal.svelte +++ b/frontend/src/lib/components/AddBookModal.svelte @@ -1,4 +1,4 @@ - + + + + diff --git a/frontend/src/lib/components/AnimalAvatar.svelte b/frontend/src/lib/components/AnimalAvatar.svelte new file mode 100644 index 0000000..ffa3e11 --- /dev/null +++ b/frontend/src/lib/components/AnimalAvatar.svelte @@ -0,0 +1,28 @@ + + +
+ {@html avatarSvg} +
diff --git a/frontend/src/lib/components/AutoSearchCoverModal.svelte b/frontend/src/lib/components/AutoSearchCoverModal.svelte index e3ac1f5..f6a67ef 100644 --- a/frontend/src/lib/components/AutoSearchCoverModal.svelte +++ b/frontend/src/lib/components/AutoSearchCoverModal.svelte @@ -1,6 +1,8 @@ - @@ -54,7 +73,7 @@ {:else} {#if error} -
{error}
+ + {error} + {/if}

{$_('book.autoSearchInfo')}

- {#if candidates.length === 0} + {#if sorted.length === 0}
{$_('book.autoSearchNoCandidates')}
{:else}
- {#each candidates.filter((candidate) => candidate.available) as candidate (candidate.source + candidate.url)} + {#each sorted as candidate (resolutionKey(candidate))} {/each} diff --git a/frontend/src/lib/components/AutoSearchCoverModal.test.ts b/frontend/src/lib/components/AutoSearchCoverModal.test.ts index 6cca347..df6e4e4 100644 --- a/frontend/src/lib/components/AutoSearchCoverModal.test.ts +++ b/frontend/src/lib/components/AutoSearchCoverModal.test.ts @@ -75,7 +75,8 @@ describe('AutoSearchCoverModal', () => { ); await fireEvent.click(imgButtons[0]); expect(onSelect).toHaveBeenCalledOnce(); - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ source: 'AbeBooks' })); + // First card is sorted by resolution descending: OpenLibrary (400x600) before AbeBooks (200x300) + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ source: 'OpenLibrary' })); }); it('calls onCancel when close button clicked', async () => { @@ -113,7 +114,7 @@ describe('AutoSearchCoverModal', () => { render(AutoSearchCoverModal, { props: { open: true, loading: false, candidates: candidateNoMeta, error: null, onCancel, onSelect } }); - expect(document.body.textContent).toContain('n/a - n/a'); + expect(document.body.textContent).toContain('n/a'); }); it('shows KB filesize label', () => { @@ -152,12 +153,12 @@ describe('AutoSearchCoverModal', () => { props: { open: true, loading: false, candidates: candidateWithLoad, error: null, onCancel, onSelect } }); const img = document.querySelector('img'); - // Simulate image load with zero natural dimensions + // Simulate image load with zero natural dimensions — resolution stays hidden const loadEvent = new Event('load'); Object.defineProperty(img!, 'naturalWidth', { value: 0 }); Object.defineProperty(img!, 'naturalHeight', { value: 0 }); await fireEvent(img!, loadEvent); - // Resolution should still show n/a since natural dimensions are 0 - expect(document.body.textContent).toContain('n/a'); + // Filesize is still shown + expect(document.body.textContent).toContain('1000 B'); }); }); diff --git a/frontend/src/lib/components/BackupRestore.svelte b/frontend/src/lib/components/BackupRestore.svelte index fab4a0f..7ef23f2 100644 --- a/frontend/src/lib/components/BackupRestore.svelte +++ b/frontend/src/lib/components/BackupRestore.svelte @@ -2,6 +2,7 @@ import { _ } from '$lib/i18n'; import { api } from '$lib/api'; import { toasts } from '$lib/toasts'; + import { localizeError } from '$lib/errors'; let backupInProgress = $state(false); let restoreFile = $state(null); @@ -29,16 +30,6 @@ } } - function localizeError(err: unknown, fallback: string): string { - if (err instanceof Error) { - if (err.message.startsWith('error.')) { - return $_(err.message); - } - return err.message; - } - return fallback; - } - async function validateAndConfirmRestore() { if (!restoreFile) return; try { @@ -50,7 +41,7 @@ toasts.add(validation.error || $_('admin.restore.invalidBackup'), 'error'); } } catch (err: unknown) { - toasts.add(localizeError(err, $_('admin.restore.validationFailed')), 'error'); + toasts.add(localizeError(err, $_, $_('admin.restore.validationFailed')), 'error'); } } @@ -63,7 +54,7 @@ toasts.add($_('admin.restore.success', { values: { books: String(result.restored_books) } }), 'success'); setTimeout(() => window.location.reload(), 2000); } catch (err: unknown) { - toasts.add(localizeError(err, $_('admin.restore.failed')), 'error'); + toasts.add(localizeError(err, $_, $_('admin.restore.failed')), 'error'); } finally { restoreInProgress = false; restoreFile = null; @@ -110,6 +101,7 @@ {:else} { diff --git a/frontend/src/lib/components/BarChart.svelte b/frontend/src/lib/components/BarChart.svelte index d8b03ff..d64861c 100644 --- a/frontend/src/lib/components/BarChart.svelte +++ b/frontend/src/lib/components/BarChart.svelte @@ -1,74 +1,200 @@ {#if data.length === 0} -
-

{emptyText}

-
+
+

{emptyText}

+
{:else} -
- -
+ {/if} diff --git a/frontend/src/lib/components/BarcodeScanner.svelte b/frontend/src/lib/components/BarcodeScanner.svelte index acac85a..a6f495e 100644 --- a/frontend/src/lib/components/BarcodeScanner.svelte +++ b/frontend/src/lib/components/BarcodeScanner.svelte @@ -1,7 +1,10 @@ - diff --git a/frontend/src/lib/components/BookDetailDialog.svelte b/frontend/src/lib/components/BookDetailDialog.svelte index 4f829f9..2197fac 100644 --- a/frontend/src/lib/components/BookDetailDialog.svelte +++ b/frontend/src/lib/components/BookDetailDialog.svelte @@ -1,4 +1,4 @@ - + + diff --git a/frontend/src/lib/components/CalendarCellRenderer.svelte b/frontend/src/lib/components/CalendarCellRenderer.svelte deleted file mode 100644 index cf10bd2..0000000 --- a/frontend/src/lib/components/CalendarCellRenderer.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - -{#each cells as cell} - { - if (cell.data?.pages !== undefined) ctx.tooltip.show(e, cell.data); - }} - onpointerleave={() => ctx.tooltip.hide()} - /> -{/each} diff --git a/frontend/src/lib/components/CalendarCellRenderer.test.ts b/frontend/src/lib/components/CalendarCellRenderer.test.ts deleted file mode 100644 index e0eadfe..0000000 --- a/frontend/src/lib/components/CalendarCellRenderer.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, fireEvent } from '@testing-library/svelte'; -import CalendarCellRenderer from './CalendarCellRenderer.svelte'; - -const mockTooltip = { show: vi.fn(), hide: vi.fn() }; - -vi.mock('layerchart', async () => { - const { default: MockRect } = await import('$lib/test/mocks/Rect.svelte'); - return { - Rect: MockRect, - getChartContext: vi.fn(() => ({ - tooltip: mockTooltip - })) - }; -}); - -describe('CalendarCellRenderer', () => { - const cells = [ - { x: 0, y: 0, data: { date: '2024-01-01', pages: 10 } }, - { x: 1, y: 0, data: { date: '2024-01-02', pages: 0 } }, - { x: 2, y: 0, data: { date: '2024-01-03' } } - ]; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('renders rects for each cell', () => { - render(CalendarCellRenderer, { - props: { cells, cellSize: [20, 20], maxPages: 20 } - }); - expect(document.querySelectorAll('[role="gridcell"]')).toHaveLength(3); - }); - - it('handles zero and undefined pages', () => { - render(CalendarCellRenderer, { - props: { cells, cellSize: [20, 20], maxPages: 20 } - }); - const rects = document.querySelectorAll('[role="gridcell"]'); - expect(rects).toHaveLength(3); - }); - - it('uses maxPages of 1', () => { - render(CalendarCellRenderer, { - props: { - cells: [{ x: 0, y: 0, data: { date: '2024-01-01', pages: 5 } }], - cellSize: [20, 20], - maxPages: 1 - } - }); - expect(document.querySelector('[role="gridcell"]')).toBeInTheDocument(); - }); - - it('shows tooltip on pointermove for cell with pages', async () => { - render(CalendarCellRenderer, { - props: { cells, cellSize: [20, 20], maxPages: 20 } - }); - const rects = document.querySelectorAll('[role="gridcell"]'); - await fireEvent.pointerMove(rects[0]); - expect(mockTooltip.show).toHaveBeenCalledOnce(); - }); - - it('does not show tooltip on pointermove for cell without pages', async () => { - render(CalendarCellRenderer, { - props: { cells, cellSize: [20, 20], maxPages: 20 } - }); - const rects = document.querySelectorAll('[role="gridcell"]'); - await fireEvent.pointerMove(rects[2]); // cell with no pages - expect(mockTooltip.show).not.toHaveBeenCalled(); - }); - - it('hides tooltip on pointerleave', async () => { - render(CalendarCellRenderer, { - props: { cells, cellSize: [20, 20], maxPages: 20 } - }); - const rects = document.querySelectorAll('[role="gridcell"]'); - await fireEvent.pointerLeave(rects[0]); - expect(mockTooltip.hide).toHaveBeenCalledOnce(); - }); -}); diff --git a/frontend/src/lib/components/CalendarHeatmap.svelte b/frontend/src/lib/components/CalendarHeatmap.svelte index 257a84d..ba47737 100644 --- a/frontend/src/lib/components/CalendarHeatmap.svelte +++ b/frontend/src/lib/components/CalendarHeatmap.svelte @@ -1,7 +1,12 @@ {#if data.length === 0} @@ -35,43 +201,7 @@

{emptyText}

{:else} -
- { - const [yr, mo, dy] = d.date.split('-').map(Number); - return new Date(yr, mo - 1, dy); - }} - c={(d: DailyPages) => d.pages} - cRange={['var(--color-base-200)', 'var(--color-primary)']} - tooltipContext={true} - height={140} - padding={{ top: 24, right: 0, bottom: 0, left: 40 }} - > - - - {#snippet children({ cells, cellSize })} - - {/snippet} - - - - - {#snippet children({ data })} - {#if data?.pages !== undefined} - - - - - {/if} - {/snippet} - - + {/if} diff --git a/frontend/src/lib/components/CoverPicker.svelte b/frontend/src/lib/components/CoverPicker.svelte index 70bcd31..695b9b3 100644 --- a/frontend/src/lib/components/CoverPicker.svelte +++ b/frontend/src/lib/components/CoverPicker.svelte @@ -136,6 +136,7 @@ (null); let parsing = $state(false); let parsed = $state(null); - let mapping = $state>({}); + let mapping = $state>({}); let dbFields = $state([]); + let preview = $state(null); + let loadingPreview = $state(false); let validating = $state(false); let validation = $state(null); let importMode = $state<'rollback_all' | 'continue_on_error'>('rollback_all'); @@ -29,7 +34,7 @@ let mappings = $state([]); let loadingMappings = $state(false); let selectedMappingId = $state(''); - let showMappingPreview = $state(false); + let selectedMappingIsPredefined = $derived(mappings.find((m) => String(m.id) === selectedMappingId)?.is_predefined ?? false); let showAllValidation = $state(false); let showAllFailures = $state(false); let importAbortController = $state(null); @@ -37,6 +42,8 @@ let fileInput: HTMLInputElement | undefined = $state(); let pendingDeleteMappingId = $state(null); let pendingImportStart = $state(false); + let previewMappingSnapshot = $state(null); + let isPreviewStale = $derived(previewMappingSnapshot !== null && previewMappingSnapshot !== JSON.stringify(mapping)); function resetFlow() { parsed = null; @@ -50,7 +57,6 @@ importResult = null; showAllValidation = false; showAllFailures = false; - showMappingPreview = false; } async function refreshMappings() { @@ -103,14 +109,17 @@ } } + let missingMappingFields = $state>([]); + async function loadMapping(id: number) { try { const saved = await api.data.getMapping(id); mapping = saved.mapping; - mappingName = saved.name; - const missing = Object.keys(saved.mapping).filter((source) => !parsed?.source_fields.includes(source)); - if (missing.length > 0) { - toasts.add($_('data.import.mappingMissingFields', { values: { count: missing.length } }), 'error'); + missingMappingFields = Object.entries(saved.mapping) + .filter(([, config]) => config.source && !parsed?.source_fields.includes(config.source)) + .map(([target, config]) => ({ target, source: config.source })); + if (!saved.is_predefined) { + mappingName = saved.name; } } catch (err: unknown) { toasts.add(err instanceof Error ? err.message : $_('data.import.errors.loadMappingFailed'), 'error'); @@ -119,13 +128,13 @@ async function loadSelectedMapping() { const id = Number(selectedMappingId); - if (!Number.isFinite(id) || id <= 0) return; + if (!Number.isFinite(id) || id === 0) return; await loadMapping(id); } function openDeleteMappingModal() { const id = Number(selectedMappingId); - if (!Number.isFinite(id) || id <= 0) return; + if (!Number.isFinite(id) || id === 0 || selectedMappingIsPredefined) return; pendingDeleteMappingId = id; } @@ -254,23 +263,35 @@ return showAllFailures ? importResult.failures : importResult.failures.slice(0, 8); }); - const mappedPreviewColumns = $derived.by(() => { - return Object.entries(mapping) - .filter(([, target]) => Boolean(target)) - .map(([source, target]) => ({ source, target })); - }); - - const mappedPreviewRows = $derived.by(() => { - if (!parsed) return []; - return parsed.sample_rows.map((sample) => { - const row: Record = {}; - for (const [source, target] of Object.entries(mapping)) { - if (!target) continue; - row[target] = sample[source] ?? null; + function formatError(err: string): string { + if (err.startsWith('\x1f')) { + const idx = err.indexOf('\x1f', 1); + if (idx !== -1) { + const field = err.slice(1, idx); + const error = err.slice(idx + 1); + return $_('data.import.transformError', { values: { field, error } }); } - return row; - }); - }); + } + return err; + } + + async function fetchPreview() { + if (!parsed) return; + loadingPreview = true; + try { + preview = await api.data.previewImport({ file_id: parsed.file_id, mapping }); + previewMappingSnapshot = JSON.stringify(mapping); + } catch (err: unknown) { + toasts.add(err instanceof Error ? err.message : $_('data.import.errors.previewFailed'), 'error'); + } finally { + loadingPreview = false; + } + } + + function resetPreview() { + preview = null; + previewMappingSnapshot = null; + }
@@ -309,6 +330,7 @@
{#if parsed} -

- {$_('data.import.fileSummary', { values: { rows: parsed.row_count, fields: parsed.source_fields.length } })} +

+ {$_('data.import.fileSummary', { values: { rows: parsed.row_count, fields: parsed.source_fields.length } })} +

{/if}
@@ -338,7 +361,7 @@
-
+ {#if missingMappingFields.length > 0} +
+
+

{$_('data.import.missingFieldsTitle')}

+
    + {#each missingMappingFields as m} +
  • {$_('data.import.missingFieldEntry', { values: { target: m.target, source: m.source } })}
  • + {/each} +
+
+ +
+ {/if} {#if mappings.length === 0}

{$_('data.import.noSavedMappings')}

{/if} -
- -
-
(mapping = next)} />
+ + - {#if showMappingPreview} -
- {#if mappedPreviewColumns.length === 0} -
{$_('data.import.previewNoMappedFields')}
- {:else} - - - - - {#each mappedPreviewColumns as column} - - {/each} - - - - {#each mappedPreviewRows as row, idx} - - - {#each mappedPreviewColumns as column} - - {/each} - +
+
+
+

{$_('data.import.previewTitle')}

+ + {#if isPreviewStale} + {$_('data.import.previewStale')} + {/if} +
+ {#if preview} + {#if (preview.errors ?? []).length > 0} +
+ {#each preview.errors ?? [] as err} +

{formatError(err)}

+ {/each} +
+ {/if} + {#each preview.preview_rows as row} +
+

{$_('data.import.previewRow', { values: { row: row.row_number } })}

+
+
+

{$_('data.import.previewSource')}

+
{@html highlightJson(JSON.stringify(row.source, null, 2))}
+
+
+

{$_('data.import.previewTransformed')}

+
{@html highlightJson(JSON.stringify(row.transformed, null, 2))}
+
+
+ {#if (row.errors ?? []).length > 0} +
+ {#each row.errors as err} +

• {formatError(err)}

{/each} -
-
#{column.target} ({column.source})
{idx + 1}{row[column.target] ?? '-'}
- {/if} -
+ + {/if} + + {/each} {/if} @@ -415,12 +463,12 @@ - + + diff --git a/frontend/src/lib/components/DataImport.test.ts b/frontend/src/lib/components/DataImport.test.ts index c4f9942..4c9e560 100644 --- a/frontend/src/lib/components/DataImport.test.ts +++ b/frontend/src/lib/components/DataImport.test.ts @@ -6,13 +6,13 @@ import DataImport from './DataImport.svelte'; const mockParseImportFile = vi.fn(async () => ({ file_id: 'test-file-123', format: 'csv' as const, - source_fields: ['title', 'author'], - sample_rows: [{ title: 'Book 1', author: 'Author 1' }], + source_fields: ['Book Title', 'Author Name', 'ISBN'], + sample_rows: [{ 'Book Title': 'Dune', 'Author Name': 'Frank Herbert', 'ISBN': '978-3-16-148410-0' }], row_count: 1 })); const mockSuggestMapping = vi.fn(async () => ({ - suggested_mapping: { title: 'title', author: 'author' }, - db_fields: ['title', 'author', 'isbn'] + suggested_mapping: { title: 'Book Title', author: 'Author Name', isbn: 'ISBN' }, + db_fields: ['title', 'author', 'isbn', 'publisher', 'page_count'] })); const mockValidateImport = vi.fn(async () => ({ valid: true, @@ -120,7 +120,7 @@ describe('DataImport', () => { await fireEvent.click(screen.getByRole('button', { name: 'Parse file' })); await waitFor(() => { - expect(screen.getByText(/Rows: 1, fields: 2/)).toBeInTheDocument(); + expect(screen.getByText(/Rows: 1, fields: 3/)).toBeInTheDocument(); }); }); diff --git a/frontend/src/lib/components/ImportMappingEditor.svelte b/frontend/src/lib/components/ImportMappingEditor.svelte index 576a5ad..f0810d2 100644 --- a/frontend/src/lib/components/ImportMappingEditor.svelte +++ b/frontend/src/lib/components/ImportMappingEditor.svelte @@ -1,4 +1,9 @@ -
- {#each sourceFields as source} -
-
{source}
-
->
- +
+ {#each dbFields as dbField} + {@const isMandatory = MANDATORY_FIELDS.includes(dbField)} + {@const config = mapping[dbField] ?? { source: '', transform: null }} + {@const currentSource = config.source ?? ''} + {@const hasTransform = Boolean(config.transform)} +
+
+
+ {dbField} + {#if isMandatory} + * + {/if} +
+
<-
+ +
+ {#if currentSource} + {#if dbField === 'cover_url'} +
+ + {$_('data.import.coverUrlHint')} +
+ {/if} +
+ + {#if transformOpen[dbField] || hasTransform} +
+
+ + +
+
+ {$_('data.import.transformHelp')} +
+

value {$_('data.import.transformHelpValue')}

+

row {$_('data.import.transformHelpRow')}

+

context {$_('data.import.transformHelpContext')}

+

{$_('data.import.transformHelpReturn')}

+

{$_('data.import.transformHelpImports')}

+
+
+
+ {/if} +
+ {/if}
{/each}
+ +
+ * {$_('data.import.requiredField')} +
+ + diff --git a/frontend/src/lib/components/ImportMappingEditor.test.ts b/frontend/src/lib/components/ImportMappingEditor.test.ts index db4de7e..6fae399 100644 --- a/frontend/src/lib/components/ImportMappingEditor.test.ts +++ b/frontend/src/lib/components/ImportMappingEditor.test.ts @@ -1,27 +1,41 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import ImportMappingEditor from './ImportMappingEditor.svelte'; describe('ImportMappingEditor', () => { const onChange = vi.fn(); const sourceFields = ['Author', 'Title', 'ISBN']; - const dbFields = ['author', 'title', 'isbn', 'publisher']; + const dbFields = ['author', 'title', 'isbn', 'publisher', 'page_count']; - it('renders all source fields', () => { + beforeEach(() => { + onChange.mockClear(); + }); + + it('renders all db target fields', () => { + render(ImportMappingEditor, { + props: { sourceFields, dbFields, mapping: {}, onChange } + }); + expect(screen.getByText('author')).toBeInTheDocument(); + expect(screen.getByText('title')).toBeInTheDocument(); + expect(screen.getByText('isbn')).toBeInTheDocument(); + expect(screen.getByText('publisher')).toBeInTheDocument(); + expect(screen.getByText('page_count')).toBeInTheDocument(); + }); + + it('marks mandatory fields with asterisk', () => { render(ImportMappingEditor, { props: { sourceFields, dbFields, mapping: {}, onChange } }); - expect(screen.getByText('Author')).toBeInTheDocument(); - expect(screen.getByText('Title')).toBeInTheDocument(); - expect(screen.getByText('ISBN')).toBeInTheDocument(); + const mandatory = screen.getAllByText('*'); + expect(mandatory.length).toBeGreaterThanOrEqual(3); // title, author, page_count (+ legend) }); - it('renders skip option for each source field', () => { + it('renders a select for each db field with none option', () => { render(ImportMappingEditor, { props: { sourceFields, dbFields, mapping: {}, onChange } }); const selects = screen.getAllByRole('combobox'); - expect(selects).toHaveLength(3); + expect(selects).toHaveLength(5); // one per db field selects.forEach((select) => { expect(select).toHaveValue(''); }); @@ -32,23 +46,31 @@ describe('ImportMappingEditor', () => { props: { sourceFields, dbFields, - mapping: { Author: 'author', Title: 'title' }, + mapping: { + author: { source: 'Author', transform: null }, + title: { source: 'Title', transform: null } + }, onChange } }); - const selects = screen.getAllByRole('combobox'); - expect(selects[0]).toHaveValue('author'); - expect(selects[1]).toHaveValue('title'); - expect(selects[2]).toHaveValue(''); + // author row should show "Author" selected + const authorSelect = screen.getByRole('combobox', { name: /Map source for author/i }); + expect(authorSelect).toHaveValue('Author'); + // title row should show "Title" selected + const titleSelect = screen.getByRole('combobox', { name: /Map source for title/i }); + expect(titleSelect).toHaveValue('Title'); + // isbn row should show empty + const isbnSelect = screen.getByRole('combobox', { name: /Map source for isbn/i }); + expect(isbnSelect).toHaveValue(''); }); - it('calls onChange when mapping is updated', async () => { + it('calls onChange when mapping is created', async () => { render(ImportMappingEditor, { props: { sourceFields, dbFields, mapping: {}, onChange } }); - const selects = screen.getAllByRole('combobox'); - await fireEvent.change(selects[0], { target: { value: 'author' } }); - expect(onChange).toHaveBeenCalledWith({ Author: 'author' }); + const authorSelect = screen.getByRole('combobox', { name: /Map source for author/i }); + await fireEvent.change(authorSelect, { target: { value: 'Author' } }); + expect(onChange).toHaveBeenCalledWith({ author: { source: 'Author', transform: null } }); }); it('calls onChange when mapping is cleared', async () => { @@ -56,26 +78,72 @@ describe('ImportMappingEditor', () => { props: { sourceFields, dbFields, - mapping: { Author: 'author' }, + mapping: { author: { source: 'Author', transform: null } }, onChange } }); - const selects = screen.getAllByRole('combobox'); - await fireEvent.change(selects[0], { target: { value: '' } }); - expect(onChange).toHaveBeenCalledWith({}); + const authorSelect = screen.getByRole('combobox', { name: /Map source for author/i }); + await fireEvent.change(authorSelect, { target: { value: '' } }); + expect(onChange).toHaveBeenCalledWith({ author: { source: '', transform: null } }); }); - it('calls onChange when mapping is changed', async () => { + it('allows same source field mapped to multiple targets', async () => { render(ImportMappingEditor, { props: { sourceFields, dbFields, - mapping: { Author: 'author' }, + mapping: { author: { source: 'Author', transform: null } }, onChange } }); - const selects = screen.getAllByRole('combobox'); - await fireEvent.change(selects[0], { target: { value: 'title' } }); - expect(onChange).toHaveBeenCalledWith({ Author: 'title' }); + const titleSelect = screen.getByRole('combobox', { name: /Map source for title/i }); + await fireEvent.change(titleSelect, { target: { value: 'Author' } }); + // Author should now map to both title and author + expect(onChange).toHaveBeenCalledWith({ + title: { source: 'Author', transform: null }, + author: { source: 'Author', transform: null } + }); + }); + + it('shows transform textarea after clicking expand', async () => { + render(ImportMappingEditor, { + props: { + sourceFields, + dbFields, + mapping: { author: { source: 'Author', transform: null } }, + onChange + } + }); + await fireEvent.click(screen.getByText('Transform (Python)')); + expect(screen.getByLabelText(/Transform for author/i)).toBeInTheDocument(); + }); + + it('calls onChange with transform when typing in transform textarea', async () => { + render(ImportMappingEditor, { + props: { + sourceFields, + dbFields, + mapping: { author: { source: 'Author', transform: null } }, + onChange + } + }); + await fireEvent.click(screen.getByText('Transform (Python)')); + const textarea = screen.getByLabelText(/Transform for author/i); + await fireEvent.input(textarea, { target: { value: 'value.upper()' } }); + expect(onChange).toHaveBeenCalledWith({ author: { source: 'Author', transform: 'value.upper()' } }); + }); + + it('clears transform via onChange when source is cleared', async () => { + render(ImportMappingEditor, { + props: { + sourceFields, + dbFields, + mapping: { author: { source: 'Author', transform: 'value.upper()' } }, + onChange + } + }); + const authorSelect = screen.getByRole('combobox', { name: /Map source for author/i }); + await fireEvent.change(authorSelect, { target: { value: '' } }); + expect(onChange).toHaveBeenCalledWith({ author: { source: '', transform: null } }); }); }); diff --git a/frontend/src/lib/components/ImportSearch.svelte b/frontend/src/lib/components/ImportSearch.svelte index a92422e..fed2294 100644 --- a/frontend/src/lib/components/ImportSearch.svelte +++ b/frontend/src/lib/components/ImportSearch.svelte @@ -4,6 +4,7 @@ import { api } from '$lib/api'; import { _ } from '$lib/i18n'; import { toasts } from '$lib/toasts'; + import { ScanBarcode } from '@lucide/svelte'; let { onImport, @@ -35,6 +36,7 @@ onMount(async () => { cameraSupported = typeof navigator !== 'undefined' && + window.isSecureContext && !!navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function'; await refreshImportedLookups(); @@ -136,8 +138,8 @@ async function refreshImportedLookups() { try { - const books = await api.books.list(); - updateImportedLookups(books); + const response = await api.books.list(); + updateImportedLookups(response.books); } catch { // Best-effort only; search and import still works without markers. } @@ -240,44 +242,45 @@ } -
-
+
+
e.key === 'Enter' && search()} /> - - {#if cameraSupported} +
+ + +
+
+ {#if cameraSupported} +
+
+
+ {$_('import.or')} +
+
- {/if} - -
+
+ {/if} {#if stages.length > 0}
    diff --git a/frontend/src/lib/components/Logo.svelte b/frontend/src/lib/components/Logo.svelte new file mode 100644 index 0000000..baa61d6 --- /dev/null +++ b/frontend/src/lib/components/Logo.svelte @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/SearchBar.svelte b/frontend/src/lib/components/SearchBar.svelte index fa100d7..8771733 100644 --- a/frontend/src/lib/components/SearchBar.svelte +++ b/frontend/src/lib/components/SearchBar.svelte @@ -1,5 +1,6 @@ - diff --git a/frontend/src/lib/components/SuggestionInput.svelte b/frontend/src/lib/components/SuggestionInput.svelte index ec9fa4a..54356ff 100644 --- a/frontend/src/lib/components/SuggestionInput.svelte +++ b/frontend/src/lib/components/SuggestionInput.svelte @@ -3,12 +3,14 @@ value = $bindable(''), label = '', placeholder = '', + name = '', disabled = false, fetchSuggestions = async (_q: string): Promise => [] }: { value?: string; label?: string; placeholder?: string; + name?: string; disabled?: boolean; fetchSuggestions?: (query: string) => Promise; } = $props(); @@ -20,6 +22,24 @@ let isLoading = $state(false); let debounceTimer: ReturnType | undefined = $state(); let containerEl: HTMLDivElement | undefined = $state(); + let dropdownStyle = $state(''); + let inputEl: HTMLInputElement | undefined = $state(); + + $effect(() => { + inputValue = value; + }); + + $effect(() => { + if (!isOpen || !inputEl) return; + const rect = inputEl.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const dropdownHeight = Math.min(192, suggestions.length * 36 + 16); + if (spaceBelow >= dropdownHeight + 8) { + dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`; + } else { + dropdownStyle = `position:fixed;bottom:${window.innerHeight - rect.top + 4}px;left:${rect.left}px;width:${rect.width}px`; + } + }); function handleInput() { value = inputValue; @@ -92,20 +112,22 @@ } -
    +
    {#if label} {label} {/if}
    {#each suggestions as suggestion, i}
  • { @@ -180,4 +180,52 @@ describe('SuggestionInput', () => { // No suggestions should appear expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); + + it('uses fixed positioning to avoid overflow clipping', async () => { + const fetchSuggestions = vi.fn(async () => ['Option 1', 'Option 2']); + render(SuggestionInput, { props: { value: '', fetchSuggestions } }); + + const input = screen.getByRole('searchbox'); + await fireEvent.input(input, { target: { value: 'O' } }); + await vi.advanceTimersByTimeAsync(300); + + await vi.waitFor(() => { + const lb = screen.getByRole('listbox'); + expect(lb.getAttribute('style')).toContain('position: fixed'); + }); + const listbox = screen.getByRole('listbox'); + const style = listbox.getAttribute('style') || ''; + expect(style).toContain('top:'); + expect(style).toContain('left:'); + expect(style).toContain('width:'); + }); + + it('positions dropdown above when space below is insufficient', async () => { + const fetchSuggestions = vi.fn(async () => ['Option 1', 'Option 2']); + + const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect'); + spy.mockImplementation(function (this: Element) { + return { + top: window.innerHeight - 30, bottom: window.innerHeight - 10, + left: 0, right: 100, width: 100, height: 20, + x: 0, y: 0, toJSON: () => ({}), + } as DOMRect; + }); + + render(SuggestionInput, { props: { value: '', fetchSuggestions } }); + + const input = screen.getByRole('searchbox'); + await fireEvent.input(input, { target: { value: 'O' } }); + await vi.advanceTimersByTimeAsync(300); + + await vi.waitFor(() => { + const lb = screen.getByRole('listbox'); + expect(lb.getAttribute('style')).toContain('position: fixed'); + }); + const listbox = screen.getByRole('listbox'); + const style = listbox.getAttribute('style') || ''; + expect(style).toContain('bottom:'); + + spy.mockRestore(); + }); }); diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index 7b336dc..6fb67d3 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -3,11 +3,13 @@ let { value = $bindable(''), + name = '', disabled = false, maxTagsCount, fetchSuggestions }: { value?: string; + name?: string; disabled?: boolean; maxTagsCount?: number; fetchSuggestions?: (query: string) => Promise; @@ -20,6 +22,7 @@ let isOpen = $state(false); let isLoading = $state(false); let debounceTimer: ReturnType | undefined = $state(); + let dropdownStyle = $state(''); const tags = $derived.by(() => value @@ -57,6 +60,21 @@ } function handleInput() { + const commaIdx = inputValue.lastIndexOf(','); + if (commaIdx >= 0) { + const before = inputValue.slice(0, commaIdx).trim(); + if (before && !tags.some((t) => t.toLowerCase() === before.toLowerCase())) { + if (!(typeof maxTagsCount === 'number' && maxTagsCount > 0 && tags.length >= maxTagsCount)) { + setTags([...tags, before]); + } + } + inputValue = inputValue.slice(commaIdx + 1).trimStart(); + suggestions = []; + isOpen = false; + highlightedIndex = -1; + return; + } + if (!fetchSuggestions) return; clearTimeout(debounceTimer); const trimmed = inputValue.trim(); @@ -157,9 +175,21 @@ const after = text.slice(idx + query.length); return `${before}${match}${after}`; } + + $effect(() => { + if (!isOpen || !inputEl) return; + const rect = inputEl.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const dropdownHeight = Math.min(192, suggestions.length * 36 + 16); + if (spaceBelow >= dropdownHeight + 8) { + dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`; + } else { + dropdownStyle = `position:fixed;bottom:${window.innerHeight - rect.top + 4}px;left:${rect.left}px;width:${rect.width}px`; + } + }); -
    +
    {$_('book.tags')}
    @@ -185,6 +215,7 @@ {#if isLoading}
    @@ -204,7 +236,8 @@ {#if isOpen}
      {#each suggestions as suggestion, i}
    • { const input = screen.getByRole('textbox'); expect(input).toBeDisabled(); }); + + it('uses fixed positioning to avoid overflow clipping', async () => { + const fetchSuggestions = vi.fn(async () => ['fantasy', 'fiction']); + render(TagInput, { props: { value: '', fetchSuggestions } }); + + const input = screen.getByRole('textbox'); + await fireEvent.input(input, { target: { value: 'f' } }); + await vi.advanceTimersByTimeAsync(300); + + await waitFor(() => { + const lb = screen.getByRole('listbox'); + expect(lb.getAttribute('style')).toContain('position: fixed'); + }); + const listbox = screen.getByRole('listbox'); + const style = listbox.getAttribute('style') || ''; + expect(style).toContain('top:'); + expect(style).toContain('left:'); + expect(style).toContain('width:'); + }); + + it('uses position fixed to avoid overflow clipping', async () => { + const fetchSuggestions = vi.fn(async () => ['fantasy', 'fiction']); + render(TagInput, { props: { value: '', fetchSuggestions } }); + + const input = screen.getByRole('textbox'); + await fireEvent.input(input, { target: { value: 'f' } }); + await vi.advanceTimersByTimeAsync(300); + + await waitFor(() => { + const lb = screen.getByRole('listbox'); + expect(lb.getAttribute('style')).toContain('position: fixed'); + }); + const listbox = screen.getByRole('listbox'); + const style = listbox.getAttribute('style') || ''; + expect(style).toContain('top:'); + expect(style).toContain('left:'); + expect(style).toContain('width:'); + }); }); diff --git a/frontend/src/lib/components/Toaster.svelte b/frontend/src/lib/components/Toaster.svelte index 3171792..2341c56 100644 --- a/frontend/src/lib/components/Toaster.svelte +++ b/frontend/src/lib/components/Toaster.svelte @@ -1,6 +1,7 @@ - -
      +
      {#if open} -
      - (open = false)}>{$_('user.profile')} - -
      + {/if}
      diff --git a/frontend/src/lib/components/UserMenu.test.ts b/frontend/src/lib/components/UserMenu.test.ts index 31a202c..26b934b 100644 --- a/frontend/src/lib/components/UserMenu.test.ts +++ b/frontend/src/lib/components/UserMenu.test.ts @@ -48,10 +48,10 @@ describe('UserMenu', () => { expect(screen.getByRole('button', { name: 'User menu' })).toBeInTheDocument(); }); - it('shows user initials', () => { + it('shows user avatar', () => { currentUser.set({ id: 1, firstname: 'John', lastname: 'Doe', email: 'john@example.com', role: 'user' }); render(UserMenu); - expect(screen.getByText('JD')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'User menu' }).querySelector('svg')).toBeInTheDocument(); }); it('shows ?? when no user', () => { diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts new file mode 100644 index 0000000..b65089b --- /dev/null +++ b/frontend/src/lib/constants.ts @@ -0,0 +1 @@ +export const ALERT_DURATION_MS = 5000; diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index d1fc4ec..1ea6501 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -1,3 +1,61 @@ +const BACKEND_ERROR_MAP: Record = { + 'Email already registered': 'error.emailAlreadyRegistered', + 'User not found': 'error.userNotFound', + 'Cannot change your own admin role': 'error.cannotChangeOwnRole', + 'This ISBN is already used by another book.': 'error.isbnAlreadyExists', + 'Date cannot be in the future.': 'error.dateInFuture', + 'Start date cannot be after finish date.': 'error.dateStartedAfterFinished', + 'A finished book must have an end date. Change the status if you want to remove the finish date.': 'error.dateFinishedRequiredForRead', + 'Language must be a 2-letter ISO code (for example: EN, DE, FR).': 'error.invalidLanguageCode', + 'Select at least one dataset to export.': 'error.exportNoDatasets', + 'Unsupported upload content type. Use CSV or JSON files.': 'error.importUnsupportedContentType', + 'A mapping with this name already exists.': 'error.importMappingNameConflict', + 'Import mapping not found.': 'error.importMappingNotFound', + 'Confirmation phrase does not match.': 'error.invalidConfirmationPhrase', + 'Batch update failed due to a database error': 'error.batchUpdateFailed', + 'Cannot delete the last administrator account.': 'error.cannotDeleteLastAdmin', + 'You cannot delete your own account here. Use Profile > Danger Zone.': 'error.cannotDeleteOwnAccountHere', +}; + +const BACKEND_ERROR_REGEX: [RegExp, string, string[]][] = [ + [/^At most (\d+) books can be updated at once$/, 'error.tooManyBooksSelected', ['max']], +]; + +export function localizeBackendError(err: unknown): { key: string; values?: Record } { + if (err instanceof Error) { + if (err.message.startsWith('error.')) { + return { key: err.message }; + } + const exactKey = BACKEND_ERROR_MAP[err.message]; + if (exactKey) { + return { key: exactKey }; + } + for (const [pattern, key, names] of BACKEND_ERROR_REGEX) { + const match = err.message.match(pattern); + if (match) { + const values: Record = {}; + for (let i = 0; i < names.length; i++) { + values[names[i]] = match[i + 1]; + } + return { key, values }; + } + } + return { key: err.message }; + } + return { key: 'Unknown error' }; +} + +export function localizeError(err: unknown, translate: (key: string, options?: { values?: Record }) => string, fallback: string): string { + const { key, values } = localizeBackendError(err); + if (key.startsWith('error.')) { + return translate(key, values ? { values } : undefined); + } + if (key !== 'Unknown error') { + return key; + } + return fallback; +} + export function shouldShowActionToast(message: string): boolean { return message !== 'Missing API key' && message !== 'Not authenticated'; } diff --git a/frontend/src/lib/i18n/index.ts b/frontend/src/lib/i18n/index.ts index 876a725..151d1e3 100644 --- a/frontend/src/lib/i18n/index.ts +++ b/frontend/src/lib/i18n/index.ts @@ -5,6 +5,7 @@ export const SUPPORTED_LOCALES = ['en', 'de'] as const; export type AppLocale = (typeof SUPPORTED_LOCALES)[number]; const DEFAULT_LOCALE: AppLocale = 'en'; +const LOCALE_STORAGE_KEY = 'librislog_locale'; const envLocale = (import.meta.env.PUBLIC_DEFAULT_LOCALE as string | undefined)?.toLowerCase(); const configuredDefaultLocale: AppLocale = isSupportedLocale(envLocale) ? envLocale : DEFAULT_LOCALE; @@ -20,6 +21,24 @@ function isSupportedLocale(value: string | null | undefined): value is AppLocale return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value); } +function loadStoredLocale(): AppLocale | null { + try { + const stored = localStorage.getItem(LOCALE_STORAGE_KEY); + if (isSupportedLocale(stored)) return stored; + } catch { + // localStorage unavailable (SSR, private mode, etc.) + } + return null; +} + +function storeLocale(value: AppLocale) { + try { + localStorage.setItem(LOCALE_STORAGE_KEY, value); + } catch { + // localStorage unavailable + } +} + export async function setupI18n() { if (initialized) { await waitLocale(); @@ -33,7 +52,10 @@ export async function setupI18n() { initialLocale = settings.language; } } catch { - // unauthenticated route before cookie-based login + const stored = loadStoredLocale(); + if (stored) { + initialLocale = stored; + } } init({ @@ -42,6 +64,13 @@ export async function setupI18n() { }); initialized = true; + + locale.subscribe((value) => { + if (isSupportedLocale(value)) { + storeLocale(value); + } + }); + await waitLocale(); } diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 1f7d47a..e64a71a 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -42,9 +42,12 @@ "noCalendarData": "Keine Leseaktivität im letzten Jahr verfügbar", "pagesOver": "Seiten an", "daysLabel": "Tagen", - "avgPerDay": "Ø:", + "avgPerDay": "Ø pro aktivem Tag:", + "avgPerDayAll": "Ø pro Tag (365 Tage):", + "pagesPerDay": "Seiten/Tag", "loading": "Lade Statistiken...", - "noData": "Noch keine Daten verfügbar. Fange an, Bücher zu lesen und zu erfassen, um Statistiken zu sehen!" + "noData": "Noch keine Daten verfügbar. Fange an, Bücher zu lesen und zu erfassen, um Statistiken zu sehen!", + "resetZoom": "Zoom zurücksetzen" }, "dashboard": { "title": "Lese-Dashboard", @@ -57,8 +60,8 @@ "currentlyReading": "Lese ich gerade", "nextToRead": "Als nächstes lesen", "viewAll": "Alle anzeigen", - "searchAllBooks": "Alle Buecher durchsuchen", - "noSearchResults": "Keine Buecher gefunden", + "searchAllBooks": "Alle Bücher durchsuchen", + "noSearchResults": "Keine Bücher gefunden", "noCurrentlyReading": "Du liest aktuell kein Buch.", "noNextToRead": "Noch keine Bücher in deiner Wunschliste.", "popularTags": "Beliebte Tags" @@ -72,12 +75,18 @@ "common": { "search": "Suchen", "searchBooks": "Bücher suchen...", + "result": "Ergebnis", + "results": "Ergebnisse", "save": "Speichern", + "saved": "Gespeichert", + "saveFailed": "Speichern fehlgeschlagen", "edit": "Bearbeiten", "cancel": "Abbrechen", "confirm": "Bestätigen?", "delete": "Löschen", "deleting": "Lösche...", + "back": "Zurück", + "loadMore": "Mehr laden", "syncing": "Synchronisiere...", "noBooksYet": "Hier sind noch keine Bücher.", "addFirstBook": "Erstes Buch hinzufügen", @@ -111,8 +120,8 @@ "pages": "Seiten", "language": "Sprache", "tags": "Tags", - "tagsPlaceholder": "Tag eingeben und Enter oder Komma druecken", - "tagsHint": "Mit Enter oder Komma Tag hinzufuegen. Backspace entfernt den letzten Tag.", + "tagsPlaceholder": "Tag eingeben und Enter oder Komma drücken", + "tagsHint": "Mit Enter oder Komma Tag hinzufügen. Backspace entfernt den letzten Tag.", "notes": "Notizen", "blurb": "Beschreibung", "about": "Über das Buch", @@ -130,7 +139,7 @@ "autoSearchMeta": "{size} - {resolution}", "autoSearchSourceLabel": "Quelle: {source}", "coverOf": "Cover von {title}", - "openDetailsHint": "Zum Oeffnen der Details klicken", + "openDetailsHint": "Zum Öffnen der Details klicken", "readingProgress": "Lesefortschritt", "currentPage": "Seite", "progressLog": "Verlauf", @@ -140,6 +149,8 @@ "logPage": "Seite", "deleteEntry": "Löschen", "deleteEntryConfirm": "Diesen Eintrag löschen?", + "editEntry": "Bearbeiten", + "saveEntry": "Speichern", "progressGraph": "Fortschritt im Zeitverlauf", "progressPromptTitle": "Lesefortschritt setzen?", "progressPromptMessage": "Lesefortschritt für \"{title}\" auf 100% setzen?", @@ -168,12 +179,13 @@ "importFailed": "Import fehlgeschlagen", "searchFailed": "Suche fehlgeschlagen", "scannedIsbn": "ISBN gescannt: {isbn}", + "or": "oder", "sourceHardcoverSearching": "Durchsuche Hardcover...", "sourceHardcoverSkipped": "Hardcover übersprungen (kein API-Token konfiguriert)", "sourceSkipped": "Google Books übersprungen (kein API-Schlüssel konfiguriert)", "sourceOpenLibrarySearching": "Durchsuche Open Library...", "sourceGoogleSearching": "Durchsuche Google Books...", - "sourceBackendError": "{source}-Backendfehler (Backend-Logs pruefen)", + "sourceBackendError": "{source}-Backendfehler (Backend-Logs prüfen)", "sourceError": "Suche fehlgeschlagen: {message}", "resultCount": "{source} - {count} Ergebnis{suffix}" }, @@ -194,7 +206,9 @@ "previewAlt": "Cover-Vorschau" }, "toasts": { - "dismiss": "Schließen" + "dismiss": "Schließen", + "newVersion": "Eine neue Version ({version}) ist verfügbar.", + "reload": "Neu laden" }, "settings": { "title": "Einstellungen", @@ -204,6 +218,11 @@ "timezoneDetected": "Erkannt: {tz}", "timezoneSelected": "Ausgewählt: {tz}", "timezoneInvalid": "Bitte wählen Sie eine gültige Zeitzone aus der Liste.", + "themeTitle": "Design", + "themeLight": "Hell", + "themeDark": "Dunkel", + "themeCustom": "Anpassen", + "themeSelect": "Wähle ein benutzerdefiniertes Design", "timezonePlaceholder": "Zeitzone suchen...", "apiDocsTitle": "API-Dokumentation", "apiDocsHelp": "Erkunde und teste Backend-Endpunkte direkt in der App.", @@ -237,6 +256,12 @@ "clearDesc": "Entfernt das Beendigungsdatum und setzt heute ({newStartDate}) als Startdatum." } }, + "search": { + "resultsCount": "{count, plural, one {Ergebnis} other {Ergebnisse}} gefunden", + "noResults": "Keine Ergebnisse gefunden", + "noResultsFor": "Keine Ergebnisse für \"{query}\" gefunden", + "tryDifferentQuery": "Versuche einen anderen Suchbegriff" + }, "languages": { "en": "Englisch", "de": "Deutsch" @@ -251,12 +276,14 @@ "setupTitle": "Admin-Konto erstellen", "setupFailed": "Setup fehlgeschlagen", "createAdmin": "Admin erstellen", - "invalidEmailError": "Bitte eine gueltige E-Mail-Adresse eingeben", + "invalidEmailError": "Bitte eine gültige E-Mail-Adresse eingeben", "passwordComplexityError": "Passwort erfüllt die Komplexitätsanforderungen nicht" }, "user": { "menu": "Benutzermenü", "profile": "Profil", + "about": "Über", + "theme": "Design", "logout": "Abmelden", "apiKeys": "API-Schlüssel", "keyDescription": "Beschreibung (optional)", @@ -332,23 +359,33 @@ "cannotDeleteOwnAccountHere": "Das eigene Konto kann hier nicht gelöscht werden. Verwenden Sie Profil > Gefahrenbereich.", "importMalformedEvent": "Während des Imports wurde ein fehlerhaftes Server-Ereignis empfangen.", "importUnsupportedContentType": "Nicht unterstützter Upload-Inhaltstyp. Bitte CSV- oder JSON-Dateien verwenden.", + "emailAlreadyRegistered": "Diese E-Mail-Adresse ist bereits registriert.", + "batchUpdateFailed": "Stapelaktualisierung aufgrund eines unerwarteten Fehlers fehlgeschlagen. Es wurden keine Änderungen gespeichert.", + "tooManyBooksSelected": "Zu viele Bücher ausgewählt. Bitte maximal {max} auf einmal auswählen.", + "userNotFound": "Benutzer nicht gefunden.", + "cannotChangeOwnRole": "Sie können Ihre eigene Admin-Rolle nicht ändern.", + "authorRequired": "Autor ist erforderlich.", + "pageCountRequired": "Seitenanzahl ist erforderlich.", "importTempFileCreateFailed": "Temporäre Importdatei konnte nicht erstellt werden. Bitte erneut versuchen.", - "fileTooLarge": "Die Datei ist zu groß. Bitte versuche eine kleinere Datei oder prüfe die Server-Limits." + "fileTooLarge": "Die Datei ist zu groß. Bitte versuche eine kleinere Datei oder prüfe die Server-Limits.", + "exportNoDatasets": "Wähle mindestens einen Datensatz zum Exportieren aus.", + "importMappingNameConflict": "Ein Mapping mit diesem Namen existiert bereits.", + "importMappingNotFound": "Import-Mapping nicht gefunden." }, "oidc": { "orContinueWith": "oder weiter mit", "loginWithProvider": "Weiter mit {provider}", "profileTitle": "Single Sign-On", - "notLinked": "Dein Konto ist noch nicht verknuepft.", - "linkButton": "{provider}-Konto verknuepfen", - "unlinkButton": "Konto-Verknuepfung entfernen", - "linkedAs": "Verknuepft mit {provider}", - "linkSuccess": "Konto erfolgreich verknuepft", - "linkStartFailed": "Konto-Verknuepfung konnte nicht gestartet werden", - "unlinkSuccess": "Konto-Verknuepfung entfernt", - "unlinkFailed": "Konto-Verknuepfung konnte nicht entfernt werden", - "signingIn": "Anmeldung laeuft...", - "linkingAccount": "Konto wird verknuepft..." + "notLinked": "Dein Konto ist noch nicht verknüpft.", + "linkButton": "{provider}-Konto verknüpfen", + "unlinkButton": "Konto-Verknüpfung entfernen", + "linkedAs": "Verknüpft mit {provider}", + "linkSuccess": "Konto erfolgreich verknüpft", + "linkStartFailed": "Konto-Verknüpfung konnte nicht gestartet werden", + "unlinkSuccess": "Konto-Verknüpfung entfernt", + "unlinkFailed": "Konto-Verknüpfung konnte nicht entfernt werden", + "signingIn": "Anmeldung läuft...", + "linkingAccount": "Konto wird verknüpft..." }, "profile": { "sectionNav": "Auf dieser Seite", @@ -431,14 +468,38 @@ "mappingName": "Name der Zuordnung", "loadSavedMapping": "Gespeicherte Zuordnungen", "noSavedMappings": "Noch keine gespeicherten Zuordnungen. Speichere die aktuelle Zuordnung, um sie später wiederzuverwenden.", + "missingFieldsTitle": "Einige Quellfelder der gespeicherten Zuordnung sind in dieser Datei nicht vorhanden:", + "missingFieldEntry": "{target} ← {source}", "selectMapping": "Gespeicherte Zuordnung auswählen", "loadMapping": "Zuordnung laden", + "readonlyMapping": "schreibgeschützt", "deleteMapping": "Zuordnung löschen", "deleteMappingTitle": "Gespeicherte Zuordnung löschen", "showPreview": "Zuordnungsvorschau anzeigen", "createProgressForRead": "100%-Fortschrittseintrag für als 'Gelesen' importierte Bücher anlegen", "hidePreview": "Zuordnungsvorschau ausblenden", "previewNoMappedFields": "Noch keine Felder zugeordnet. Weise Quellfelder Zielfeldern zu, um Werte zu sehen.", + "transformLabel": "Transformation (Python)", + "transformPlaceholder": "z.B. value.upper()", + "previewTitle": "Vorschau", + "previewButton": "Generieren", + "previewLoading": "Generiere...", + "previewStale": "Vorschau veraltet", + "previewRow": "Zeile {row}", + "errorRow": "Zeile {row}", + "previewSource": "Quelle", + "previewTransformed": "Transformiert", + "none": "(keine)", + "requiredField": "= Pflichtfeld", + "changeFile": "Datei wechseln", + "coverUrlHint": "Erwartet eine HTTP(S)-URL zu einem Bild. Lokale Dateipfade und Base64-Daten werden nicht unterstützt.", + "transformHelp": "Verfügbare Parameter und Beispiele", + "transformHelpValue": "Der Rohwert des zugeordneten Quellfelds", + "transformHelpRow": "Alle Quellfelder als Dict, z.B. row['title']", + "transformHelpContext": "Context-Dict mit Zeilennummer und Gesamtzeilen (context['row_num'], context['total_rows'])", + "transformHelpReturn": "Einzelausdrücke werden automatisch zurückgegeben; für mehrzeiligen Code ein explizites return verwenden", + "transformHelpImports": "Verfügbare Python-Imports: datetime, re, json, math", + "transformError": "Transformationsregel für {field} ist ungültig: {error}", "saveMapping": "Zuordnung speichern", "refreshMappings": "Zuordnungen aktualisieren", "mappingSaved": "Zuordnung gespeichert", @@ -471,8 +532,69 @@ "loadMappingsFailed": "Zuordnungen konnten nicht geladen werden.", "loadMappingFailed": "Zuordnung konnte nicht geladen werden.", "validateFailed": "Validierung fehlgeschlagen.", + "previewFailed": "Vorschau konnte nicht geladen werden.", "executeFailed": "Import fehlgeschlagen." } } + }, + "dataHygiene": { + "authorRequired": "Autor darf nicht leer sein.", + "pageCountPositive": "Seitenzahl muss größer als 0 sein.", + "title": "Datenpflege", + "description": "Finde und korrigiere Bücher mit fehlenden Metadaten in deiner Bibliothek.", + "attributes": { + "author": "Autor", + "isbn": "ISBN", + "publisher": "Verlag", + "published_year": "Jahr", + "blurb": "Beschreibung", + "language": "Sprache", + "subtitle": "Untertitel", + "page_count": "Seitenanzahl", + "cover_url": "Cover" + }, + "matchAny": "Beliebiges", + "matchAll": "Alle", + "noMissingBooks": "Keine Bücher mit fehlenden Attributen gefunden, die deinen Filtern entsprechen.", + "total": "{count} Buch/Bücher gefunden", + "loadMore": "Mehr laden", + "loading": "Überprüfe deine Bibliothek...", + "selectAll": "Alle auswählen", + "deselectAll": "Auswahl aufheben", + "nSelected": "{count} Buch/Bücher ausgewählt", + "batchEditTitle": "Stapelbearbeitung", + "batchFieldLabel": "Zu aktualisierendes Feld", + "batchFieldPlaceholder": "Wähle ein Feld...", + "batchValueLabel": "Neuer Wert", + "batchValuePlaceholder": "Neuen Wert eingeben", + "applyBatch": "Auf ausgewählte anwenden", + "confirmTitle": "{count} Buch/Bücher aktualisieren?", + "confirmBody": "Dies setzt \"{field}\" auf \"{value}\" für folgende Bücher:", + "confirmApply": "Aktualisieren", + "confirmCancel": "Abbrechen", + "success": "{updated} Buch/Bücher aktualisiert. {skipped} hatten bereits diesen Wert.", + "updateFailed": "Stapelaktualisierung fehlgeschlagen.", + "loadFailed": "Daten konnten nicht geladen werden.", + "tooManySelected": "Bitte wähle maximal 500 Bücher auf einmal aus.", + "noAttributeSelected": "Wähle mindestens ein Attribut zum Suchen aus.", + "noFieldSelected": "Wähle ein zu aktualisierendes Feld aus.", + "noValueEntered": "Gib einen Wert zum Setzen ein.", + "allSet": "Deine Bibliothek ist in großartiger Verfassung! Alle Bücher haben vollständige Metadaten.", + "allSetFiltered": "Deine Bibliothek ist in großartiger Verfassung! Alle Bücher haben vollständige Metadaten für die ausgewählten Attribute.", + "tableHeaderMissing": "Fehlend", + "remaining": "übrig", + "andXMore": "...und {count} weitere" + }, + "about": { + "title": "Über LibrisLog", + "description": "Eine Web-App zur Buchverwaltung — verwalte deine Leselisten, importiere Bücher aus Online-Quellen und verfolge deinen Lesefortschritt in einem modernen Dashboard.", + "author": "Autor", + "version": "Version", + "technologies": "Verwendete Technologien", + "thankYou": "Danksagung", + "thankYouText": "LibrisLog wäre ohne die erstaunlichen Open-Source-Bibliotheken und Frameworks, auf denen es aufbaut, nicht möglich. Unser Dank gilt allen Entwicklern, die zu diesen Projekten beitragen.", + "frontend": "Frontend", + "backend": "Backend", + "devTools": "Entwicklungswerkzeuge" } } diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 6ac5e14..66b4fcc 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -42,9 +42,12 @@ "noCalendarData": "No reading data available for the past year", "pagesOver": "pages over", "daysLabel": "days", - "avgPerDay": "Avg:", + "avgPerDay": "Avg per active day:", + "avgPerDayAll": "Avg per day (365 days):", + "pagesPerDay": "pages/day", "loading": "Loading statistics...", - "noData": "No data available yet. Start reading and tracking books to see statistics!" + "noData": "No data available yet. Start reading and tracking books to see statistics!", + "resetZoom": "Reset zoom" }, "dashboard": { "title": "Reading Dashboard", @@ -72,12 +75,18 @@ "common": { "search": "Search", "searchBooks": "Search books...", + "result": "result", + "results": "results", "save": "Save", + "saved": "Saved", + "saveFailed": "Save failed", "edit": "Edit", "cancel": "Cancel", "confirm": "Confirm?", "delete": "Delete", "deleting": "Deleting...", + "back": "Back", + "loadMore": "Load more", "syncing": "Syncing...", "noBooksYet": "No books here yet.", "addFirstBook": "Add your first book", @@ -140,6 +149,8 @@ "logPage": "Page", "deleteEntry": "Delete", "deleteEntryConfirm": "Delete this entry?", + "editEntry": "Edit", + "saveEntry": "Save", "progressGraph": "Progress Over Time", "progressPromptTitle": "Set Reading Progress?", "progressPromptMessage": "Set the reading progress for \"{title}\" to 100%?", @@ -168,6 +179,7 @@ "importFailed": "Import failed", "searchFailed": "Search failed", "scannedIsbn": "Scanned ISBN: {isbn}", + "or": "or", "sourceHardcoverSearching": "Searching Hardcover...", "sourceHardcoverSkipped": "Hardcover skipped (no API token configured)", "sourceSkipped": "Google Books skipped (no API key configured)", @@ -194,7 +206,9 @@ "previewAlt": "Cover preview" }, "toasts": { - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "newVersion": "A new version ({version}) is available.", + "reload": "Reload" }, "settings": { "title": "Settings", @@ -204,6 +218,11 @@ "timezoneDetected": "Detected: {tz}", "timezoneSelected": "Selected: {tz}", "timezoneInvalid": "Please select a valid timezone from the list.", + "themeTitle": "Theme", + "themeLight": "Light", + "themeDark": "Dark", + "themeCustom": "Customize", + "themeSelect": "Select a custom theme", "timezonePlaceholder": "Search timezone...", "apiDocsTitle": "API Documentation", "apiDocsHelp": "Explore and test backend endpoints directly from the app.", @@ -237,6 +256,12 @@ "clearDesc": "Removes the finish date and sets today ({newStartDate}) as the start date." } }, + "search": { + "resultsCount": "{count, plural, one {result} other {results}} found", + "noResults": "No results found", + "noResultsFor": "No results found for \"{query}\"", + "tryDifferentQuery": "Try a different search term" + }, "languages": { "en": "English", "de": "German" @@ -257,6 +282,8 @@ "user": { "menu": "User menu", "profile": "Profile", + "about": "About", + "theme": "Theme", "logout": "Logout", "apiKeys": "API Keys", "keyDescription": "Description (optional)", @@ -332,8 +359,18 @@ "cannotDeleteOwnAccountHere": "You cannot delete your own account here. Use Profile > Danger Zone.", "importMalformedEvent": "Received malformed server event during import.", "importUnsupportedContentType": "Unsupported upload content type. Use CSV or JSON files.", + "emailAlreadyRegistered": "This email address is already registered.", + "userNotFound": "User not found.", + "cannotChangeOwnRole": "You cannot change your own admin role.", + "authorRequired": "Author is required.", + "pageCountRequired": "Page count is required.", "importTempFileCreateFailed": "Could not create a temporary import file. Please try again.", - "fileTooLarge": "The file is too large. Please try a smaller file or check server limits." + "fileTooLarge": "The file is too large. Please try a smaller file or check server limits.", + "exportNoDatasets": "Select at least one dataset to export.", + "batchUpdateFailed": "Batch update failed due to an unexpected error. No changes were saved.", + "tooManyBooksSelected": "Too many books selected. Please select at most {max} at a time.", + "importMappingNameConflict": "A mapping with this name already exists.", + "importMappingNotFound": "Import mapping not found." }, "oidc": { "orContinueWith": "or continue with", @@ -359,7 +396,7 @@ "dataManagement": { "title": "Manage my data", "description": "Export your library or import books from a CSV/JSON file.", - "link": "Open data page" + "link": "Import / Export" }, "dangerZone": { "title": "Danger Zone", @@ -431,14 +468,38 @@ "mappingName": "Mapping name", "loadSavedMapping": "Saved mappings", "noSavedMappings": "No saved mappings yet. Save your current mapping to reuse it later.", + "missingFieldsTitle": "Some source fields from the saved mapping are not present in this file:", + "missingFieldEntry": "{target} ← {source}", "selectMapping": "Select a saved mapping", "loadMapping": "Load mapping", + "readonlyMapping": "read-only", "deleteMapping": "Delete mapping", "deleteMappingTitle": "Delete saved mapping", "showPreview": "Show mapping preview", "createProgressForRead": "Create 100% progress entry for books imported as 'Read'", "hidePreview": "Hide mapping preview", "previewNoMappedFields": "No mapped fields yet. Assign source fields to target fields to preview values.", + "transformLabel": "Transform (Python)", + "transformPlaceholder": "e.g. value.upper()", + "previewTitle": "Preview", + "previewButton": "Generate", + "previewLoading": "Generating...", + "previewStale": "Preview is outdated", + "previewRow": "Row {row}", + "errorRow": "Row {row}", + "previewSource": "Source", + "previewTransformed": "Transformed", + "none": "(none)", + "requiredField": "= required field", + "changeFile": "Change file", + "coverUrlHint": "Expects an HTTP(S) URL to an image. Local file paths and base64 data are not supported.", + "transformHelp": "Available parameters and examples", + "transformHelpValue": "The raw value of the mapped source field", + "transformHelpRow": "All source fields as a dict, e.g. row['title']", + "transformHelpContext": "Context dict with row number and total rows (context['row_num'], context['total_rows'])", + "transformHelpReturn": "Single expressions are auto-returned; use an explicit return statement for multi-line code", + "transformHelpImports": "Available Python imports: datetime, re, json, math", + "transformError": "Transform rule for {field} is invalid: {error}", "saveMapping": "Save mapping", "refreshMappings": "Refresh mappings", "mappingSaved": "Mapping saved", @@ -471,8 +532,69 @@ "loadMappingsFailed": "Failed to load mappings.", "loadMappingFailed": "Failed to load mapping.", "validateFailed": "Validation failed.", + "previewFailed": "Failed to load preview.", "executeFailed": "Import failed." } } + }, + "dataHygiene": { + "authorRequired": "Author cannot be empty.", + "pageCountPositive": "Page count must be greater than 0.", + "title": "Data Hygiene", + "description": "Find and fix books with missing metadata in your library.", + "attributes": { + "author": "Author", + "isbn": "ISBN", + "publisher": "Publisher", + "published_year": "Year", + "blurb": "Description", + "language": "Language", + "subtitle": "Subtitle", + "page_count": "Page count", + "cover_url": "Cover" + }, + "matchAny": "Match any", + "matchAll": "Match all", + "noMissingBooks": "No books found with missing attributes matching your filters.", + "total": "{count} book(s) found", + "loadMore": "Load more", + "loading": "Checking your library...", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "nSelected": "{count} book(s) selected", + "batchEditTitle": "Batch edit", + "batchFieldLabel": "Field to update", + "batchFieldPlaceholder": "Select a field...", + "batchValueLabel": "New value", + "batchValuePlaceholder": "Enter new value", + "applyBatch": "Apply to selected", + "confirmTitle": "Update {count} book(s)?", + "confirmBody": "This will set \"{field}\" to \"{value}\" for the following books:", + "confirmApply": "Apply update", + "confirmCancel": "Cancel", + "success": "Updated {updated} book(s). {skipped} already had this value.", + "updateFailed": "Batch update failed.", + "loadFailed": "Failed to load data.", + "tooManySelected": "Please select at most 500 books at a time.", + "noAttributeSelected": "Select at least one attribute to search for.", + "noFieldSelected": "Select a field to update.", + "noValueEntered": "Enter a value to set.", + "allSet": "Your library is in great shape! All books have complete metadata.", + "allSetFiltered": "Your library is in great shape! All books have complete metadata for the selected attributes.", + "tableHeaderMissing": "Missing", + "remaining": "remaining", + "andXMore": "...and {count} more" + }, + "about": { + "title": "About LibrisLog", + "description": "A book tracking webapp to manage your reading lists, import books from online sources, and track your reading progress — all through a modern dashboard.", + "author": "Author", + "version": "Version", + "technologies": "Technologies Used", + "thankYou": "Thank You", + "thankYouText": "LibrisLog would not exist without the amazing open-source libraries and frameworks it builds upon. Our thanks go to all developers who contribute to these projects.", + "frontend": "Frontend", + "backend": "Backend", + "devTools": "Dev Tools" } } diff --git a/frontend/src/lib/stores/theme.test.ts b/frontend/src/lib/stores/theme.test.ts new file mode 100644 index 0000000..08ec208 --- /dev/null +++ b/frontend/src/lib/stores/theme.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + setThemeMode, getThemeMode, setCustomTheme, getCustomTheme, + getEffectiveTheme, cycleTheme, loadThemeFromStorage, saveThemeToStorage, + applyThemeToDocument, DAISYUI_THEMES +} from './theme'; + +describe('theme store', () => { + beforeEach(() => { + setThemeMode('custom'); + setCustomTheme('librislog'); + localStorage.clear(); + }); + + it('defaults to custom mode with librislog theme', () => { + expect(getThemeMode()).toBe('custom'); + expect(getEffectiveTheme()).toBe('librislog'); + }); + + it('cycles through modes starting from custom', () => { + expect(cycleTheme()).toBe('light'); + expect(cycleTheme()).toBe('dark'); + expect(cycleTheme()).toBe('custom'); + }); + + it('uses custom theme when in custom mode', () => { + setThemeMode('custom'); + setCustomTheme('dracula'); + expect(getEffectiveTheme()).toBe('dracula'); + }); + + it('falls back to librislog when custom theme is not set', () => { + setThemeMode('custom'); + setCustomTheme(null); + expect(getEffectiveTheme()).toBe('librislog'); + }); + + it('rejects invalid custom themes', () => { + setCustomTheme('invalid'); + expect(getCustomTheme()).toBeNull(); + }); + + it('persists to and loads from localStorage', () => { + setThemeMode('dark'); + setCustomTheme('nord'); + saveThemeToStorage(); + + setThemeMode('light'); + setCustomTheme(null); + + loadThemeFromStorage(); + expect(getThemeMode()).toBe('dark'); + expect(getCustomTheme()).toBe('nord'); + }); + + it('applyThemeToDocument sets data-theme on html', () => { + setThemeMode('dark'); + applyThemeToDocument(); + expect(document.documentElement.dataset.theme).toBe('dark'); + }); + + it('contains all expected daisyui themes', () => { + expect(DAISYUI_THEMES).toContain('dracula'); + expect(DAISYUI_THEMES).toContain('nord'); + expect(DAISYUI_THEMES).toContain('sunset'); + expect(DAISYUI_THEMES).toContain('librislog'); + expect(DAISYUI_THEMES).not.toContain('light'); + expect(DAISYUI_THEMES).not.toContain('dark'); + }); +}); diff --git a/frontend/src/lib/stores/theme.ts b/frontend/src/lib/stores/theme.ts new file mode 100644 index 0000000..8d207a9 --- /dev/null +++ b/frontend/src/lib/stores/theme.ts @@ -0,0 +1,150 @@ +export type ThemeMode = 'light' | 'dark' | 'custom'; +import { invalidateColorCache } from '$lib/chartjs/theme'; + +const DAISYUI_THEMES = [ + 'cupcake', 'bumblebee', 'emerald', 'corporate', 'synthwave', 'retro', + 'cyberpunk', 'valentine', 'halloween', 'garden', 'forest', 'aqua', + 'lofi', 'pastel', 'fantasy', 'wireframe', 'black', 'luxury', 'dracula', + 'cmyk', 'autumn', 'business', 'acid', 'lemonade', 'night', 'coffee', + 'winter', 'dim', 'nord', 'sunset', 'caramellatte', 'abyss', 'silk', + 'librislog' +] as const; + +export type DaisyUITheme = (typeof DAISYUI_THEMES)[number]; + +export const THEME_MODE_KEY = 'librislog_theme_mode'; +export const CUSTOM_THEME_KEY = 'librislog_custom_theme'; + +let _themeMode: ThemeMode = 'custom'; +let _customTheme: DaisyUITheme | null = 'librislog'; +let _version = 0; + +export function getThemeMode(): ThemeMode { + return _themeMode; +} + +export function setThemeMode(mode: ThemeMode) { + _themeMode = mode; +} + +export function getCustomTheme(): DaisyUITheme | null { + return _customTheme; +} + +export function getThemeVersion(): number { + return _version; +} + +export function setCustomTheme(theme: DaisyUITheme | string | null) { + if (theme && DAISYUI_THEMES.includes(theme as DaisyUITheme)) { + _customTheme = theme as DaisyUITheme; + } else { + _customTheme = null; + } + _version++; +} + +const VALID_MODES: ThemeMode[] = ['light', 'dark', 'custom']; + +export function sanitizeThemeMode(raw: string): ThemeMode { + return VALID_MODES.includes(raw as ThemeMode) ? (raw as ThemeMode) : 'custom'; +} + +export function getEffectiveTheme(): string { + if (_themeMode === 'custom' && _customTheme) { + return _customTheme; + } + if (_themeMode === 'custom') { + return 'librislog'; + } + return _themeMode; +} + +export function cycleTheme(): ThemeMode { + const order: ThemeMode[] = ['custom', 'light', 'dark']; + const idx = order.indexOf(_themeMode); + _themeMode = order[(idx + 1) % order.length]; + return _themeMode; +} + +export function loadThemeFromStorage() { + try { + const storedMode = localStorage.getItem(THEME_MODE_KEY) as ThemeMode | null; + if (storedMode && ['light', 'dark', 'custom'].includes(storedMode)) { + _themeMode = storedMode; + } + const storedCustom = localStorage.getItem(CUSTOM_THEME_KEY); + if (storedCustom && DAISYUI_THEMES.includes(storedCustom as DaisyUITheme)) { + _customTheme = storedCustom as DaisyUITheme; + } + } catch { + // localStorage unavailable + } +} + +export function saveThemeToStorage() { + try { + localStorage.setItem(THEME_MODE_KEY, _themeMode); + if (_customTheme) { + localStorage.setItem(CUSTOM_THEME_KEY, _customTheme); + } else { + localStorage.removeItem(CUSTOM_THEME_KEY); + } + } catch { + // localStorage unavailable + } +} + +import { writable } from 'svelte/store'; + +export const themeApplyCount = writable(0); + +const DARK_THEMES: readonly string[] = ['synthwave', 'halloween', 'forest', 'dracula', 'black', 'luxury', 'night', 'coffee', 'dim', 'abyss', 'sunset', 'business']; + +function updateFavicon() { + const effective = getEffectiveTheme(); + const isDark = _themeMode === 'dark' || (_themeMode === 'custom' && DARK_THEMES.includes(effective)); + const href = isDark ? '/favicon/favicon-dark.svg' : '/favicon/favicon.svg'; + const link = document.querySelector('link[rel="icon"][type="image/svg+xml"]') as HTMLLinkElement | null; + if (link && link.href !== new URL(href, location.href).href) { + link.href = href; + } +} + +export function applyThemeToDocument() { + const effective = getEffectiveTheme(); + document.documentElement.dataset.theme = effective; + invalidateColorCache(); + updateFavicon(); + themeApplyCount.update(n => n + 1); +} + +export function getThemeIcon(): string { + switch (_themeMode) { + case 'light': return 'Sun'; + case 'dark': return 'Moon'; + case 'custom': return 'Palette'; + } + return 'Sun'; +} + +/** Restore-point for profile page so the preview can be reverted on navigation */ +let _restorePoint: { mode: ThemeMode; custom: DaisyUITheme | null } | null = null; + +export function saveRestorePoint(): void { + _restorePoint = { mode: _themeMode, custom: _customTheme }; +} + +export function clearRestorePoint(): void { + _restorePoint = null; +} + +export function restoreFromPoint(): boolean { + if (!_restorePoint) return false; + _themeMode = _restorePoint.mode; + _customTheme = _restorePoint.custom; + _restorePoint = null; + return true; +} + +export { DAISYUI_THEMES }; diff --git a/frontend/src/lib/test/setup.ts b/frontend/src/lib/test/setup.ts index 4b08cd7..b1c7645 100644 --- a/frontend/src/lib/test/setup.ts +++ b/frontend/src/lib/test/setup.ts @@ -1,5 +1,6 @@ import { vi } from 'vitest'; import '@testing-library/jest-dom/vitest'; +import '$lib/chartjs/register'; // --- Polyfill crypto.randomUUID for happy-dom --- if (typeof crypto !== 'undefined' && !crypto.randomUUID) { @@ -58,6 +59,11 @@ vi.mock('$app/navigation', () => ({ onNavigate: () => () => {} })); +// --- Mock animal-avatar-generator (ESM resolution issues in vitest) --- +vi.mock('animal-avatar-generator', () => ({ + default: () => '' +})); + // --- Reset DOM between tests --- import { cleanup } from '@testing-library/svelte'; diff --git a/frontend/src/lib/toasts.ts b/frontend/src/lib/toasts.ts index 7edc1f1..7b201fe 100644 --- a/frontend/src/lib/toasts.ts +++ b/frontend/src/lib/toasts.ts @@ -6,14 +6,15 @@ export interface Toast { id: number; message: string; level: ToastLevel; + action?: { label: string; onClick: () => void }; } let _id = 0; const { subscribe, update } = writable([]); -function add(message: string, level: ToastLevel = 'error', duration = 4000) { +function add(message: string, level: ToastLevel = 'error', duration = 4000, action?: { label: string; onClick: () => void }) { const id = ++_id; - update((toasts) => [...toasts, { id, message, level }]); + update((toasts) => [...toasts, { id, message, level, action }]); setTimeout(() => remove(id), duration); } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 28b58da..42f1f5c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -21,6 +21,11 @@ export interface Book { date_finished: string | null; } +export interface BookListResponse { + books: Book[]; + total: number; +} + export interface BookImportCandidate { title: string; subtitle: string | null; @@ -183,6 +188,8 @@ export interface UserSettings { language: string; timezone: string; quote_service_enabled: boolean; + theme: string; + custom_theme: string | null; } export interface ApiKeyMeta { @@ -271,15 +278,35 @@ export interface DataImportMappingListItem { name: string; created_at: string; updated_at: string; + is_predefined: boolean; } export interface DataImportMappingRead { id: number; name: string; source_fields: string[]; - mapping: Record; + mapping: Record; created_at: string; updated_at: string; + is_predefined: boolean; +} + +export interface ImportFieldConfig { + source: string; + transform: string | null; +} + +export interface DataImportPreviewRow { + row_number: number; + source: Record; + transformed: Record; + errors: string[]; +} + +export interface DataImportPreviewResponse { + preview_rows: DataImportPreviewRow[]; + row_count: number; + errors: string[]; } export interface DataImportValidateResponse { @@ -289,6 +316,50 @@ export interface DataImportValidateResponse { errors: string[]; } +export type HygieneAttribute = + | 'author' + | 'isbn' + | 'publisher' + | 'published_year' + | 'blurb' + | 'language' + | 'subtitle' + | 'page_count' + | 'cover_url'; + +export interface HygieneMissingBook { + id: number; + title: string; + author: string | null; + isbn: string | null; + publisher: string | null; + published_year: number | null; + blurb: string | null; + language: string | null; + subtitle: string | null; + page_count: number; + cover_url: string | null; + missing_attributes: HygieneAttribute[]; +} + +export interface HygieneMissingResponse { + books: HygieneMissingBook[]; + total: number; + total_missing_per_attribute: Record; +} + +export interface HygieneBatchUpdateRequest { + book_ids: number[]; + field: HygieneAttribute; + value: string | number | null; +} + +export interface HygieneBatchUpdateResponse { + updated: number; + skipped: number; + skipped_ids: number[]; +} + export type DataImportEvent = | { event: 'start'; total_rows: number } | { event: 'progress'; processed: number; total: number; percent: number } diff --git a/frontend/src/lib/utils/language.test.ts b/frontend/src/lib/utils/language.test.ts index 00d9226..09a1f31 100644 --- a/frontend/src/lib/utils/language.test.ts +++ b/frontend/src/lib/utils/language.test.ts @@ -35,7 +35,7 @@ describe('formatLanguageCode', () => { it('returns uppercase code when Intl.DisplayNames throws', () => { const OriginalDisplayNames = Intl.DisplayNames; // @ts-expect-error mock - Intl.DisplayNames = vi.fn(() => { + Intl.DisplayNames = vi.fn(function () { throw new TypeError('Intl not available'); }); try { diff --git a/frontend/src/lib/utils/prism.ts b/frontend/src/lib/utils/prism.ts new file mode 100644 index 0000000..fa70594 --- /dev/null +++ b/frontend/src/lib/utils/prism.ts @@ -0,0 +1,96 @@ +const PY_KEYWORDS = new Set([ + 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', + 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', + 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', + 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', + 'try', 'while', 'with', 'yield' +]); + +const TOKENS: Array<{ re: RegExp; cls: string }> = [ + { re: /#[^\n]*/g, cls: 'comment' }, + { re: /'''[\s\S]*?'''|"""[\s\S]*?"""/g, cls: 'string' }, + { re: /'[^'\\]*(?:\\.[^'\\]*)*'|"[^"\\]*(?:\\.[^"\\]*)*"/g, cls: 'string' }, + { re: /\b[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b/g, cls: 'number' }, + { re: /\b[a-zA-Z_]\w*(?=\s*\()/g, cls: 'function' }, + { re: /\b[a-zA-Z_]\w*\b/g, cls: 'keyword' }, +]; + +export function highlightPython(code: string): string { + let html = ''; + let last = 0; + const segments: Array<[number, number, string]> = []; + + for (const { re, cls } of TOKENS) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(code)) !== null) { + if (cls === 'keyword' && !PY_KEYWORDS.has(m[0])) continue; + segments.push([m.index, m.index + m[0].length, cls]); + } + } + + segments.sort((a, b) => a[0] - b[0] || (b[1] - b[0]) - (a[1] - a[0])); + + const merged: Array<[number, number, string]> = []; + for (const seg of segments) { + if (merged.length > 0 && seg[0] < merged[merged.length - 1][1]) continue; + merged.push(seg); + } + + for (const [start, end, cls] of merged) { + if (start > last) { + html += esc(code.slice(last, start)); + } + html += `${esc(code.slice(start, end))}`; + last = end; + } + if (last < code.length) { + html += esc(code.slice(last)); + } + return html; +} + +const JSON_TOKENS: Array<{ re: RegExp; cls: string }> = [ + { re: /"[^"\\]*(?:\\.[^"\\]*)?"\s*:/g, cls: 'json-key' }, + { re: /"[^"\\]*(?:\\.[^"\\]*)?"/g, cls: 'json-string' }, + { re: /\b-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b/g, cls: 'json-number' }, + { re: /\b(?:true|false|null)\b/g, cls: 'json-bool' }, +]; + +export function highlightJson(code: string): string { + let html = ''; + let last = 0; + const segments: Array<[number, number, string]> = []; + + for (const { re, cls } of JSON_TOKENS) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(code)) !== null) { + segments.push([m.index, m.index + m[0].length, cls]); + } + } + + segments.sort((a, b) => a[0] - b[0] || (b[1] - b[0]) - (a[1] - a[0])); + + const merged: Array<[number, number, string]> = []; + for (const seg of segments) { + if (merged.length > 0 && seg[0] < merged[merged.length - 1][1]) continue; + merged.push(seg); + } + + for (const [start, end, cls] of merged) { + if (start > last) { + html += esc(code.slice(last, start)); + } + html += `${esc(code.slice(start, end))}`; + last = end; + } + if (last < code.length) { + html += esc(code.slice(last)); + } + return html; +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 78f83dd..99fc9e2 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,21 +1,37 @@ + +
      +

      {$_('about.title')}

      + +
      +
      +
      + +
      +

      LibrisLog

      +

      {displayVersion}

      +
      +
      +

      + {$_('about.description')} +

      +
      +
      + +
      +
      +

      {$_('about.author')}

      +
      +
      + RH +
      +
      +

      Raffael Herrmann

      + github.com/codebude +
      +
      +
      +
      + +
      +
      +

      {$_('about.technologies')}

      + +
      +

      {$_('about.frontend')}

      +
      + {#each frontendDeps as [name, ver]} + {name} + {/each} + {#each additionalDeps as dep} + {dep.name} + {/each} +
      +
      + +
      +

      {$_('about.backend')}

      +
      + {#each backendDeps as dep} + {dep.name} + {/each} +
      +
      + +
      +

      {$_('about.devTools')}

      +
      + {#each devDeps as [name, ver]} + {name} + {/each} +
      +
      +
      +
      + +
      +
      +

      {$_('about.thankYou')}

      +

      + {$_('about.thankYouText')} +

      +
      +
      +
      diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 2d076b6..974131d 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -1,9 +1,11 @@ @@ -159,25 +167,28 @@

      {$_('admin.newUser')}

      {#if adminError} -
      {adminError}
      + (adminError = '')}> + {adminError} + {/if}
      - @@ -211,20 +222,21 @@
      - diff --git a/frontend/src/routes/auth/oidc/callback/+page.svelte b/frontend/src/routes/auth/oidc/callback/+page.svelte index fc9075e..1bb0879 100644 --- a/frontend/src/routes/auth/oidc/callback/+page.svelte +++ b/frontend/src/routes/auth/oidc/callback/+page.svelte @@ -1,4 +1,5 @@ + +
      +

      {$_('dataHygiene.title')}

      +

      {$_('dataHygiene.description')}

      + + +
      + {#each ATTRIBUTES as attr} + + {/each} + +
      + + + {#if loading && books.length === 0} +
      + + {$_('dataHygiene.loading')} +
      + + {:else if error && books.length === 0} + (error = null)}> + {error} + + + {:else if allComplete} + + {$_(selectedAttributes.length > 0 ? 'dataHygiene.allSetFiltered' : 'dataHygiene.allSet')} + + + {:else if !loading && books.length === 0} +

      + {$_('dataHygiene.noMissingBooks')} +

      + + {:else} +
      + + + + + + + + + + + + + {#each books as book (book.id)} + + + + + + + + + {/each} + +
      + 0} + onchange={toggleSelectAll} + aria-label={selectedBookIds.size === books.length ? $_('dataHygiene.deselectAll') : $_('dataHygiene.selectAll')} + /> + {$_('book.title')}{$_('dataHygiene.tableHeaderMissing')}
      +
      + toggleBook(book.id)} + aria-label={$_('common.search')} + /> +
      +
      {book.title} +
      + {#each book.missing_attributes as attr} + {$_(ATTRIBUTES.find(a => a.key === attr)?.labelKey ?? attr)} + {/each} +
      +
      +
      + + + {#if hasMore} +
      + +
      + {/if} + {/if} + + + {#if selectedCount > 0} +
      +
      + + {$_('dataHygiene.nSelected', { values: { count: selectedCount } })} + + + + + {#if batchField === 'page_count' || batchField === 'published_year'} + + {:else} + + {/if} + + +
      +
      + {/if} +
      + + + + + + diff --git a/frontend/src/routes/data-hygiene/page.test.ts b/frontend/src/routes/data-hygiene/page.test.ts new file mode 100644 index 0000000..17062dc --- /dev/null +++ b/frontend/src/routes/data-hygiene/page.test.ts @@ -0,0 +1,367 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; +import DataHygienePage from './+page.svelte'; +import type { HygieneMissingBook, HygieneAttribute } from '$lib/types'; + +const mockListMissing = vi.fn(); +const mockBatchUpdate = vi.fn(); +vi.mock('$lib/api', () => ({ + api: { + hygiene: { + listMissing: (...args: unknown[]) => mockListMissing(...args), + batchUpdate: (...args: unknown[]) => mockBatchUpdate(...args), + } + } +})); + +const mockToastAdd = vi.fn(); +vi.mock('$lib/toasts', () => ({ + toasts: { + add: (...args: unknown[]) => mockToastAdd(...args), + subscribe: vi.fn(), + } +})); + +vi.mock('$lib/errors', () => ({ + localizeError: (_err: unknown, _translate: unknown, fallback: string) => fallback, +})); + +function mockBook(id: number, overrides?: Partial): HygieneMissingBook { + return { + id, + title: `Book ${id}`, + author: id % 2 === 0 ? `Author ${id}` : null, + isbn: id % 3 === 0 ? `978${String(id).padStart(10, '0')}` : null, + publisher: id % 2 === 0 ? 'Publisher' : null, + published_year: null, + blurb: null, + language: null, + subtitle: null, + page_count: 0, + cover_url: null, + missing_attributes: overrides?.missing_attributes ?? ['author'], + ...overrides, + }; +} + +const emptyPerAttribute = { + author: 0, isbn: 0, publisher: 0, published_year: 0, + blurb: 0, language: 0, subtitle: 0, page_count: 0, cover_url: 0, +}; + +describe('DataHygienePage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockListMissing.mockResolvedValue({ + books: [], + total: 0, + total_missing_per_attribute: { ...emptyPerAttribute }, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders title and description', async () => { + render(DataHygienePage); + + await waitFor(() => { + expect(screen.getByText('Data Hygiene')).toBeInTheDocument(); + }); + expect(screen.getByText('Find and fix books with missing metadata in your library.')).toBeInTheDocument(); + }); + + it('shows loading state initially', () => { + mockListMissing.mockReturnValue(new Promise(() => {})); + render(DataHygienePage); + expect(screen.getByText('Checking your library...')).toBeInTheDocument(); + }); + + it('loads data on mount with default attributes', async () => { + render(DataHygienePage); + + await waitFor(() => { + expect(mockListMissing).toHaveBeenCalledTimes(1); + }); + expect(mockListMissing).toHaveBeenCalledWith({ + attributes: [ + 'author', 'isbn', 'publisher', 'published_year', + 'blurb', 'language', 'subtitle', 'page_count', 'cover_url', + ], + match: 'any', + offset: 0, + limit: 50, + }); + }); + + it('displays all 9 attribute chips', async () => { + render(DataHygienePage); + + const chips = await screen.findAllByRole('button'); + const attrChips = chips.filter(c => + /Author|ISBN|Publisher|Year|Description|Language|Subtitle|Page count|Cover/.test(c.textContent || '') + ); + expect(attrChips).toHaveLength(9); + }); + + it('shows per-attribute missing counts on chips', async () => { + mockListMissing.mockResolvedValue({ + books: [mockBook(1)], + total: 1, + total_missing_per_attribute: { + author: 5, isbn: 3, publisher: 0, published_year: 2, + blurb: 1, language: 0, subtitle: 0, page_count: 0, cover_url: 4, + }, + }); + + render(DataHygienePage); + + await waitFor(() => { + const chips = screen.getAllByText('Author').filter(el => el.closest('.btn')); + expect(chips.length).toBeGreaterThanOrEqual(1); + }); + + expect(screen.getByText('(5)')).toBeInTheDocument(); + expect(screen.getByText('(3)')).toBeInTheDocument(); + }); + + it('toggling attribute chip reloads data with updated attributes', async () => { + render(DataHygienePage); + + await waitFor(() => { + expect(mockListMissing).toHaveBeenCalledTimes(1); + }); + + const authorBtn = screen.getAllByText('Author').filter(el => el.closest('.btn'))[0]; + await fireEvent.click(authorBtn); + + await waitFor(() => { + expect(mockListMissing).toHaveBeenCalledTimes(2); + }); + + const secondCall = mockListMissing.mock.calls[1][0]; + expect(secondCall.attributes).toEqual(['author']); + expect(secondCall.offset).toBe(0); + }); + + it('toggle match mode between any and all', async () => { + render(DataHygienePage); + + await waitFor(() => { + expect(mockListMissing).toHaveBeenCalledTimes(1); + }); + + const matchBtn = screen.getByText('Match any'); + await fireEvent.click(matchBtn); + + await waitFor(() => { + expect(mockListMissing).toHaveBeenCalledTimes(2); + }); + expect(mockListMissing.mock.calls[1][0].match).toBe('all'); + + const allBtn = screen.getByText('Match all'); + await fireEvent.click(allBtn); + + await waitFor(() => { + expect(mockListMissing).toHaveBeenCalledTimes(3); + }); + expect(mockListMissing.mock.calls[2][0].match).toBe('any'); + }); + + it('renders book rows from API response', async () => { + mockListMissing.mockResolvedValue({ + books: [ + mockBook(1, { title: 'Dune', author: null }), + mockBook(2, { title: 'Neuromancer', author: 'William Gibson' }), + ], + total: 2, + total_missing_per_attribute: { + author: 2, isbn: 0, publisher: 0, published_year: 0, + blurb: 0, language: 0, subtitle: 0, page_count: 0, cover_url: 0, + }, + }); + + render(DataHygienePage); + + await waitFor(() => { + expect(screen.getByText('Dune')).toBeInTheDocument(); + }); + expect(screen.getByText('Neuromancer')).toBeInTheDocument(); + }); + + it('shows missing attribute badges per book', async () => { + mockListMissing.mockResolvedValue({ + books: [mockBook(1, { missing_attributes: ['author', 'isbn'] })], + total: 1, + total_missing_per_attribute: { + author: 1, isbn: 1, publisher: 0, published_year: 0, + blurb: 0, language: 0, subtitle: 0, page_count: 0, cover_url: 0, + }, + }); + + render(DataHygienePage); + + await waitFor(() => { + expect(screen.getByText('Book 1')).toBeInTheDocument(); + }); + + const badges = screen.getAllByText('Author').filter( + el => el.closest('.badge') + ); + expect(badges.length).toBeGreaterThanOrEqual(1); + }); + + it('loads more books on load more click', async () => { + const firstPage = Array.from({ length: 50 }, (_, i) => + mockBook(i + 1, { missing_attributes: ['author'] }) + ); + + mockListMissing.mockResolvedValueOnce({ + books: firstPage, + total: 60, + total_missing_per_attribute: { ...emptyPerAttribute, author: 60 }, + }); + + render(DataHygienePage); + + await waitFor(() => { + expect(screen.getByText('Book 1')).toBeInTheDocument(); + expect(screen.queryByText('Book 51')).not.toBeInTheDocument(); + }); + + mockListMissing.mockResolvedValueOnce({ + books: [mockBook(51, { missing_attributes: ['author'] })], + total: 60, + total_missing_per_attribute: { ...emptyPerAttribute, author: 60 }, + }); + + const loadMoreBtn = screen.getByText(/load more/i); + await fireEvent.click(loadMoreBtn); + + await waitFor(() => { + expect(screen.getByText('Book 51')).toBeInTheDocument(); + }); + expect(mockListMissing).toHaveBeenCalledTimes(2); + }); + + it('hides load more when all books loaded', async () => { + mockListMissing.mockResolvedValue({ + books: [mockBook(1, { missing_attributes: ['author'] })], + total: 1, + total_missing_per_attribute: { ...emptyPerAttribute, author: 1 }, + }); + + render(DataHygienePage); + + await waitFor(() => { + expect(screen.getByText('Book 1')).toBeInTheDocument(); + }); + + expect(screen.queryByText(/load more/i)).not.toBeInTheDocument(); + }); + + it('shows all-complete success alert when no missing books', async () => { + render(DataHygienePage); + + await waitFor(() => { + expect(screen.getByText('Your library is in great shape! All books have complete metadata.')).toBeInTheDocument(); + }); + }); + + it('shows error alert when API call fails', async () => { + mockListMissing.mockRejectedValue(new Error('Network error')); + + render(DataHygienePage); + + await waitFor(() => { + expect(screen.getByText('Failed to load data.')).toBeInTheDocument(); + }); + }); + + it('dismisses error alert', async () => { + mockListMissing.mockRejectedValue(new Error('Network error')); + + render(DataHygienePage); + + await waitFor(() => { + expect(screen.getByText('Failed to load data.')).toBeInTheDocument(); + }); + + const closeBtn = screen.getByLabelText('Close'); + await fireEvent.click(closeBtn); + + expect(screen.queryByText('Failed to load data.')).not.toBeInTheDocument(); + }); + + it('shows filtered success message when attributes selected', async () => { + let apiCallCount = 0; + mockListMissing.mockImplementation(() => { + apiCallCount++; + if (apiCallCount > 1) { + return Promise.resolve({ + books: [], + total: 0, + total_missing_per_attribute: { ...emptyPerAttribute }, + }); + } + return Promise.resolve({ + books: [], + total: 0, + total_missing_per_attribute: { ...emptyPerAttribute }, + }); + }); + + render(DataHygienePage); + + await waitFor(() => { + expect(mockListMissing).toHaveBeenCalledTimes(1); + }); + + const chipBtns = screen.getAllByText('Author').filter( + el => el.closest('.btn') + ); + await fireEvent.click(chipBtns[0]); + + await waitFor(() => { + expect(mockListMissing).toHaveBeenCalledTimes(2); + }); + + await waitFor(() => { + expect(screen.getByText('Your library is in great shape! All books have complete metadata for the selected attributes.')).toBeInTheDocument(); + }); + }); + + it('shows empty message when filter yields no results', async () => { + mockListMissing.mockResolvedValue({ + books: [mockBook(1)], + total: 1, + total_missing_per_attribute: { ...emptyPerAttribute, author: 1 }, + }); + + render(DataHygienePage); + + await waitFor(() => { + expect(screen.getByText('Book 1')).toBeInTheDocument(); + }); + + mockListMissing.mockResolvedValue({ + books: [], + total: 0, + total_missing_per_attribute: { ...emptyPerAttribute }, + }); + + const isbnBtn = Array.from(document.querySelectorAll('.btn')).find( + btn => btn.textContent?.trim().startsWith('ISBN') + ); + expect(isbnBtn).toBeTruthy(); + await fireEvent.click(isbnBtn!); + + await waitFor(() => { + expect(mockListMissing).toHaveBeenCalledTimes(2); + }); + + const params = mockListMissing.mock.calls[1][0]; + expect(params.attributes).toContain('isbn'); + }); +}); diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte index 90ca1b0..e59fb29 100644 --- a/frontend/src/routes/library/+page.svelte +++ b/frontend/src/routes/library/+page.svelte @@ -1,29 +1,31 @@ - + + + {searchQuery ? `${searchQuery} - ` : ''}{$_('app.title')} + + +
      + +
      + + +
      + + + + {#if searchQuery.trim().length > 0} + + {/if} +
      +
      + + + {#if searchQuery.trim() && !loading} +
      + {#if totalCount > 0} + {numberFormatter.format(totalCount)} + {$_('search.resultsCount', { values: { count: totalCount } })} + {:else} + {$_('search.noResults')} + {/if} +
      + {/if} + + + {#if loading && books.length === 0} +
      + +
      + {:else if books.length > 0} +
      + {#each books as book (book.id)} + + {/each} +
      + + + {#if hasMore} +
      + +
      + {/if} + {:else if searchQuery.trim() && !loading} +
      + +

      {$_('search.noResultsFor', { values: { query: searchQuery } })}

      +

      {$_('search.tryDifferentQuery')}

      +
      + {/if} +
      + + + + diff --git a/frontend/src/routes/search/page.test.ts b/frontend/src/routes/search/page.test.ts new file mode 100644 index 0000000..aaaec77 --- /dev/null +++ b/frontend/src/routes/search/page.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; +import SearchPage from './+page.svelte'; +import type { Book } from '$lib/types'; + +const mockPage = vi.hoisted(() => { + const subscribers = new Set<(value: unknown) => void>(); + let state = { url: new URL('http://localhost:5173/search'), params: {}, route: { id: null } }; + + return { + subscribe(run: (value: unknown) => void) { + run(state); + subscribers.add(run); + return () => subscribers.delete(run); + }, + setUrl(url: string) { + state = { url: new URL(url), params: {}, route: { id: null } }; + subscribers.forEach((fn) => fn(state)); + } + }; +}); + +vi.mock('$app/stores', () => ({ + page: { subscribe: mockPage.subscribe }, + navigating: { subscribe: vi.fn() } +})); + +const mockGoto = vi.fn(); +vi.mock('$app/navigation', () => ({ + goto: (...args: unknown[]) => mockGoto(...args), + beforeNavigate: () => {}, + afterNavigate: () => {}, + onNavigate: () => () => {} +})); + +const mockBooksList = vi.fn(); +vi.mock('$lib/api', () => ({ + api: { + books: { + list: (...args: unknown[]) => mockBooksList(...args) + } + } +})); + +function createMockBook(id: number, overrides?: Partial): Book { + return { + id, + title: `Book ${id}`, + subtitle: null, + author: 'Test Author', + isbn: null, + cover_url: null, + publisher: null, + published_year: null, + page_count: 100, + language: null, + tags: null, + notes: null, + blurb: null, + rating: null, + reading_status: 'want_to_read', + date_added: '2025-01-01T00:00:00Z', + date_started: null, + date_finished: null, + ...overrides + }; +} + +describe('SearchPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPage.setUrl('http://localhost:5173/search'); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders search input with placeholder', () => { + render(SearchPage); + expect(screen.getByPlaceholderText('Search books...')).toBeInTheDocument(); + }); + + it('reads q from URL and performs search on mount', async () => { + mockPage.setUrl('http://localhost:5173/search?q=Dune'); + + mockBooksList.mockResolvedValue({ + total: 1, + books: [createMockBook(1, { title: 'Dune', author: 'Frank Herbert' })] + }); + + render(SearchPage); + + await waitFor(() => { + expect(mockBooksList).toHaveBeenCalledWith( + expect.objectContaining({ q: 'Dune', offset: 0, limit: 40 }) + ); + }); + }); + + it('displays search results', async () => { + mockPage.setUrl('http://localhost:5173/search?q=Dune'); + + mockBooksList.mockResolvedValue({ + total: 1, + books: [createMockBook(1, { title: 'Dune', author: 'Frank Herbert' })] + }); + + render(SearchPage); + + await waitFor(() => { + expect(screen.getByText('Dune')).toBeInTheDocument(); + expect(screen.getByText('Frank Herbert')).toBeInTheDocument(); + }); + }); + + it('shows results count', async () => { + mockPage.setUrl('http://localhost:5173/search?q=Dune'); + + mockBooksList.mockResolvedValue({ + total: 3, + books: [ + createMockBook(1, { title: 'Dune' }), + createMockBook(2, { title: 'Dune Messiah' }), + createMockBook(3, { title: 'Children of Dune' }) + ] + }); + + render(SearchPage); + + await waitFor(() => { + expect(screen.getByText('3')).toBeInTheDocument(); + }); + }); + + it('shows no results message when API returns empty', async () => { + mockPage.setUrl('http://localhost:5173/search?q=xyzzy'); + + mockBooksList.mockResolvedValue({ + total: 0, + books: [] + }); + + render(SearchPage); + + await waitFor(() => { + expect(screen.getByText('No results found for "xyzzy"')).toBeInTheDocument(); + }); + }); + + it('shows loading spinner while searching', async () => { + mockPage.setUrl('http://localhost:5173/search?q=Dune'); + + mockBooksList.mockReturnValue(new Promise(() => {})); + + render(SearchPage); + + await waitFor(() => { + expect(document.querySelector('.loading-spinner')).toBeInTheDocument(); + }); + }); + + it('clears input and results when clear button clicked', async () => { + mockPage.setUrl('http://localhost:5173/search?q=test'); + + mockBooksList.mockResolvedValue({ + total: 1, + books: [createMockBook(1, { title: 'Test Book' })] + }); + + render(SearchPage); + + await waitFor(() => { + expect(screen.getByText('Test Book')).toBeInTheDocument(); + }); + + const clearBtn = screen.getByRole('button', { name: /clear/i }); + await fireEvent.click(clearBtn); + + const input = screen.getByPlaceholderText('Search books...') as HTMLInputElement; + expect(input).toHaveValue(''); + expect(screen.queryByText('Test Book')).not.toBeInTheDocument(); + }); + + it('debounces search while typing', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + render(SearchPage); + const input = screen.getByPlaceholderText('Search books...'); + + mockBooksList.mockResolvedValue({ total: 0, books: [] }); + + await fireEvent.input(input, { target: { value: 'd' } }); + await vi.advanceTimersByTimeAsync(200); + await fireEvent.input(input, { target: { value: 'du' } }); + await vi.advanceTimersByTimeAsync(200); + await fireEvent.input(input, { target: { value: 'dune' } }); + + expect(mockBooksList).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(300); + + expect(mockBooksList).toHaveBeenCalledTimes(1); + expect(mockBooksList).toHaveBeenCalledWith( + expect.objectContaining({ q: 'dune', offset: 0 }) + ); + + vi.useRealTimers(); + }); + + it('navigates to search URL on Enter', async () => { + render(SearchPage); + const input = screen.getByPlaceholderText('Search books...'); + await fireEvent.input(input, { target: { value: 'Neuromancer' } }); + + await fireEvent.keyDown(input, { key: 'Enter' }); + + expect(mockGoto).toHaveBeenCalledWith( + '/search?q=Neuromancer', + expect.objectContaining({ replaceState: true }) + ); + }); + + it('loads more results when load more is clicked', async () => { + const firstPage = Array.from({ length: 40 }, (_, i) => + createMockBook(i + 1, { title: `Book ${i + 1}` }) + ); + const secondPage = Array.from({ length: 40 }, (_, i) => + createMockBook(41 + i, { title: `Book ${41 + i}` }) + ); + + mockBooksList.mockResolvedValueOnce({ total: 80, books: firstPage }); + mockBooksList.mockResolvedValueOnce({ total: 80, books: secondPage }); + + mockPage.setUrl('http://localhost:5173/search?q=book'); + + render(SearchPage); + + await waitFor(() => { + expect(screen.getByText('Book 1')).toBeInTheDocument(); + }); + expect(screen.queryByText('Book 41')).not.toBeInTheDocument(); + + expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('button', { name: /load more/i })); + + await waitFor(() => { + expect(screen.getByText('Book 41')).toBeInTheDocument(); + }); + expect(screen.getByText('Book 1')).toBeInTheDocument(); + + expect(mockBooksList).toHaveBeenCalledTimes(2); + expect(mockBooksList).toHaveBeenLastCalledWith( + expect.objectContaining({ offset: 40, limit: 40, q: 'book' }) + ); + }); + + it('shows back button', () => { + render(SearchPage); + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/setup/+page.svelte b/frontend/src/routes/setup/+page.svelte index 5685854..adf9ec9 100644 --- a/frontend/src/routes/setup/+page.svelte +++ b/frontend/src/routes/setup/+page.svelte @@ -1,4 +1,6 @@