diff --git a/.dockerignore b/.dockerignore index d32c9e5..28eee55 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,6 @@ __pycache__ *.log .env htmlcov +frontend/node_modules +frontend/dist +planning diff --git a/.env.example b/.env.example index 82480f1..6b3a78b 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,33 @@ POSTGRES_DB=scholar POSTGRES_USER=scholar -POSTGRES_PASSWORD=scholar -POSTGRES_PORT=5432 +POSTGRES_PASSWORD=change-me DATABASE_URL=postgresql+asyncpg://scholar:scholar@db:5432/scholar # Optional override. If empty, tests derive "_test". TEST_DATABASE_URL= +SCHOLARR_IMAGE=justinzeus/scholarr:latest APP_NAME=scholarr APP_HOST=0.0.0.0 APP_PORT=8000 APP_HOST_PORT=8000 -APP_RELOAD=1 +APP_RELOAD=0 +FRONTEND_ENABLED=1 +FRONTEND_DIST_DIR=/app/frontend/dist +FRONTEND_HOST_PORT=5173 +CHOKIDAR_USEPOLLING=1 +VITE_DEV_API_PROXY_TARGET=http://app:8000 MIGRATE_ON_START=1 -SESSION_SECRET_KEY=replace-with-a-long-random-secret -SESSION_COOKIE_SECURE=0 +SESSION_SECRET_KEY=replace-with-a-long-random-secret-at-least-32-characters +SESSION_COOKIE_SECURE=1 LOGIN_RATE_LIMIT_ATTEMPTS=5 LOGIN_RATE_LIMIT_WINDOW_SECONDS=60 LOG_LEVEL=INFO LOG_FORMAT=console LOG_REQUESTS=1 LOG_UVICORN_ACCESS=0 -LOG_REQUEST_SKIP_PATHS=/healthz,/static/ +LOG_REQUEST_SKIP_PATHS=/healthz LOG_REDACT_FIELDS= SCHEDULER_ENABLED=1 SCHEDULER_TICK_SECONDS=60 @@ -35,6 +40,16 @@ INGESTION_CONTINUATION_BASE_DELAY_SECONDS=120 INGESTION_CONTINUATION_MAX_DELAY_SECONDS=3600 INGESTION_CONTINUATION_MAX_ATTEMPTS=6 SCHEDULER_QUEUE_BATCH_SIZE=10 +SCHOLAR_IMAGE_UPLOAD_DIR=/var/lib/scholarr/uploads +SCHOLAR_IMAGE_UPLOAD_MAX_BYTES=2000000 +SCHOLAR_NAME_SEARCH_ENABLED=1 +SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS=21600 +SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS=300 +SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES=512 +SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS=8.0 +SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS=2.0 +SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD=1 +SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS=1800 BOOTSTRAP_ADMIN_ON_START=0 BOOTSTRAP_ADMIN_EMAIL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5e6592..2aceb0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,3 +51,85 @@ jobs: - name: Integration tests run: uv run pytest -m integration + frontend-quality: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: API contract drift check + run: python3 scripts/check_frontend_api_contract.py + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install frontend dependencies + working-directory: frontend + run: npm install + + - name: Frontend typecheck + working-directory: frontend + run: npm run typecheck + + - name: Frontend unit tests + working-directory: frontend + run: npm run test:run + + - name: Frontend build + working-directory: frontend + run: npm run build + + docker-publish: + runs-on: ubuntu-latest + needs: + - test + - frontend-quality + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Derive image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: justinzeus/scholarr + tags: | + type=raw,value=latest + type=sha,format=short,prefix=sha- + + - name: Build and push multi-arch image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + target: prod + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index f0a8f4e..ef21cff 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ htmlcov/ .uv-cache/ .env *.log +planning/ +frontend/node_modules/ +frontend/dist/ diff --git a/AGENTS.MD b/AGENTS.MD new file mode 100644 index 0000000..4a9cbec --- /dev/null +++ b/AGENTS.MD @@ -0,0 +1,314 @@ +# scholarr Agent Handbook + +This file is the consolidated source of truth for project scope, constraints, scrape contract, and UI implementation flow. + +## 1. Product Objective + +- Build a self-hosted scholar tracking system ("scholarr") with reliable, low-cost scraping and clear, actionable UI. +- Keep the MVP scope intentionally small. +- Prioritize reliability and ease of use over advanced/fancy processing. + +## 2. Locked Constraints and Preferences + +- Multi-user system with strict tenant isolation. +- Users are admin-created only. +- Users can change their own password. +- Admin features are shown only to admin users. +- Same-origin cookie session model with CSRF protection. +- Container-first development workflow (`docker compose`, `uv`). +- Test while developing; avoid shipping untested flows. +- Keep backend modular and DRY. +- UI is being rebuilt from scratch against API contracts. + +## 3. Current Backend Architecture + +- Runtime: Python + FastAPI. +- DB: PostgreSQL + SQLAlchemy + Alembic migrations. +- Scheduler: background scheduler + continuation queue processing. +- Auth: + - Argon2 password hashing. + - Session cookie (`HttpOnly`, `SameSite=Lax`, secure flag configurable). + - CSRF required for unsafe methods via `X-CSRF-Token`. +- Logging: + - structured request logging with request IDs. + - redaction controls for sensitive fields. + +## 4. Scraping Contract (Probe-Based) + +Status: sufficient for MVP implementation with graceful degradation. + +Target endpoint: +- `https://scholar.google.com/citations?hl=en&user=` + +Primary selectors: +- Row: `tr.gsc_a_tr` +- Title/link: `a.gsc_a_at` +- Citation count: `a.gsc_a_ac` +- Year: `span.gsc_a_h` (fallback regex on year cell text) +- Metadata lines: first/second `div.gs_gray` -> authors/venue +- Cluster ID: from `citation_for_view=:` in title URL + +Required parser states: +- `ok` +- `no_results` +- `blocked_or_captcha` +- `layout_changed` +- `network_error` + +Required page flags: +- `has_show_more_button` +- `articles_range` + +Quality assumptions from probe: +- title/cluster_id/citation/authors coverage observed near 100% +- year and venue may be missing and must remain nullable + +Guardrails: +- Never crash the full run when one scholar fails. +- Persist structured failure/debug information for diagnosis. +- Handle inaccessible/redirected IDs as states, not exceptions. + +## 5. Current API Scope + +Base path: `/api/v1` + +Envelope model: +- success: `{"data": ..., "meta": {"request_id": "..."}}` +- error: `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` + +Auth/session: +- `GET /api/v1/auth/csrf` +- `POST /api/v1/auth/login` +- `GET /api/v1/auth/me` +- `POST /api/v1/auth/change-password` +- `POST /api/v1/auth/logout` + +Admin users: +- `GET /api/v1/admin/users` +- `POST /api/v1/admin/users` +- `PATCH /api/v1/admin/users/{id}/active` +- `POST /api/v1/admin/users/{id}/reset-password` + +Scholars: +- `GET /api/v1/scholars` +- `POST /api/v1/scholars` +- `GET /api/v1/scholars/search` +- `PATCH /api/v1/scholars/{id}/toggle` +- `PUT /api/v1/scholars/{id}/image/url` +- `POST /api/v1/scholars/{id}/image/upload` +- `GET /api/v1/scholars/{id}/image/upload` +- `DELETE /api/v1/scholars/{id}/image` +- `DELETE /api/v1/scholars/{id}` + +Settings: +- `GET /api/v1/settings` +- `PUT /api/v1/settings` + +Runs/diagnostics/queue: +- `GET /api/v1/runs` +- `GET /api/v1/runs/{id}` +- `POST /api/v1/runs/manual` (`Idempotency-Key` supported) +- `GET /api/v1/runs/queue/items` +- `POST /api/v1/runs/queue/{id}/retry` +- `POST /api/v1/runs/queue/{id}/drop` +- `DELETE /api/v1/runs/queue/{id}` + +Publications: +- `GET /api/v1/publications` +- `POST /api/v1/publications/mark-read` +- `POST /api/v1/publications/mark-all-read` + +Ops: +- `GET /healthz` + +## 5.1 Runtime Semantics (Locked) + +Manual run idempotency: +- `Idempotency-Key` is optional on `POST /api/v1/runs/manual`. +- Idempotency scope is `(user_id, idempotency_key)`. +- Replaying the same key for the same user returns the same run payload after completion. +- If the keyed run is still in progress, respond with `409 run_in_progress` and include `run_id`. +- Enforcement is database-backed, not best-effort in-memory logic. +- Frontend triggers generate keys internally; key input is not user-facing. + +Continuation queue attempt policy: +- `attempt_count` is a failed-attempt budget, not a total execution counter. +- Increment attempts only when a continuation execution fails to make progress. +- Reset attempts to `0` after successful progress (success or resumable partial progress). +- Drop queue items only after failed-attempt threshold is reached. + +Scholar no-change skip policy: +- Persist first-page fingerprint snapshots per scholar. +- If the first-page fingerprint is unchanged for a normal run (`cstart=0`), skip deep pagination/upsert work. +- Record run scholar result `state_reason` as `no_change_initial_page_signature`. + +Scholar create naming policy: +- Do not accept custom display names during scholar create. +- Hydrate names from scraped profile metadata. + +Scholar name-search safety policy: +- Treat name search as best-effort only. +- Apply single-flight execution, pacing/jitter, cache, and cooldown circuit breaker after repeated blocked responses. +- Prefer Scholar URL/ID adds for reliable ingestion onboarding. + +## 6. Required UI Behavior (MVP) + +Core UX entities: +- scholars +- publications (`all` and `new`) +- runs + run diagnostics +- queue visibility/actions +- settings/account +- admin users (role-gated) + +Critical semantics: +- Keep `is_new_in_latest_run` separate from `is_read`. +- First-time ingestion should not imply user read-state. +- Surface partial runs and warnings clearly. +- Show loading, empty, and error states explicitly. +- Support both light and dark modes with consistent status semantics. + +## 7. UI Information Architecture + +Public: +- Login + +Authenticated: +- Dashboard +- Scholars +- Publications +- Settings +- Admin Users (admin only) + +## 8. End-to-End UI Flow Plan + +## 8.1 Session bootstrap + +1. On load call `GET /api/v1/auth/csrf`. +2. Call `GET /api/v1/auth/me`. +3. Route to login or dashboard based on auth state. + +## 8.2 Login/logout + +1. Login form submits `POST /api/v1/auth/login` with `X-CSRF-Token`. +2. Store CSRF token from response for future unsafe requests. +3. Logout calls `POST /api/v1/auth/logout`. + +## 8.3 Dashboard +1. Fetch latest publications summary from `GET /api/v1/publications?mode=new&limit=20`. +2. Fetch recent runs from `GET /api/v1/runs?limit=5`. +3. Fetch queue status from `GET /api/v1/runs/queue/items?limit=200` (aggregate in UI). +4. Show: + - new publications count (`new_count`) + - total tracked publications (`total_count`) + - latest run status/started time + - queue badges (`queued`, `retrying`, `dropped`) +5. Admin users additionally see a diagnostics shortcut block to full runs/queue page. +6. Every dashboard error panel must display request-id for support. + + +## 8.4 Scholars + +1. List via `GET /api/v1/scholars`. +2. Add/toggle/delete actions mapped to scholar endpoints. +3. Search by name via `GET /api/v1/scholars/search` with candidate add actions. +4. Profile image controls: + - scraped image fallback from Scholar metadata + - custom URL override + - image upload + reset to scraped/default +5. Per-row quick links: + - all publications filtered by scholar + - new publications filtered by scholar + +## 8.5 Publications + +1. Default view mode is `new` (`GET /api/v1/publications?mode=new`). +2. Support mode toggle: + - `new`: scoped to latest completed run + - `all`: full history +3. Support scholar filter via `scholar_profile_id` query param. +4. Render both badges independently: + - `is_new_in_latest_run` + - `is_read` +5. "Mark all read" action calls `POST /api/v1/publications/mark-all-read` with CSRF. +6. Show explicit empty states: + - no publications tracked + - no new publications in latest run + - filtered scholar has no items +7. Surface run recency context when mode is `new` (latest completed run semantics). + +## 8.6 Runs and diagnostics (admin) + +1. List runs from `GET /api/v1/runs`. +2. Run details from `GET /api/v1/runs/{id}`: + - scholar result state/reason + - warnings + - page logs + attempt logs + - debug metadata +3. Queue operations from queue endpoints (`retry`, `drop`, `clear`). + +## 8.7 Settings + account + +1. User ingest settings from `/api/v1/settings`. +2. Password change via `/api/v1/auth/change-password`. + +## 8.8 Admin user management + +1. Show section only when `current_user.is_admin=true`. +2. Use admin user endpoints for create/activate/deactivate/reset password. + +## 9. Development Method + +- API-contract-first frontend. +- Vertical slices (complete flow before moving on). +- Shared API client module: + - always send credentials + - inject CSRF header for unsafe methods + - normalize envelope parsing and error handling +- Keep frontend modules separate (`auth`, `scholars`, `publications`, `runs`, `settings`, `admin`). +- Write tests while implementing each slice. + +## 10. Definition of Done (UI Readiness) + +- All main flows above implemented and mapped to existing API endpoints. +- Role-gated admin experience enforced in UI. +- Distinct new-vs-read publication semantics visible. +- Run/queue diagnostics visible and actionable. +- Reliable loading/empty/error states with request-id surfaced for support. +- No dependence on removed server-rendered UI layer. + +## 11. Open Items (Known Future Work) + +- API contract freeze/changelog discipline for external UI clients. +- Additional API-first smoke coverage for end-to-end UI critical flows. +- Optional background image enrichment/refresh jobs for already-tracked scholars. + +## 12. UI Rebuild Program Artifacts + +- Phase 0 through Phase 3 decisions are consolidated into this handbook and `README.md`. +- Local scratch notes, probes, and temporary planning artifacts are intentionally not tracked in git. + +## 13. Frontend Scaffold Status (Implemented) + +- Frontend scaffold path: `frontend/` +- Implemented core: + - App shell + routing + auth/role route guards + - Shared API client with envelope parsing, credentials, and CSRF header injection + - Pinia stores for auth, theme, and global UI errors + - Tailwind-first design foundation with light/dark themes and pre-paint theme initialization + - Page shells for login, dashboard, scholars, publications, runs, run detail, settings, and admin users + - Initial feature modules under `frontend/src/features/*` mapped to current API endpoints +- Deployment boundary: + - No in-repo reverse-proxy implementation + - Frontend edge/proxy routing is managed externally (for example, Caddy) + +## 14. Frontend Quality Gates (Phase 3 Implemented) + +- CI now includes frontend quality gates in `.github/workflows/ci.yml`: + - API contract drift check (`scripts/check_frontend_api_contract.py`) + - frontend typecheck (`npm run typecheck`) + - frontend unit tests (`npm run test:run`) + - frontend build (`npm run build`) +- Frontend test harness: + - `vitest` configured in `frontend/vitest.config.ts` + - baseline tests in `frontend/src/lib/api/envelope.test.ts` and `frontend/src/lib/api/errors.test.ts` diff --git a/Dockerfile b/Dockerfile index bfa7e00..22dc9b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,13 @@ +FROM node:20-alpine AS frontend-builder + +WORKDIR /frontend + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + FROM python:3.12-slim AS base ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -33,7 +43,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ UV_PROJECT_ENVIRONMENT=/opt/venv \ PATH="/opt/venv/bin:$PATH" \ APP_RELOAD=0 \ - UVICORN_WORKERS=1 + UVICORN_WORKERS=1 \ + FRONTEND_ENABLED=1 \ + FRONTEND_DIST_DIR=/app/frontend/dist WORKDIR /app @@ -51,4 +63,5 @@ COPY alembic ./alembic RUN uv sync --frozen COPY scripts ./scripts +COPY --from=frontend-builder /frontend/dist /app/frontend/dist ENTRYPOINT ["/bin/sh", "/app/scripts/entrypoint.sh"] diff --git a/README.md b/README.md index 21c0da2..d5a847b 100644 --- a/README.md +++ b/README.md @@ -1,206 +1,147 @@ # scholarr -Container-first, self-hosted scholar tracking in the spirit of the `*arr` ecosystem, with strict multi-user isolation and an intentionally small operational footprint. +
-## Current Scope +API-first, self-hosted scholar tracking in the spirit of the `*arr` ecosystem. -- Admin-managed users only (no public signup) -- Session auth with CSRF protection and login rate limiting -- Per-user scholar tracking list (add, enable/disable, delete) -- Per-user automation settings -- Manual per-user ingestion runs from dashboard -- Per-user run history and "new since last run" publication stream -- Publications library view with `new` and `all` modes plus scholar filtering -- Dedicated run diagnostics pages (`/runs`, `/runs/{id}`) with failed-only filtering -- Continuation queue visibility + controls on runs page (`retry`, `drop`, `clear`) -- Baseline-aware discovery tracking (`new` = discovered in latest completed run) -- Read state is user-controlled (`Mark All Read`), including baseline items -- Multi-page profile ingestion (`cstart`) to capture full publication lists -- Dashboard rendered via a presentation-layer view model and section template registry -- Structured application logging with request IDs and field redaction -- Ingestion overlap guard via Postgres advisory locks -- Network-error retry support for ingestion attempts -- Automatic continuation queue for resumable/limit-hit scholar runs -- Background scheduler for per-user automatic runs -- User self-service password changes -- Admin user management (create users, activate/deactivate, reset passwords) -- PostgreSQL + Alembic migrations + containerized test workflow -- Versioned backend API (`/api/v1`) for frontend decoupling (same-origin cookie session) +[![CI](https://img.shields.io/github/actions/workflow/status/justinzeus/scholarr/ci.yml?style=for-the-badge)](https://github.com/justinzeus/scholarr/actions/workflows/ci.yml) +[![Docker Pulls](https://img.shields.io/docker/pulls/justinzeus/scholarr?style=for-the-badge&logo=docker)](https://hub.docker.com/r/justinzeus/scholarr) +[![Docker Image](https://img.shields.io/badge/docker-justinzeus%2Fscholarr-2496ED?style=for-the-badge&logo=docker&logoColor=white)](https://hub.docker.com/r/justinzeus/scholarr) -## Tech Stack +
-- Python 3.12+ -- FastAPI -- PostgreSQL 15+ -- SQLAlchemy 2 + Alembic -- Jinja2 templates + modular theme tokens -- `uv` for dependency and runtime workflow -- Docker / Docker Compose for development and CI parity +## What This Includes -## Quick Start +- Multi-user accounts with admin-managed lifecycle +- Cookie session auth with CSRF enforcement +- Scholar CRUD + name-search discovery + profile image management +- Per-user ingestion settings and scheduler +- Manual runs with idempotency behavior +- Run history and continuation queue diagnostics/actions +- Publications workflow (`new` / `all`, mark-selected-read, mark-all-read) +- Vue 3 + Vite frontend served from the same container image as the API -1. Copy environment defaults: +## Docker Image + +- Image: `justinzeus/scholarr` +- Published by GitHub Actions on every push to `main` +- Architectures: `linux/amd64`, `linux/arm64` +- Tags: + - `latest` + - `sha-` + +## Quick Deploy (Copy Compose + Fill Env) + +1. Copy `docker-compose.yml` and `.env.example` into your deployment directory. +2. Create `.env` from `.env.example`. +3. Set at minimum: + - `SESSION_SECRET_KEY` + - `POSTGRES_PASSWORD` +4. Pull and start: ```bash -cp .env.example .env +docker compose pull +docker compose up -d ``` -2. Bootstrap the first admin on startup (recommended for first run): +5. Open: + +- App + API: `http://localhost:8000` +- Health check: `http://localhost:8000/healthz` + +The SPA and API are same-origin by default in this deployment model. + +## Required Environment Variables + +| Variable | Required | Purpose | +| --- | --- | --- | +| `SESSION_SECRET_KEY` | Yes | Session signing key. Use a long random value. | +| `POSTGRES_PASSWORD` | Yes | Database password for the bundled Postgres service. | + +Optional startup bootstrap: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `BOOTSTRAP_ADMIN_ON_START` | `0` | Auto-create admin on app start. | +| `BOOTSTRAP_ADMIN_EMAIL` | empty | Admin email for bootstrap. | +| `BOOTSTRAP_ADMIN_PASSWORD` | empty | Admin password for bootstrap. | +| `BOOTSTRAP_ADMIN_FORCE_PASSWORD` | `0` | Force-reset bootstrap admin password if exists. | + +## Ports + +| Port | Service | Description | +| --- | --- | --- | +| `8000` | `app` | scholarr API + frontend | + +## Volumes + +| Volume | Container Path | Purpose | +| --- | --- | --- | +| `postgres_data` | `/var/lib/postgresql/data` | Postgres persistence | +| `scholar_uploads` | `/var/lib/scholarr/uploads` | Scholar image upload persistence | + +## Upgrade ```bash -export BOOTSTRAP_ADMIN_ON_START=1 -export BOOTSTRAP_ADMIN_EMAIL=admin@example.com -export BOOTSTRAP_ADMIN_PASSWORD=change-me-now +docker compose pull +docker compose up -d ``` -3. Start the stack: +## Development Workflow + +Default `docker-compose.yml` is deployment-oriented (prebuilt image). + +For local development with source mounts + Vite dev server: ```bash -docker compose up --build +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build ``` -4. Open: +Then open: -- Login: `http://localhost:8000/login` -- Dashboard: `http://localhost:8000/` -- Healthcheck: `http://localhost:8000/healthz` +- API: `http://localhost:8000` +- Frontend dev server: `http://localhost:5173` -5. After first successful admin login, disable auto-bootstrap: +## Test and Quality Commands + +Backend: ```bash -export BOOTSTRAP_ADMIN_ON_START=0 +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app uv run pytest tests/unit +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app uv run pytest -m integration ``` -## Admin Bootstrap (Manual) - -You can create/update an admin account without restarting the app: +Frontend: ```bash -docker compose run --rm app uv run python scripts/bootstrap_admin.py \ - --email admin@example.com \ - --password change-me-now \ - --force-password +cd frontend +npm install +npm run typecheck +npm run test:run +npm run build ``` -Notes: -- If the user does not exist, this creates an active admin account. -- If the user exists, it ensures admin + active flags. -- `--force-password` resets the password for existing users. - -## Test Workflow - -- Integration/smoke tests run against `TEST_DATABASE_URL`. -- If `TEST_DATABASE_URL` is empty, test runs derive an isolated database from `DATABASE_URL` by appending `_test`. -- This keeps app data in `DATABASE_URL` untouched during test runs. -- Parser regression tests are pinned to captured real-world fixture pages in `tests/fixtures/scholar/regression`. - -- Unit tests: +Contract drift check: ```bash -docker compose run --rm app uv run pytest tests/unit +python3 scripts/check_frontend_api_contract.py ``` -- Integration tests: - -```bash -docker compose run --rm app uv run pytest -m integration -``` - -- Smoke checks (container + DB + migration sanity): - -```bash -./scripts/smoke_compose.sh -``` - -CI runs migration, unit, and integration checks on push/PR via `.github/workflows/ci.yml`. - -## Scholar Ingestion Notes - -- Current ingestion targets public profile pages: `/citations?user=`. -- Runs are intentionally conservative and prioritize reliability over aggressive scraping. -- Ingestion follows pagination (`cstart`) to collect all reachable profile pages. -- If pagination cannot be completed (max pages hit, cursor stall, or page-state failure), the scholar result is marked partial with explicit debug context. -- Resumable partial/failure states are automatically queued and retried by the scheduler with bounded backoff. -- Queue items keep explicit status (`queued`, `retrying`, `dropped`) and last-error context for UI diagnostics. -- Failed scholar attempts persist structured debug context in `crawl_runs.error_log` (state reason, fetch metadata, marker evidence, and compact response excerpt). -- Only resumable states are retried automatically (`network_error` and continuation-eligible pagination truncations); blocked/layout states are recorded immediately as failures. - -## API v1 (Frontend Prep) +## API Base - Base path: `/api/v1` -- Auth model: same-origin cookie session (no token auth added) -- CSRF model: required for unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`) via `X-CSRF-Token` header. -- Bootstrap CSRF via `GET /api/v1/auth/csrf` (works for anonymous and authenticated sessions). -- You can also fetch `csrf_token` from `GET /api/v1/auth/me` after login. -- Manual run idempotency is supported via `Idempotency-Key` header on `POST /api/v1/runs/manual`. -- Response envelopes: - - Success: `{"data": ..., "meta": {"request_id": "..."}}` - - Error: `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` -- Initial API domains exposed: - - `auth`: `/auth/csrf`, `/auth/login`, `/auth/me`, `/auth/change-password`, `/auth/logout` - - `admin users`: `/admin/users` (list/create), `/admin/users/{id}/active`, `/admin/users/{id}/reset-password` - - `scholars`: list/create/toggle/delete - - `settings`: get/update - - `runs`: list/detail/manual trigger + queue list/retry/drop/clear - - `publications`: list + mark-all-read -- Queue action semantics: - - `retry` is valid only for dropped items; retrying/queued states return `409`. - - `drop` returns the updated queue item (status `dropped`); dropping an already dropped item returns `409`. - - `clear` only works for dropped items and returns `{queue_item_id, previous_status, status="cleared"}`. +- Success envelope: + - `{"data": ..., "meta": {"request_id": "..."}}` +- Error envelope: + - `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` ## Logging -- Default output is concise console logs to stdout. -- Timestamp format is compact UTC: `YYYY-MM-DD HH:MM:SSZ`. -- Every request gets an `X-Request-ID` (propagated if caller provides one). -- Sensitive fields are redacted before log emission (`password`, `csrf_token`, `cookie`, etc.). -- Configure via env: - - `LOG_LEVEL` (default `INFO`) - - `LOG_FORMAT` (`console` or `json`, default `console`) - - `LOG_REQUESTS` (`1`/`0`, default `1`) - - `LOG_UVICORN_ACCESS` (`1`/`0`, default `0`) - - `LOG_REQUEST_SKIP_PATHS` (comma-separated prefixes, default `/healthz,/static/`) - - `LOG_REDACT_FIELDS` (comma-separated additional keys) - - `SCHEDULER_ENABLED` (`1`/`0`, default `1`) - - `SCHEDULER_TICK_SECONDS` (default `60`) - - `INGESTION_NETWORK_ERROR_RETRIES` (default `1`) - - `INGESTION_RETRY_BACKOFF_SECONDS` (default `1.0`) - - `INGESTION_MAX_PAGES_PER_SCHOLAR` (default `30`) - - `INGESTION_PAGE_SIZE` (default `100`) - - `INGESTION_CONTINUATION_QUEUE_ENABLED` (`1`/`0`, default `1`) - - `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` (default `120`) - - `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` (default `3600`) - - `INGESTION_CONTINUATION_MAX_ATTEMPTS` (default `6`) - - `SCHEDULER_QUEUE_BATCH_SIZE` (default `10`) +Important envs: -## Optional Local `uv` Workflow - -```bash -uv sync --extra dev -uv run pytest -uv run uvicorn app.main:app --reload -``` - -Update the lockfile after dependency changes: - -```bash -uv lock -``` - -## Theming - -- Theme registry: `app/theme.py` -- Semantic tokens: `app/static/theme.css` -- App styling: `app/static/app.css` -- Theme persistence: `app/static/theme.js` -- Dashboard presentation contract: `app/presentation/dashboard.py` - -## Project Layout - -```text -app/ FastAPI app, auth/security modules, templates, services, DB wiring, presentation view-models -app/web/ Router modules, shared web helpers, and request middleware -alembic/ Migration environment and versions -scripts/ Entrypoint, DB wait, bootstrap, smoke automation -tests/ Unit, integration, and smoke suites -``` +- `LOG_LEVEL` +- `LOG_FORMAT` (`console` or `json`) +- `LOG_REQUESTS` +- `LOG_UVICORN_ACCESS` +- `LOG_REQUEST_SKIP_PATHS` +- `LOG_REDACT_FIELDS` diff --git a/alembic/versions/20260217_0005_manual_run_idempotency.py b/alembic/versions/20260217_0005_manual_run_idempotency.py new file mode 100644 index 0000000..0375569 --- /dev/null +++ b/alembic/versions/20260217_0005_manual_run_idempotency.py @@ -0,0 +1,88 @@ +"""Add database-backed manual run idempotency key + +Revision ID: 20260217_0005 +Revises: 20260217_0004 +Create Date: 2026-02-17 16:20:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "20260217_0005" +down_revision: str | Sequence[str] | None = "20260217_0004" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +INDEX_NAME = "uq_crawl_runs_user_manual_idempotency_key" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns("crawl_runs")} + indexes = {index["name"] for index in inspector.get_indexes("crawl_runs")} + + if "idempotency_key" not in columns: + op.add_column( + "crawl_runs", + sa.Column("idempotency_key", sa.String(length=128), nullable=True), + ) + columns.add("idempotency_key") + + if "idempotency_key" in columns: + op.execute( + """ + UPDATE crawl_runs + SET idempotency_key = NULLIF(BTRIM(error_log #>> '{meta,idempotency_key}'), '') + WHERE trigger_type = 'manual' + AND idempotency_key IS NULL + """ + ) + # Preserve one winning row per (user_id, idempotency_key) before creating uniqueness. + op.execute( + """ + WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY user_id, idempotency_key + ORDER BY start_dt DESC, id DESC + ) AS rn + FROM crawl_runs + WHERE trigger_type = 'manual' + AND idempotency_key IS NOT NULL + ) + UPDATE crawl_runs AS runs + SET idempotency_key = NULL + FROM ranked + WHERE runs.id = ranked.id + AND ranked.rn > 1 + """ + ) + + if INDEX_NAME not in indexes: + op.create_index( + INDEX_NAME, + "crawl_runs", + ["user_id", "idempotency_key"], + unique=True, + postgresql_where=sa.text( + "idempotency_key IS NOT NULL AND trigger_type = 'manual'::run_trigger_type" + ), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns("crawl_runs")} + indexes = {index["name"] for index in inspector.get_indexes("crawl_runs")} + + if INDEX_NAME in indexes: + op.drop_index(INDEX_NAME, table_name="crawl_runs") + if "idempotency_key" in columns: + op.drop_column("crawl_runs", "idempotency_key") diff --git a/alembic/versions/20260217_0006_scholar_profile_images.py b/alembic/versions/20260217_0006_scholar_profile_images.py new file mode 100644 index 0000000..08035c8 --- /dev/null +++ b/alembic/versions/20260217_0006_scholar_profile_images.py @@ -0,0 +1,59 @@ +"""Add scholar profile image metadata and overrides. + +Revision ID: 20260217_0006 +Revises: 20260217_0005 +Create Date: 2026-02-17 18:30:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "20260217_0006" +down_revision: str | Sequence[str] | None = "20260217_0005" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +TABLE_NAME = "scholar_profiles" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)} + + if "profile_image_url" not in columns: + op.add_column( + TABLE_NAME, + sa.Column("profile_image_url", sa.Text(), nullable=True), + ) + + if "profile_image_override_url" not in columns: + op.add_column( + TABLE_NAME, + sa.Column("profile_image_override_url", sa.Text(), nullable=True), + ) + + if "profile_image_upload_path" not in columns: + op.add_column( + TABLE_NAME, + sa.Column("profile_image_upload_path", sa.Text(), nullable=True), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)} + + if "profile_image_upload_path" in columns: + op.drop_column(TABLE_NAME, "profile_image_upload_path") + + if "profile_image_override_url" in columns: + op.drop_column(TABLE_NAME, "profile_image_override_url") + + if "profile_image_url" in columns: + op.drop_column(TABLE_NAME, "profile_image_url") diff --git a/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py b/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py new file mode 100644 index 0000000..dcfd0f7 --- /dev/null +++ b/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py @@ -0,0 +1,50 @@ +"""Add per-scholar initial page fingerprint snapshot columns. + +Revision ID: 20260217_0007 +Revises: 20260217_0006 +Create Date: 2026-02-17 18:55:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "20260217_0007" +down_revision: str | Sequence[str] | None = "20260217_0006" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +TABLE_NAME = "scholar_profiles" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)} + + if "last_initial_page_fingerprint_sha256" not in columns: + op.add_column( + TABLE_NAME, + sa.Column("last_initial_page_fingerprint_sha256", sa.String(length=64), nullable=True), + ) + + if "last_initial_page_checked_at" not in columns: + op.add_column( + TABLE_NAME, + sa.Column("last_initial_page_checked_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)} + + if "last_initial_page_checked_at" in columns: + op.drop_column(TABLE_NAME, "last_initial_page_checked_at") + + if "last_initial_page_fingerprint_sha256" in columns: + op.drop_column(TABLE_NAME, "last_initial_page_fingerprint_sha256") diff --git a/app/api/deps.py b/app/api/deps.py index 9dd3a68..f9c5377 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -4,16 +4,16 @@ from fastapi import Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from app.api.errors import ApiException +from app.auth import runtime as auth_runtime from app.db.models import User from app.db.session import get_db_session -from app.web import common as web_common async def get_api_current_user( request: Request, db_session: AsyncSession = Depends(get_db_session), ) -> User: - current_user = await web_common.get_authenticated_user(request, db_session) + current_user = await auth_runtime.get_authenticated_user(request, db_session) if current_user is None: raise ApiException( status_code=401, @@ -33,4 +33,3 @@ async def get_api_admin_user( message="Admin access required.", ) return current_user - diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 3b53dc8..1a76ced 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -18,13 +18,13 @@ from app.api.schemas import ( ) from app.auth.deps import get_auth_service, get_login_rate_limiter from app.auth.rate_limit import SlidingWindowRateLimiter +from app.auth import runtime as auth_runtime from app.auth.service import AuthService from app.auth.session import set_session_user from app.db.models import User from app.db.session import get_db_session from app.security.csrf import ensure_csrf_token from app.services import users as user_service -from app.web import common as web_common logger = logging.getLogger(__name__) @@ -42,7 +42,7 @@ async def login( auth_service: AuthService = Depends(get_auth_service), rate_limiter: SlidingWindowRateLimiter = Depends(get_login_rate_limiter), ): - limiter_key = web_common.login_rate_limit_key(request, payload.email) + limiter_key = auth_runtime.login_rate_limit_key(request, payload.email) decision = rate_limiter.check(limiter_key) normalized_email = payload.email.strip().lower() if not decision.allowed: @@ -142,7 +142,7 @@ async def get_csrf_bootstrap( request: Request, db_session: AsyncSession = Depends(get_db_session), ): - current_user = await web_common.get_authenticated_user(request, db_session) + current_user = await auth_runtime.get_authenticated_user(request, db_session) return success_payload( request, data={ @@ -213,8 +213,8 @@ async def logout( request: Request, db_session: AsyncSession = Depends(get_db_session), ): - current_user = await web_common.get_authenticated_user(request, db_session) - web_common.invalidate_session(request) + current_user = await auth_runtime.get_authenticated_user(request, db_session) + auth_runtime.invalidate_session(request) logger.info( "api.auth.logout", extra={ diff --git a/app/api/routers/publications.py b/app/api/routers/publications.py index a783147..9933fe6 100644 --- a/app/api/routers/publications.py +++ b/app/api/routers/publications.py @@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_api_current_user from app.api.errors import ApiException from app.api.responses import success_payload -from app.api.schemas import MarkAllReadEnvelope, PublicationsListEnvelope +from app.api.schemas import ( + MarkAllReadEnvelope, + MarkSelectedReadEnvelope, + MarkSelectedReadRequest, + PublicationsListEnvelope, +) from app.db.models import User from app.db.session import get_db_session from app.services import publications as publication_service @@ -121,3 +126,43 @@ async def mark_all_publications_read( "updated_count": updated_count, }, ) + + +@router.post( + "/mark-read", + response_model=MarkSelectedReadEnvelope, +) +async def mark_selected_publications_read( + payload: MarkSelectedReadRequest, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + selection_pairs = sorted( + { + (int(item.scholar_profile_id), int(item.publication_id)) + for item in payload.selections + } + ) + updated_count = await publication_service.mark_selected_as_read_for_user( + db_session, + user_id=current_user.id, + selections=selection_pairs, + ) + logger.info( + "api.publications.mark_selected_read", + extra={ + "event": "api.publications.mark_selected_read", + "user_id": current_user.id, + "requested_count": len(selection_pairs), + "updated_count": updated_count, + }, + ) + return success_payload( + request, + data={ + "message": "Marked selected publications as read.", + "requested_count": len(selection_pairs), + "updated_count": updated_count, + }, + ) diff --git a/app/api/routers/runs.py b/app/api/routers/runs.py index 00f18d1..e2eab8c 100644 --- a/app/api/routers/runs.py +++ b/app/api/routers/runs.py @@ -5,6 +5,7 @@ import re from typing import Any from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_api_current_user @@ -24,7 +25,7 @@ from app.services import ingestion as ingestion_service from app.services import runs as run_service from app.services import user_settings as user_settings_service from app.settings import settings -from app.web.deps import get_ingestion_service +from app.api.runtime_deps import get_ingestion_service logger = logging.getLogger(__name__) @@ -380,6 +381,7 @@ async def run_manual( page_size=settings.ingestion_page_size, auto_queue_continuations=settings.ingestion_continuation_queue_enabled, queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds, + idempotency_key=idempotency_key, ) except ingestion_service.RunAlreadyInProgressError as exc: await db_session.rollback() @@ -388,6 +390,45 @@ async def run_manual( code="run_in_progress", message="A run is already in progress for this account.", ) from exc + except IntegrityError as exc: + await db_session.rollback() + if idempotency_key is not None: + existing_run = await run_service.get_manual_run_by_idempotency_key( + db_session, + user_id=current_user.id, + idempotency_key=idempotency_key, + ) + if existing_run is not None: + if existing_run.status == RunStatus.RUNNING: + raise ApiException( + status_code=409, + code="run_in_progress", + message="A run with this idempotency key is still in progress.", + details={ + "run_id": int(existing_run.id), + "idempotency_key": idempotency_key, + }, + ) from exc + return success_payload( + request, + data=_manual_run_payload_from_run( + existing_run, + idempotency_key=idempotency_key, + reused_existing_run=True, + ), + ) + logger.exception( + "api.runs.manual_integrity_error", + extra={ + "event": "api.runs.manual_integrity_error", + "user_id": current_user.id, + }, + ) + raise ApiException( + status_code=500, + code="manual_run_failed", + message="Manual run failed.", + ) from exc except Exception as exc: await db_session.rollback() logger.exception( @@ -403,14 +444,6 @@ async def run_manual( message="Manual run failed.", ) from exc - if idempotency_key is not None: - await run_service.set_manual_run_idempotency_key( - db_session, - user_id=current_user.id, - run_id=run_summary.crawl_run_id, - idempotency_key=idempotency_key, - ) - return success_payload( request, data={ diff --git a/app/api/routers/scholars.py b/app/api/routers/scholars.py index 2f109b5..bc7b8a3 100644 --- a/app/api/routers/scholars.py +++ b/app/api/routers/scholars.py @@ -1,33 +1,56 @@ from __future__ import annotations +import asyncio import logging +import mimetypes -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, File, Query, Request, UploadFile +from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_api_current_user from app.api.errors import ApiException from app.api.responses import success_payload +from app.api.runtime_deps import get_scholar_source from app.api.schemas import ( MessageEnvelope, ScholarCreateRequest, ScholarEnvelope, + ScholarImageUrlUpdateRequest, + ScholarSearchEnvelope, ScholarsListEnvelope, ) from app.db.models import User from app.db.session import get_db_session from app.services import scholars as scholar_service +from app.services.scholar_source import ScholarSource +from app.settings import settings logger = logging.getLogger(__name__) router = APIRouter(prefix="/scholars", tags=["api-scholars"]) +def _uploaded_image_api_path(scholar_profile_id: int) -> str: + return f"/api/v1/scholars/{scholar_profile_id}/image/upload" + + def _serialize_scholar(profile) -> dict[str, object]: + uploaded_image_url = None + if profile.profile_image_upload_path: + uploaded_image_url = _uploaded_image_api_path(int(profile.id)) + + profile_image_url, profile_image_source = scholar_service.resolve_profile_image( + profile, + uploaded_image_url=uploaded_image_url, + ) + return { "id": int(profile.id), "scholar_id": profile.scholar_id, "display_name": profile.display_name, + "profile_image_url": profile_image_url, + "profile_image_source": profile_image_source, "is_enabled": bool(profile.is_enabled), "baseline_completed": bool(profile.baseline_completed), "last_run_dt": profile.last_run_dt, @@ -67,6 +90,7 @@ async def create_scholar( payload: ScholarCreateRequest, request: Request, db_session: AsyncSession = Depends(get_db_session), + source: ScholarSource = Depends(get_scholar_source), current_user: User = Depends(get_api_current_user), ): try: @@ -74,7 +98,8 @@ async def create_scholar( db_session, user_id=current_user.id, scholar_id=payload.scholar_id, - display_name=payload.display_name or "", + display_name="", + profile_image_url=payload.profile_image_url, ) except scholar_service.ScholarServiceError as exc: raise ApiException( @@ -90,12 +115,104 @@ async def create_scholar( "scholar_profile_id": created.id, }, ) + try: + if not created.profile_image_url or not (created.display_name or "").strip(): + created = await asyncio.wait_for( + scholar_service.hydrate_profile_metadata( + db_session, + profile=created, + source=source, + ), + timeout=5.0, + ) + except Exception: + logger.warning( + "api.scholars.create_metadata_hydration_failed", + extra={ + "event": "api.scholars.create_metadata_hydration_failed", + "user_id": current_user.id, + "scholar_profile_id": created.id, + }, + ) + return success_payload( request, data=_serialize_scholar(created), ) +@router.get( + "/search", + response_model=ScholarSearchEnvelope, +) +async def search_scholars( + request: Request, + query: str = Query(..., min_length=2, max_length=120), + limit: int = Query(10, ge=1, le=25), + source: ScholarSource = Depends(get_scholar_source), + current_user: User = Depends(get_api_current_user), +): + try: + parsed = await scholar_service.search_author_candidates( + source=source, + query=query, + limit=limit, + network_error_retries=settings.ingestion_network_error_retries, + retry_backoff_seconds=settings.ingestion_retry_backoff_seconds, + search_enabled=settings.scholar_name_search_enabled, + cache_ttl_seconds=settings.scholar_name_search_cache_ttl_seconds, + blocked_cache_ttl_seconds=settings.scholar_name_search_blocked_cache_ttl_seconds, + cache_max_entries=settings.scholar_name_search_cache_max_entries, + min_interval_seconds=settings.scholar_name_search_min_interval_seconds, + interval_jitter_seconds=settings.scholar_name_search_interval_jitter_seconds, + cooldown_block_threshold=settings.scholar_name_search_cooldown_block_threshold, + cooldown_seconds=settings.scholar_name_search_cooldown_seconds, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=400, + code="invalid_scholar_search", + message=str(exc), + ) from exc + + logger.info( + "api.scholars.search.completed", + extra={ + "event": "api.scholars.search.completed", + "user_id": current_user.id, + "query": query.strip(), + "candidate_count": len(parsed.candidates), + "state": parsed.state.value, + }, + ) + return success_payload( + request, + data={ + "query": query.strip(), + "state": parsed.state.value, + "state_reason": parsed.state_reason, + "action_hint": scholar_service.scrape_state_hint( + state=parsed.state, + state_reason=parsed.state_reason, + ), + "candidates": [ + { + "scholar_id": item.scholar_id, + "display_name": item.display_name, + "affiliation": item.affiliation, + "email_domain": item.email_domain, + "cited_by_count": item.cited_by_count, + "interests": item.interests, + "profile_url": item.profile_url, + "profile_image_url": item.profile_image_url, + } + for item in parsed.candidates + ], + "warnings": parsed.warnings, + }, + ) + + @router.patch( "/{scholar_profile_id}/toggle", response_model=ScholarEnvelope, @@ -154,7 +271,11 @@ async def delete_scholar( code="scholar_not_found", message="Scholar not found.", ) - await scholar_service.delete_scholar(db_session, profile=profile) + await scholar_service.delete_scholar( + db_session, + profile=profile, + upload_dir=settings.scholar_image_upload_dir, + ) logger.info( "api.scholars.deleted", extra={ @@ -167,3 +288,202 @@ async def delete_scholar( request, data={"message": "Scholar deleted."}, ) + + +@router.put( + "/{scholar_profile_id}/image/url", + response_model=ScholarEnvelope, +) +async def update_scholar_image_url( + scholar_profile_id: int, + payload: ScholarImageUrlUpdateRequest, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=current_user.id, + scholar_profile_id=scholar_profile_id, + ) + if profile is None: + raise ApiException( + status_code=404, + code="scholar_not_found", + message="Scholar not found.", + ) + + try: + updated = await scholar_service.set_profile_image_override_url( + db_session, + profile=profile, + image_url=payload.image_url, + upload_dir=settings.scholar_image_upload_dir, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=400, + code="invalid_scholar_image", + message=str(exc), + ) from exc + + logger.info( + "api.scholars.image_url_updated", + extra={ + "event": "api.scholars.image_url_updated", + "user_id": current_user.id, + "scholar_profile_id": updated.id, + }, + ) + return success_payload( + request, + data=_serialize_scholar(updated), + ) + + +@router.post( + "/{scholar_profile_id}/image/upload", + response_model=ScholarEnvelope, +) +async def upload_scholar_image( + scholar_profile_id: int, + request: Request, + image: UploadFile = File(...), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=current_user.id, + scholar_profile_id=scholar_profile_id, + ) + if profile is None: + raise ApiException( + status_code=404, + code="scholar_not_found", + message="Scholar not found.", + ) + + try: + image_bytes = await image.read() + updated = await scholar_service.set_profile_image_upload( + db_session, + profile=profile, + content_type=image.content_type, + image_bytes=image_bytes, + upload_dir=settings.scholar_image_upload_dir, + max_upload_bytes=settings.scholar_image_upload_max_bytes, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=400, + code="invalid_scholar_image", + message=str(exc), + ) from exc + finally: + await image.close() + + logger.info( + "api.scholars.image_uploaded", + extra={ + "event": "api.scholars.image_uploaded", + "user_id": current_user.id, + "scholar_profile_id": updated.id, + "content_type": image.content_type, + "size_bytes": len(image_bytes), + }, + ) + return success_payload( + request, + data=_serialize_scholar(updated), + ) + + +@router.delete( + "/{scholar_profile_id}/image", + response_model=ScholarEnvelope, +) +async def clear_scholar_image_customization( + scholar_profile_id: int, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=current_user.id, + scholar_profile_id=scholar_profile_id, + ) + if profile is None: + raise ApiException( + status_code=404, + code="scholar_not_found", + message="Scholar not found.", + ) + + updated = await scholar_service.clear_profile_image_customization( + db_session, + profile=profile, + upload_dir=settings.scholar_image_upload_dir, + ) + logger.info( + "api.scholars.image_customization_cleared", + extra={ + "event": "api.scholars.image_customization_cleared", + "user_id": current_user.id, + "scholar_profile_id": updated.id, + }, + ) + return success_payload( + request, + data=_serialize_scholar(updated), + ) + + +@router.get( + "/{scholar_profile_id}/image/upload", +) +async def get_uploaded_scholar_image( + scholar_profile_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=current_user.id, + scholar_profile_id=scholar_profile_id, + ) + if profile is None: + raise ApiException( + status_code=404, + code="scholar_not_found", + message="Scholar not found.", + ) + if not profile.profile_image_upload_path: + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) + + try: + image_path = scholar_service.resolve_upload_file_path( + upload_dir=settings.scholar_image_upload_dir, + relative_path=profile.profile_image_upload_path, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) from exc + + if not image_path.exists() or not image_path.is_file(): + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) + + media_type = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream" + return FileResponse(path=image_path, media_type=media_type) diff --git a/app/web/deps.py b/app/api/runtime_deps.py similarity index 99% rename from app/web/deps.py rename to app/api/runtime_deps.py index 001e2b8..167e2ef 100644 --- a/app/web/deps.py +++ b/app/api/runtime_deps.py @@ -14,4 +14,3 @@ def get_ingestion_service( source: scholar_source_service.ScholarSource = Depends(get_scholar_source), ) -> ingestion_service.ScholarIngestionService: return ingestion_service.ScholarIngestionService(source=source) - diff --git a/app/api/schemas.py b/app/api/schemas.py index ab5934b..e5264db 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -112,6 +112,8 @@ class ScholarItemData(BaseModel): id: int scholar_id: str display_name: str | None + profile_image_url: str | None + profile_image_source: str is_enabled: bool baseline_completed: bool last_run_dt: datetime | None @@ -135,7 +137,7 @@ class ScholarsListEnvelope(BaseModel): class ScholarCreateRequest(BaseModel): scholar_id: str - display_name: str | None = None + profile_image_url: str | None = None model_config = ConfigDict(extra="forbid") @@ -147,6 +149,43 @@ class ScholarEnvelope(BaseModel): model_config = ConfigDict(extra="forbid") +class ScholarSearchCandidateData(BaseModel): + scholar_id: str + display_name: str + affiliation: str | None + email_domain: str | None + cited_by_count: int | None + interests: list[str] = Field(default_factory=list) + profile_url: str + profile_image_url: str | None + + model_config = ConfigDict(extra="forbid") + + +class ScholarSearchData(BaseModel): + query: str + state: str + state_reason: str + action_hint: str | None = None + candidates: list[ScholarSearchCandidateData] + warnings: list[str] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class ScholarSearchEnvelope(BaseModel): + data: ScholarSearchData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class ScholarImageUrlUpdateRequest(BaseModel): + image_url: str + + model_config = ConfigDict(extra="forbid") + + class RunListItemData(BaseModel): id: int trigger_type: str @@ -454,3 +493,31 @@ class MarkAllReadEnvelope(BaseModel): meta: ApiMeta model_config = ConfigDict(extra="forbid") + + +class PublicationSelectionItem(BaseModel): + scholar_profile_id: int + publication_id: int + + model_config = ConfigDict(extra="forbid") + + +class MarkSelectedReadRequest(BaseModel): + selections: list[PublicationSelectionItem] = Field(min_length=1, max_length=500) + + model_config = ConfigDict(extra="forbid") + + +class MarkSelectedReadData(BaseModel): + message: str + requested_count: int + updated_count: int + + model_config = ConfigDict(extra="forbid") + + +class MarkSelectedReadEnvelope(BaseModel): + data: MarkSelectedReadData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") diff --git a/app/auth/runtime.py b/app/auth/runtime.py new file mode 100644 index 0000000..5bb1a74 --- /dev/null +++ b/app/auth/runtime.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import logging + +from fastapi import Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.session import clear_session_user, get_session_user, set_session_user +from app.db.models import User +from app.security.csrf import CSRF_SESSION_KEY +from app.services import users as user_service + +logger = logging.getLogger(__name__) + + +def invalidate_session(request: Request) -> None: + clear_session_user(request) + request.session.pop(CSRF_SESSION_KEY, None) + + +async def get_authenticated_user( + request: Request, + db_session: AsyncSession, +) -> User | None: + session_user = get_session_user(request) + if session_user is None: + return None + + user = await user_service.get_user_by_id(db_session, session_user.id) + if user is None or not user.is_active: + logger.info( + "auth.session_invalidated", + extra={ + "event": "auth.session_invalidated", + "session_user_id": session_user.id, + }, + ) + invalidate_session(request) + return None + + if user.email != session_user.email or user.is_admin != session_user.is_admin: + set_session_user( + request, + user_id=user.id, + email=user.email, + is_admin=user.is_admin, + ) + + return user + + +def login_rate_limit_key(request: Request, email: str) -> str: + client_host = request.client.host if request.client is not None else "unknown" + normalized_email = email.strip().lower() + return f"{client_host}:{normalized_email or ''}" diff --git a/app/db/base.py b/app/db/base.py index 29adb07..6fa2049 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -1,4 +1,6 @@ -from sqlalchemy import MetaData +from datetime import datetime, timezone + +from sqlalchemy import MetaData, event from sqlalchemy.orm import DeclarativeBase @@ -15,4 +17,11 @@ class Base(DeclarativeBase): metadata = MetaData(naming_convention=NAMING_CONVENTION) +@event.listens_for(Base, "before_update", propagate=True) +def _set_updated_at_before_update(_mapper, _connection, target) -> None: + # Keep audit timestamps current for ORM-managed updates. + if hasattr(target, "updated_at"): + target.updated_at = datetime.now(timezone.utc) + + metadata = Base.metadata diff --git a/app/db/models.py b/app/db/models.py index 428c654..68c3e72 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -119,6 +119,11 @@ class ScholarProfile(Base): ) scholar_id: Mapped[str] = mapped_column(String(64), nullable=False) display_name: Mapped[str | None] = mapped_column(String(255)) + profile_image_url: Mapped[str | None] = mapped_column(Text) + profile_image_override_url: Mapped[str | None] = mapped_column(Text) + profile_image_upload_path: Mapped[str | None] = mapped_column(Text) + last_initial_page_fingerprint_sha256: Mapped[str | None] = mapped_column(String(64)) + last_initial_page_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) is_enabled: Mapped[bool] = mapped_column( Boolean, nullable=False, server_default=text("true") ) @@ -141,6 +146,15 @@ class CrawlRun(Base): __tablename__ = "crawl_runs" __table_args__ = ( Index("ix_crawl_runs_user_start", "user_id", "start_dt"), + Index( + "uq_crawl_runs_user_manual_idempotency_key", + "user_id", + "idempotency_key", + unique=True, + postgresql_where=text( + "idempotency_key IS NOT NULL AND trigger_type = 'manual'::run_trigger_type" + ), + ), ) id: Mapped[int] = mapped_column(primary_key=True) @@ -163,6 +177,7 @@ class CrawlRun(Base): new_pub_count: Mapped[int] = mapped_column( Integer, nullable=False, server_default=text("0") ) + idempotency_key: Mapped[str | None] = mapped_column(String(128)) error_log: Mapped[dict] = mapped_column( JSONB, nullable=False, server_default=text("'{}'::jsonb") ) diff --git a/app/http/__init__.py b/app/http/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/app/http/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/app/web/middleware.py b/app/http/middleware.py similarity index 100% rename from app/web/middleware.py rename to app/http/middleware.py index 9de69a6..9d85458 100644 --- a/app/web/middleware.py +++ b/app/http/middleware.py @@ -1,8 +1,8 @@ from __future__ import annotations from secrets import token_urlsafe -import time import logging +import time from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request diff --git a/app/main.py b/app/main.py index 75ee94d..44d5cc7 100644 --- a/app/main.py +++ b/app/main.py @@ -1,31 +1,23 @@ from __future__ import annotations from contextlib import asynccontextmanager +from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from app.api.errors import register_api_exception_handlers from app.api.router import router as api_router +from app.api.runtime_deps import get_ingestion_service, get_scholar_source +from app.db.session import check_database from app.db.session import close_engine +from app.http.middleware import RequestLoggingMiddleware, parse_skip_paths from app.logging_config import configure_logging, parse_redact_fields from app.security.csrf import CSRFMiddleware from app.services.scheduler import SchedulerService from app.settings import settings -from app.web import common as web_common -from app.web.deps import get_ingestion_service, get_scholar_source -from app.web.middleware import RequestLoggingMiddleware, parse_skip_paths -from app.web.routers import ( - admin, - auth, - dashboard, - health, - publications, - runs, - scholars, - settings as settings_router, -) configure_logging( level=settings.log_level, @@ -71,17 +63,50 @@ app.add_middleware( log_requests=settings.log_requests, skip_paths=parse_skip_paths(settings.log_request_skip_paths), ) -app.mount("/static", StaticFiles(directory="app/static"), name="static") - app.include_router(api_router) -app.include_router(auth.router) -app.include_router(admin.router) -app.include_router(scholars.router) -app.include_router(settings_router.router) -app.include_router(runs.router) -app.include_router(publications.router) -app.include_router(dashboard.router) -app.include_router(health.router) -# Backward-compatible export kept for tests and any existing local scripts. -_get_authenticated_user = web_common.get_authenticated_user + +@app.get("/healthz") +async def healthz() -> dict[str, str]: + if await check_database(): + return {"status": "ok"} + raise HTTPException(status_code=500, detail="database unavailable") + + +def _configure_frontend_routes(application: FastAPI) -> None: + if not settings.frontend_enabled: + return + + dist_dir = Path(settings.frontend_dist_dir) + index_file = dist_dir / "index.html" + + if not index_file.is_file(): + return + + assets_dir = dist_dir / "assets" + if assets_dir.is_dir(): + application.mount("/assets", StaticFiles(directory=assets_dir), name="frontend-assets") + + @application.get("/", include_in_schema=False) + async def frontend_index() -> FileResponse: + return FileResponse(index_file) + + @application.get("/{full_path:path}", include_in_schema=False) + async def frontend_entry(full_path: str) -> FileResponse: + normalized = full_path.lstrip("/") + if normalized in {"healthz", "api"} or normalized.startswith("api/"): + raise HTTPException(status_code=404, detail="Not Found") + + candidate = (dist_dir / normalized).resolve() + try: + candidate.relative_to(dist_dir.resolve()) + except ValueError as error: + raise HTTPException(status_code=404, detail="Not Found") from error + + if candidate.is_file(): + return FileResponse(candidate) + + return FileResponse(index_file) + + +_configure_frontend_routes(app) diff --git a/app/presentation/__init__.py b/app/presentation/__init__.py deleted file mode 100644 index d3d8cc8..0000000 --- a/app/presentation/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Presentation-layer view models used by template routes.""" - diff --git a/app/presentation/dashboard.py b/app/presentation/dashboard.py deleted file mode 100644 index 2f5d609..0000000 --- a/app/presentation/dashboard.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import Sequence - -from app.db.models import CrawlRun -from app.services.publications import UnreadPublicationItem - -SECTION_TEMPLATES: tuple[str, ...] = ( - "dashboard/_run_controls.html", - "dashboard/_unread_publications.html", - "dashboard/_run_history.html", -) - - -@dataclass(frozen=True) -class RunControlsViewModel: - request_delay_seconds: int - queue_queued_count: int - queue_retrying_count: int - queue_dropped_count: int - run_manual_action: str = "/runs/manual" - mark_all_read_action: str = "/publications/mark-all-read" - - -@dataclass(frozen=True) -class UnreadPublicationViewModel: - title: str - scholar_label: str - year_display: str - citation_count: int - venue_text: str | None - pub_url: str | None - - -@dataclass(frozen=True) -class RunHistoryItemViewModel: - run_id: int | None - detail_url: str | None - started_at_display: str - status_label: str - status_badge: str - scholar_count: int - new_publication_count: int - - -@dataclass(frozen=True) -class DashboardViewModel: - section_templates: tuple[str, ...] - run_controls: RunControlsViewModel - unread_publications: list[UnreadPublicationViewModel] - run_history: list[RunHistoryItemViewModel] - - -def build_dashboard_view_model( - *, - unread_publications: Sequence[UnreadPublicationItem], - recent_runs: Sequence[CrawlRun], - request_delay_seconds: int, - queue_counts: dict[str, int] | None = None, -) -> DashboardViewModel: - queue_counts = queue_counts or {} - return DashboardViewModel( - section_templates=SECTION_TEMPLATES, - run_controls=RunControlsViewModel( - request_delay_seconds=request_delay_seconds, - queue_queued_count=int(queue_counts.get("queued", 0)), - queue_retrying_count=int(queue_counts.get("retrying", 0)), - queue_dropped_count=int(queue_counts.get("dropped", 0)), - ), - unread_publications=[ - UnreadPublicationViewModel( - title=item.title, - scholar_label=item.scholar_label, - year_display=str(item.year) if item.year is not None else "-", - citation_count=item.citation_count, - venue_text=item.venue_text, - pub_url=item.pub_url, - ) - for item in unread_publications - ], - run_history=[ - RunHistoryItemViewModel( - run_id=run.id, - detail_url=f"/runs/{run.id}" if run.id is not None else None, - started_at_display=_format_started_at(run.start_dt), - status_label=run.status.value, - status_badge=_status_badge(run.status.value), - scholar_count=run.scholar_count, - new_publication_count=run.new_pub_count, - ) - for run in recent_runs - ], - ) - - -def _status_badge(status: str) -> str: - if status == "success": - return "ok" - if status == "failed": - return "danger" - return "warn" - - -def _format_started_at(dt: datetime) -> str: - local_aware = dt.astimezone(timezone.utc) - return local_aware.strftime("%Y-%m-%d %H:%M UTC") diff --git a/app/security/csrf.py b/app/security/csrf.py index 1f5c1a1..70eecac 100644 --- a/app/security/csrf.py +++ b/app/security/csrf.py @@ -77,8 +77,6 @@ class CSRFMiddleware(BaseHTTPMiddleware): if request.method in SAFE_METHODS: return True path = request.url.path - if path.startswith("/static/"): - return True return path in self._exempt_paths def _is_form_payload(self, request: Request) -> bool: diff --git a/app/services/continuation_queue.py b/app/services/continuation_queue.py index cb5b175..7d7d93e 100644 --- a/app/services/continuation_queue.py +++ b/app/services/continuation_queue.py @@ -181,6 +181,23 @@ async def increment_attempt_count( return item +async def reset_attempt_count( + db_session: AsyncSession, + *, + job_id: int, +) -> IngestionQueueItem | None: + now = datetime.now(timezone.utc) + result = await db_session.execute( + select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) + ) + item = result.scalar_one_or_none() + if item is None: + return None + item.attempt_count = 0 + item.updated_at = now + return item + + async def reschedule_job( db_session: AsyncSession, *, diff --git a/app/services/ingestion.py b/app/services/ingestion.py index 61025bb..986f460 100644 --- a/app/services/ingestion.py +++ b/app/services/ingestion.py @@ -4,6 +4,7 @@ import asyncio from dataclasses import dataclass from datetime import datetime, timezone import hashlib +import json import logging import re from typing import Any @@ -45,6 +46,7 @@ RESUMABLE_PARTIAL_REASONS = { "pagination_cursor_stalled", } RESUMABLE_PARTIAL_REASON_PREFIXES = ("page_state_network_error",) +INITIAL_PAGE_FINGERPRINT_MAX_PUBLICATIONS = 30 logger = logging.getLogger(__name__) @@ -63,6 +65,9 @@ class RunExecutionSummary: class PagedParseResult: fetch_result: FetchResult parsed_page: ParsedProfilePage + first_page_fetch_result: FetchResult + first_page_parsed_page: ParsedProfilePage + first_page_fingerprint_sha256: str | None publications: list[PublicationCandidate] attempt_log: list[dict[str, Any]] page_logs: list[dict[str, Any]] @@ -71,6 +76,7 @@ class PagedParseResult: has_more_remaining: bool pagination_truncated_reason: str | None continuation_cstart: int | None + skipped_no_change: bool class RunAlreadyInProgressError(RuntimeError): @@ -96,6 +102,7 @@ class ScholarIngestionService: start_cstart_by_scholar_id: dict[int, int] | None = None, auto_queue_continuations: bool = True, queue_delay_seconds: int = 60, + idempotency_key: str | None = None, ) -> RunExecutionSummary: lock_acquired = await self._try_acquire_user_lock( db_session, @@ -153,6 +160,7 @@ class ScholarIngestionService: "retry_backoff_seconds": retry_backoff_seconds, "max_pages_per_scholar": max_pages_per_scholar, "page_size": page_size, + "idempotency_key": idempotency_key, }, ) @@ -162,6 +170,7 @@ class ScholarIngestionService: status=RunStatus.RUNNING, scholar_count=len(scholars), new_pub_count=0, + idempotency_key=idempotency_key, error_log={}, ) db_session.add(run) @@ -187,6 +196,7 @@ class ScholarIngestionService: retry_backoff_seconds=retry_backoff_seconds, max_pages=max_pages_per_scholar, page_size=page_size, + previous_initial_page_fingerprint_sha256=scholar.last_initial_page_fingerprint_sha256, ) fetch_result = paged_parse_result.fetch_result parsed_page = paged_parse_result.parsed_page @@ -194,6 +204,17 @@ class ScholarIngestionService: attempt_log = paged_parse_result.attempt_log page_logs = paged_parse_result.page_logs + first_page = paged_parse_result.first_page_parsed_page + if first_page.profile_name and not (scholar.display_name or "").strip(): + scholar.display_name = first_page.profile_name + if first_page.profile_image_url: + scholar.profile_image_url = first_page.profile_image_url + if paged_parse_result.first_page_fingerprint_sha256: + scholar.last_initial_page_fingerprint_sha256 = ( + paged_parse_result.first_page_fingerprint_sha256 + ) + scholar.last_initial_page_checked_at = run_dt + logger.info( "ingestion.scholar_parsed", extra={ @@ -210,6 +231,7 @@ class ScholarIngestionService: "has_more_remaining": paged_parse_result.has_more_remaining, "pagination_truncated_reason": paged_parse_result.pagination_truncated_reason, "warning_count": len(parsed_page.warnings), + "skipped_no_change": paged_parse_result.skipped_no_change, }, ) @@ -230,7 +252,55 @@ class ScholarIngestionService: "has_more_remaining": paged_parse_result.has_more_remaining, "pagination_truncated_reason": paged_parse_result.pagination_truncated_reason, "continuation_cstart": paged_parse_result.continuation_cstart, + "skipped_no_change": paged_parse_result.skipped_no_change, + "initial_page_fingerprint_sha256": paged_parse_result.first_page_fingerprint_sha256, } + if paged_parse_result.skipped_no_change: + scholar.last_run_status = RunStatus.SUCCESS + scholar.last_run_dt = run_dt + succeeded_count += 1 + result_entry["state"] = first_page.state.value + result_entry["state_reason"] = "no_change_initial_page_signature" + result_entry["outcome"] = "success" + result_entry["publication_count"] = 0 + result_entry["warnings"] = first_page.warnings + result_entry["debug"] = { + "state_reason": "no_change_initial_page_signature", + "first_page_fingerprint_sha256": paged_parse_result.first_page_fingerprint_sha256, + "attempt_log": attempt_log, + "page_logs": page_logs, + } + scholar_results.append(result_entry) + queue_reason, queue_cstart = self._resolve_continuation_queue_target( + outcome=str(result_entry.get("outcome", "")), + state=str(result_entry.get("state", "")), + pagination_truncated_reason=paged_parse_result.pagination_truncated_reason, + continuation_cstart=paged_parse_result.continuation_cstart, + fallback_cstart=start_cstart, + ) + if auto_queue_continuations and queue_reason is not None: + await queue_service.upsert_job( + db_session, + user_id=user_id, + scholar_profile_id=scholar.id, + resume_cstart=queue_cstart, + reason=queue_reason, + run_id=run.id, + delay_seconds=queue_delay_seconds, + ) + result_entry["continuation_enqueued"] = True + result_entry["continuation_reason"] = queue_reason + result_entry["continuation_cstart"] = queue_cstart + else: + cleared = await queue_service.clear_job_for_scholar( + db_session, + user_id=user_id, + scholar_profile_id=scholar.id, + ) + if cleared: + result_entry["continuation_cleared"] = True + continue + had_page_failure = parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS} has_partial_publication_set = len(publications) > 0 and had_page_failure is_partial_due_to_pagination = ( @@ -373,6 +443,11 @@ class ScholarIngestionService: "failed_state_counts": failed_state_counts, "failed_reason_counts": failed_reason_counts, }, + "meta": { + "idempotency_key": idempotency_key, + } + if idempotency_key + else {}, } await db_session.commit() @@ -519,6 +594,7 @@ class ScholarIngestionService: retry_backoff_seconds: float, max_pages: int, page_size: int, + previous_initial_page_fingerprint_sha256: str | None = None, ) -> PagedParseResult: bounded_max_pages = max(1, int(max_pages)) bounded_page_size = max(1, int(page_size)) @@ -534,6 +610,9 @@ class ScholarIngestionService: network_error_retries=network_error_retries, retry_backoff_seconds=retry_backoff_seconds, ) + first_page_fetch_result = fetch_result + first_page_parsed_page = parsed_page + first_page_fingerprint_sha256 = build_initial_page_fingerprint(parsed_page) attempt_log: list[dict[str, Any]] = list(first_attempt_log) page_logs: list[dict[str, Any]] = [] @@ -553,11 +632,39 @@ class ScholarIngestionService: ) pages_attempted = 1 + should_skip_no_change = ( + start_cstart <= 0 + and first_page_fingerprint_sha256 is not None + and previous_initial_page_fingerprint_sha256 is not None + and first_page_fingerprint_sha256 == previous_initial_page_fingerprint_sha256 + and parsed_page.state in {ParseState.OK, ParseState.NO_RESULTS} + ) + if should_skip_no_change: + return PagedParseResult( + fetch_result=fetch_result, + parsed_page=parsed_page, + first_page_fetch_result=first_page_fetch_result, + first_page_parsed_page=first_page_parsed_page, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, + publications=[], + attempt_log=attempt_log, + page_logs=page_logs, + pages_fetched=1, + pages_attempted=pages_attempted, + has_more_remaining=False, + pagination_truncated_reason=None, + continuation_cstart=None, + skipped_no_change=True, + ) + # Immediate hard failure: nothing to salvage. if parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: return PagedParseResult( fetch_result=fetch_result, parsed_page=parsed_page, + first_page_fetch_result=first_page_fetch_result, + first_page_parsed_page=first_page_parsed_page, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, publications=[], attempt_log=attempt_log, page_logs=page_logs, @@ -568,6 +675,7 @@ class ScholarIngestionService: continuation_cstart=( start_cstart if parsed_page.state == ParseState.NETWORK_ERROR else None ), + skipped_no_change=False, ) publications = list(parsed_page.publications) @@ -649,6 +757,9 @@ class ScholarIngestionService: return PagedParseResult( fetch_result=fetch_result, parsed_page=parsed_page, + first_page_fetch_result=first_page_fetch_result, + first_page_parsed_page=first_page_parsed_page, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, publications=_dedupe_publication_candidates(publications), attempt_log=attempt_log, page_logs=page_logs, @@ -657,6 +768,7 @@ class ScholarIngestionService: has_more_remaining=has_more_remaining, pagination_truncated_reason=pagination_truncated_reason, continuation_cstart=continuation_cstart, + skipped_no_change=False, ) async def _upsert_profile_publications( @@ -838,6 +950,7 @@ class ScholarIngestionService: "fetch_error": fetch_result.error, "state_reason": parsed_page.state_reason, "profile_name": parsed_page.profile_name, + "profile_image_url": parsed_page.profile_image_url, "articles_range": parsed_page.articles_range, "has_show_more_button": parsed_page.has_show_more_button, "has_operation_error_banner": parsed_page.has_operation_error_banner, @@ -914,6 +1027,37 @@ def build_publication_fingerprint(candidate: PublicationCandidate) -> str: return hashlib.sha256(canonical.encode("utf-8")).hexdigest() +def build_initial_page_fingerprint(parsed_page: ParsedProfilePage) -> str | None: + if parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: + return None + + normalized_rows: list[dict[str, Any]] = [] + for publication in parsed_page.publications[:INITIAL_PAGE_FINGERPRINT_MAX_PUBLICATIONS]: + normalized_rows.append( + { + "cluster_id": publication.cluster_id or "", + "title_normalized": normalize_title(publication.title), + "year": publication.year, + "citation_count": publication.citation_count, + } + ) + + payload = { + "state": parsed_page.state.value, + "articles_range": parsed_page.articles_range or "", + "has_show_more_button": parsed_page.has_show_more_button, + "profile_name": parsed_page.profile_name or "", + "publications": normalized_rows, + } + canonical = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + def build_publication_url(path_or_url: str | None) -> str | None: if not path_or_url: return None diff --git a/app/services/publications.py b/app/services/publications.py index 5856b74..3ba5f87 100644 --- a/app/services/publications.py +++ b/app/services/publications.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime -from sqlalchemy import Select, func, select, update +from sqlalchemy import Select, func, select, tuple_, update from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import ( @@ -295,3 +295,39 @@ async def mark_all_unread_as_read_for_user( rowcount = result.rowcount return int(rowcount or 0) + + +async def mark_selected_as_read_for_user( + db_session: AsyncSession, + *, + user_id: int, + selections: list[tuple[int, int]], +) -> int: + normalized_pairs = { + (int(scholar_profile_id), int(publication_id)) + for scholar_profile_id, publication_id in selections + if int(scholar_profile_id) > 0 and int(publication_id) > 0 + } + if not normalized_pairs: + return 0 + + scholar_ids = ( + select(ScholarProfile.id) + .where(ScholarProfile.user_id == user_id) + .scalar_subquery() + ) + stmt = ( + update(ScholarPublication) + .where( + ScholarPublication.scholar_profile_id.in_(scholar_ids), + tuple_( + ScholarPublication.scholar_profile_id, + ScholarPublication.publication_id, + ).in_(list(normalized_pairs)), + ScholarPublication.is_read.is_(False), + ) + .values(is_read=True) + ) + result = await db_session.execute(stmt) + await db_session.commit() + return int(result.rowcount or 0) diff --git a/app/services/runs.py b/app/services/runs.py index e313a9d..1e65cfe 100644 --- a/app/services/runs.py +++ b/app/services/runs.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Any -from sqlalchemy import and_, case, func, select +from sqlalchemy import and_, case, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import ( @@ -152,7 +152,10 @@ async def get_manual_run_by_idempotency_key( .where( CrawlRun.user_id == user_id, CrawlRun.trigger_type == RunTriggerType.MANUAL, - CrawlRun.error_log["meta"]["idempotency_key"].astext == idempotency_key, + or_( + CrawlRun.idempotency_key == idempotency_key, + CrawlRun.error_log["meta"]["idempotency_key"].astext == idempotency_key, + ), ) .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) .limit(1) @@ -160,33 +163,6 @@ async def get_manual_run_by_idempotency_key( return result.scalar_one_or_none() -async def set_manual_run_idempotency_key( - db_session: AsyncSession, - *, - user_id: int, - run_id: int, - idempotency_key: str, -) -> bool: - run = await get_run_for_user( - db_session, - user_id=user_id, - run_id=run_id, - ) - if run is None: - return False - if run.trigger_type != RunTriggerType.MANUAL: - return False - - payload = dict(run.error_log) if isinstance(run.error_log, dict) else {} - meta = payload.get("meta") - meta_dict = dict(meta) if isinstance(meta, dict) else {} - meta_dict["idempotency_key"] = idempotency_key - payload["meta"] = meta_dict - run.error_log = payload - await db_session.commit() - return True - - async def list_queue_items_for_user( db_session: AsyncSession, *, diff --git a/app/services/scheduler.py b/app/services/scheduler.py index 80ca1cb..40dbc6f 100644 --- a/app/services/scheduler.py +++ b/app/services/scheduler.py @@ -399,6 +399,39 @@ class SchedulerService: return async with session_factory() as session: + # Failed-attempt budget should advance only when continuation execution fails. + if int(run_summary.failed_count) <= 0: + queue_item = await queue_service.reset_attempt_count( + session, + job_id=job.id, + ) + if queue_item is None: + await session.commit() + logger.info( + "scheduler.queue_item_resolved", + extra={ + "event": "scheduler.queue_item_resolved", + "queue_item_id": job.id, + "user_id": job.user_id, + "run_id": run_summary.crawl_run_id, + "status": run_summary.status.value, + }, + ) + return + await session.commit() + logger.info( + "scheduler.queue_item_progressed", + extra={ + "event": "scheduler.queue_item_progressed", + "queue_item_id": job.id, + "user_id": job.user_id, + "attempt_count": int(queue_item.attempt_count), + "run_id": run_summary.crawl_run_id, + "status": run_summary.status.value, + }, + ) + return + queue_item = await queue_service.increment_attempt_count( session, job_id=job.id, @@ -451,9 +484,9 @@ class SchedulerService: ) await session.commit() logger.info( - "scheduler.queue_item_rescheduled", + "scheduler.queue_item_rescheduled_failed", extra={ - "event": "scheduler.queue_item_rescheduled", + "event": "scheduler.queue_item_rescheduled_failed", "queue_item_id": job.id, "user_id": job.user_id, "attempt_count": queue_item.attempt_count, diff --git a/app/services/scholar_parser.py b/app/services/scholar_parser.py index 4cb9ed3..de2daa5 100644 --- a/app/services/scholar_parser.py +++ b/app/services/scholar_parser.py @@ -6,7 +6,7 @@ from enum import StrEnum from html import unescape from html.parser import HTMLParser from typing import Any -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, urljoin, urlparse from app.services.scholar_source import FetchResult @@ -16,6 +16,8 @@ BLOCKED_KEYWORDS = [ "not a robot", "our systems have detected", "automated queries", + "recaptcha", + "captcha", ] NO_RESULTS_KEYWORDS = [ @@ -25,6 +27,14 @@ NO_RESULTS_KEYWORDS = [ "no documents", ] +NO_AUTHOR_RESULTS_KEYWORDS = [ + "didn't match any user profiles", + "did not match any user profiles", + "didn't match any scholars", + "did not match any scholars", + "no user profiles", +] + MARKER_KEYS = [ "gsc_a_tr", "gsc_a_at", @@ -36,6 +46,33 @@ MARKER_KEYS = [ "gsc_rsb_st", ] +AUTHOR_SEARCH_MARKER_KEYS = [ + "gsc_1usr", + "gs_ai_name", + "gs_ai_aff", + "gs_ai_eml", + "gs_ai_cby", + "gs_ai_one_int", +] + +NETWORK_DNS_ERROR_KEYWORDS = [ + "temporary failure in name resolution", + "name or service not known", + "nodename nor servname provided", + "getaddrinfo failed", +] + +NETWORK_TIMEOUT_KEYWORDS = [ + "timed out", + "timeout", +] + +NETWORK_TLS_ERROR_KEYWORDS = [ + "ssl", + "tls", + "certificate verify failed", +] + TAG_RE = re.compile(r"<[^>]+>", re.S) SCRIPT_STYLE_RE = re.compile(r"<(script|style)\b[^>]*>.*?", re.I | re.S) SHOW_MORE_BUTTON_RE = re.compile( @@ -63,11 +100,24 @@ class PublicationCandidate: venue_text: str | None +@dataclass(frozen=True) +class ScholarSearchCandidate: + scholar_id: str + display_name: str + affiliation: str | None + email_domain: str | None + cited_by_count: int | None + interests: list[str] + profile_url: str + profile_image_url: str | None + + @dataclass(frozen=True) class ParsedProfilePage: state: ParseState state_reason: str profile_name: str | None + profile_image_url: str | None publications: list[PublicationCandidate] marker_counts: dict[str, int] warnings: list[str] @@ -76,6 +126,15 @@ class ParsedProfilePage: articles_range: str | None +@dataclass(frozen=True) +class ParsedAuthorSearchPage: + state: ParseState + state_reason: str + candidates: list[ScholarSearchCandidate] + marker_counts: dict[str, int] + warnings: list[str] + + def normalize_space(value: str) -> str: return " ".join(unescape(value).split()) @@ -98,6 +157,19 @@ def attr_href(attrs: list[tuple[str, str | None]]) -> str | None: return None +def attr_src(attrs: list[tuple[str, str | None]]) -> str | None: + for name, raw_value in attrs: + if name.lower() == "src": + return raw_value + return None + + +def build_absolute_scholar_url(path_or_url: str | None) -> str | None: + if not path_or_url: + return None + return urljoin("https://scholar.google.com", path_or_url) + + class ScholarRowParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) @@ -197,6 +269,18 @@ def parse_cluster_id_from_href(href: str | None) -> str | None: return None +def parse_scholar_id_from_href(href: str | None) -> str | None: + if not href: + return None + parsed = urlparse(href) + query = parse_qs(parsed.query) + user_values = query.get("user") + if not user_values: + return None + candidate = user_values[0].strip() + return candidate or None + + def parse_year(parts: list[str]) -> int | None: text = normalize_space(" ".join(parts)) match = re.search(r"\b(19|20)\d{2}\b", text) @@ -265,6 +349,30 @@ def extract_profile_name(html: str) -> str | None: return value or None +def extract_profile_image_url(html: str) -> str | None: + og_image_pattern = re.compile( + r"]+property=['\"]og:image['\"][^>]+content=['\"]([^'\"]+)['\"][^>]*>", + re.I | re.S, + ) + og_match = og_image_pattern.search(html) + if og_match: + value = normalize_space(og_match.group(1)) + absolute = build_absolute_scholar_url(value) + if absolute: + return absolute + + image_pattern = re.compile( + r"]*\bid=['\"]gsc_prf_pup-img['\"][^>]*\bsrc=['\"]([^'\"]+)['\"][^>]*>", + re.I | re.S, + ) + image_match = image_pattern.search(html) + if not image_match: + return None + + value = normalize_space(image_match.group(1)) + return build_absolute_scholar_url(value) + + def extract_articles_range(html: str) -> str | None: pattern = re.compile( r"<[^>]*\bid\s*=\s*['\"]gsc_a_nn['\"][^>]*>(.*?)]+>", @@ -304,6 +412,229 @@ def count_markers(html: str) -> dict[str, int]: return {key: lowered.count(key.lower()) for key in MARKER_KEYS} +def count_author_search_markers(html: str) -> dict[str, int]: + lowered = html.lower() + return {key: lowered.count(key.lower()) for key in AUTHOR_SEARCH_MARKER_KEYS} + + +def _extract_verified_email_domain(value: str | None) -> str | None: + if not value: + return None + match = re.search(r"verified email at\s+(.+)$", value.strip(), re.I) + if not match: + return None + domain = normalize_space(match.group(1)) + return domain or None + + +class ScholarAuthorSearchParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.candidates: list[ScholarSearchCandidate] = [] + self._candidate: dict[str, Any] | None = None + + def _begin_candidate(self) -> None: + self._candidate = { + "depth": 1, + "name_href": None, + "name_parts": [], + "aff_depth": 0, + "aff_parts": [], + "name_depth": 0, + "eml_depth": 0, + "eml_parts": [], + "cby_depth": 0, + "cby_parts": [], + "interest_depth": 0, + "interest_parts": [], + "interests": [], + "image_src": None, + } + + def _increment_capture_depths(self) -> None: + if self._candidate is None: + return + for key in ("name_depth", "aff_depth", "eml_depth", "cby_depth", "interest_depth"): + if self._candidate[key] > 0: + self._candidate[key] += 1 + + def _finalize_candidate(self) -> None: + if self._candidate is None: + return + + name = normalize_space("".join(self._candidate["name_parts"])) + scholar_id = parse_scholar_id_from_href(self._candidate["name_href"]) + if not name or not scholar_id: + return + + affiliation = normalize_space("".join(self._candidate["aff_parts"])) or None + email_domain = _extract_verified_email_domain( + normalize_space("".join(self._candidate["eml_parts"])) or None + ) + cited_by_text = normalize_space("".join(self._candidate["cby_parts"])) + cited_by_match = re.search(r"\d+", cited_by_text) + cited_by_count = int(cited_by_match.group(0)) if cited_by_match else None + + seen_interests: set[str] = set() + interests: list[str] = [] + for interest in self._candidate["interests"]: + normalized = normalize_space(interest) + if not normalized or normalized in seen_interests: + continue + seen_interests.add(normalized) + interests.append(normalized) + + profile_url = build_absolute_scholar_url(self._candidate["name_href"]) + if not profile_url: + profile_url = ( + "https://scholar.google.com/citations" + f"?hl=en&user={scholar_id}" + ) + + self.candidates.append( + ScholarSearchCandidate( + scholar_id=scholar_id, + display_name=name, + affiliation=affiliation, + email_domain=email_domain, + cited_by_count=cited_by_count, + interests=interests, + profile_url=profile_url, + profile_image_url=build_absolute_scholar_url(self._candidate["image_src"]), + ) + ) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + classes = attr_class(attrs) + + if self._candidate is None: + if tag == "div" and "gsc_1usr" in classes: + self._begin_candidate() + return + + self._candidate["depth"] += 1 + self._increment_capture_depths() + + if tag == "a" and "gs_ai_name" in classes: + self._candidate["name_depth"] = 1 + self._candidate["name_href"] = attr_href(attrs) + return + + if tag == "div" and "gs_ai_aff" in classes: + self._candidate["aff_depth"] = 1 + return + + if tag == "div" and "gs_ai_eml" in classes: + self._candidate["eml_depth"] = 1 + return + + if tag == "div" and "gs_ai_cby" in classes: + self._candidate["cby_depth"] = 1 + return + + if tag == "a" and "gs_ai_one_int" in classes: + self._candidate["interest_depth"] = 1 + self._candidate["interest_parts"] = [] + return + + if tag == "img" and self._candidate["image_src"] is None: + self._candidate["image_src"] = attr_src(attrs) + + def handle_data(self, data: str) -> None: + if self._candidate is None: + return + if self._candidate["name_depth"] > 0: + self._candidate["name_parts"].append(data) + if self._candidate["aff_depth"] > 0: + self._candidate["aff_parts"].append(data) + if self._candidate["eml_depth"] > 0: + self._candidate["eml_parts"].append(data) + if self._candidate["cby_depth"] > 0: + self._candidate["cby_parts"].append(data) + if self._candidate["interest_depth"] > 0: + self._candidate["interest_parts"].append(data) + + def _decrement_capture_depth(self, key: str) -> bool: + if self._candidate is None: + return False + if self._candidate[key] <= 0: + return False + self._candidate[key] -= 1 + return self._candidate[key] == 0 + + def handle_endtag(self, _tag: str) -> None: + if self._candidate is None: + return + + interest_closed = self._decrement_capture_depth("interest_depth") + self._decrement_capture_depth("name_depth") + self._decrement_capture_depth("aff_depth") + self._decrement_capture_depth("eml_depth") + self._decrement_capture_depth("cby_depth") + + if interest_closed: + interest_text = normalize_space("".join(self._candidate["interest_parts"])) + if interest_text: + self._candidate["interests"].append(interest_text) + self._candidate["interest_parts"] = [] + + self._candidate["depth"] -= 1 + if self._candidate["depth"] > 0: + return + + self._finalize_candidate() + self._candidate = None + + +def classify_network_error_reason(fetch_error: str | None) -> str: + lowered = (fetch_error or "").lower() + if lowered: + if any(keyword in lowered for keyword in NETWORK_DNS_ERROR_KEYWORDS): + return "network_dns_resolution_failed" + if any(keyword in lowered for keyword in NETWORK_TIMEOUT_KEYWORDS): + return "network_timeout" + if any(keyword in lowered for keyword in NETWORK_TLS_ERROR_KEYWORDS): + return "network_tls_error" + if "connection reset" in lowered: + return "network_connection_reset" + if "connection refused" in lowered: + return "network_connection_refused" + if "network is unreachable" in lowered: + return "network_unreachable" + return "network_error_missing_status_code" + + +def classify_block_or_captcha_reason( + *, + status_code: int, + final_url: str, + body_lowered: str, +) -> str | None: + if "accounts.google.com" in final_url and ("signin" in final_url or "servicelogin" in final_url): + return "blocked_accounts_redirect" + if status_code == 429: + return "blocked_http_429_rate_limited" + if status_code == 403: + if "recaptcha" in body_lowered or "captcha" in body_lowered or "sorry/index" in final_url: + return "blocked_http_403_captcha_challenge" + return "blocked_http_403_forbidden" + if "sorry/index" in final_url or "sorry/index" in body_lowered: + return "blocked_google_sorry_challenge" + if "our systems have detected" in body_lowered or "unusual traffic" in body_lowered: + return "blocked_unusual_traffic_detected" + if "automated queries" in body_lowered: + return "blocked_automated_queries_detected" + if "not a robot" in body_lowered: + return "blocked_not_a_robot_challenge" + if "recaptcha" in body_lowered: + return "blocked_recaptcha_challenge" + if "captcha" in body_lowered: + return "blocked_captcha_challenge" + if any(keyword in body_lowered for keyword in BLOCKED_KEYWORDS): + return "blocked_keyword_detected" + return None + + def detect_state( fetch_result: FetchResult, publications: list[PublicationCandidate], @@ -312,15 +643,19 @@ def detect_state( visible_text: str, ) -> tuple[ParseState, str]: if fetch_result.status_code is None: - return ParseState.NETWORK_ERROR, "network_error_missing_status_code" + return ParseState.NETWORK_ERROR, classify_network_error_reason(fetch_result.error) lowered = fetch_result.body.lower() final = (fetch_result.final_url or "").lower() + status_code = int(fetch_result.status_code) - if "accounts.google.com" in final and ("signin" in final or "servicelogin" in final): - return ParseState.BLOCKED_OR_CAPTCHA, "blocked_accounts_redirect" - if any(keyword in lowered for keyword in BLOCKED_KEYWORDS) or "sorry/index" in final: - return ParseState.BLOCKED_OR_CAPTCHA, "blocked_keyword_detected" + block_reason = classify_block_or_captcha_reason( + status_code=status_code, + final_url=final, + body_lowered=lowered, + ) + if block_reason is not None: + return ParseState.BLOCKED_OR_CAPTCHA, block_reason if not publications and any(keyword in visible_text for keyword in NO_RESULTS_KEYWORDS): return ParseState.NO_RESULTS, "no_results_keyword_detected" @@ -335,6 +670,40 @@ def detect_state( return ParseState.OK, "publications_extracted" +def detect_author_search_state( + fetch_result: FetchResult, + candidates: list[ScholarSearchCandidate], + marker_counts: dict[str, int], + *, + visible_text: str, +) -> tuple[ParseState, str]: + if fetch_result.status_code is None: + return ParseState.NETWORK_ERROR, classify_network_error_reason(fetch_result.error) + + lowered = fetch_result.body.lower() + final = (fetch_result.final_url or "").lower() + status_code = int(fetch_result.status_code) + + block_reason = classify_block_or_captcha_reason( + status_code=status_code, + final_url=final, + body_lowered=lowered, + ) + if block_reason is not None: + return ParseState.BLOCKED_OR_CAPTCHA, block_reason + + if not candidates and any(keyword in visible_text for keyword in NO_AUTHOR_RESULTS_KEYWORDS): + return ParseState.NO_RESULTS, "no_results_keyword_detected" + + if not candidates: + has_search_markers = marker_counts.get("gsc_1usr", 0) > 0 or marker_counts.get("gs_ai_name", 0) > 0 + if not has_search_markers: + return ParseState.NO_RESULTS, "no_search_candidates_detected" + return ParseState.OK, "search_markers_present_with_empty_results" + + return ParseState.OK, "author_candidates_extracted" + + def parse_profile_page(fetch_result: FetchResult) -> ParsedProfilePage: publications, warnings = parse_publications(fetch_result.body) marker_counts = count_markers(fetch_result.body) @@ -361,6 +730,7 @@ def parse_profile_page(fetch_result: FetchResult) -> ParsedProfilePage: state=state, state_reason=state_reason, profile_name=extract_profile_name(fetch_result.body), + profile_image_url=extract_profile_image_url(fetch_result.body), publications=publications, marker_counts=marker_counts, warnings=warnings, @@ -368,3 +738,29 @@ def parse_profile_page(fetch_result: FetchResult) -> ParsedProfilePage: has_operation_error_banner=operation_error_banner, articles_range=extract_articles_range(fetch_result.body), ) + + +def parse_author_search_page(fetch_result: FetchResult) -> ParsedAuthorSearchPage: + parser = ScholarAuthorSearchParser() + parser.feed(fetch_result.body) + + marker_counts = count_author_search_markers(fetch_result.body) + visible_text = strip_tags(SCRIPT_STYLE_RE.sub(" ", fetch_result.body)).lower() + warnings: list[str] = [] + if not parser.candidates: + warnings.append("no_author_candidates_detected") + + state, state_reason = detect_author_search_state( + fetch_result, + parser.candidates, + marker_counts, + visible_text=visible_text, + ) + + return ParsedAuthorSearchPage( + state=state, + state_reason=state_reason, + candidates=parser.candidates, + marker_counts=marker_counts, + warnings=warnings, + ) diff --git a/app/services/scholar_source.py b/app/services/scholar_source.py index 75e260a..327d1c4 100644 --- a/app/services/scholar_source.py +++ b/app/services/scholar_source.py @@ -52,6 +52,14 @@ class ScholarSource(Protocol): ) -> FetchResult: ... + async def fetch_author_search_html( + self, + query: str, + *, + start: int, + ) -> FetchResult: + ... + class LiveScholarSource: def __init__( @@ -94,6 +102,27 @@ class LiveScholarSource: ) return await asyncio.to_thread(self._fetch_sync, requested_url) + async def fetch_author_search_html( + self, + query: str, + *, + start: int = 0, + ) -> FetchResult: + requested_url = _build_author_search_url( + query=query, + start=start, + ) + logger.debug( + "scholar_source.search_fetch_started", + extra={ + "event": "scholar_source.search_fetch_started", + "query": query, + "requested_url": requested_url, + "start": start, + }, + ) + return await asyncio.to_thread(self._fetch_sync, requested_url) + def _fetch_sync(self, requested_url: str) -> FetchResult: request = Request( requested_url, @@ -169,3 +198,14 @@ def _build_profile_url(*, scholar_id: str, cstart: int, pagesize: int) -> str: if pagesize > 0 and cstart > 0: query["pagesize"] = int(pagesize) return f"{SCHOLAR_PROFILE_URL}?{urlencode(query)}" + + +def _build_author_search_url(*, query: str, start: int) -> str: + params: dict[str, int | str] = { + "hl": "en", + "view_op": "search_authors", + "mauthors": query, + } + if start > 0: + params["astart"] = int(start) + return f"{SCHOLAR_PROFILE_URL}?{urlencode(params)}" diff --git a/app/services/scholars.py b/app/services/scholars.py index aacf210..d5f9569 100644 --- a/app/services/scholars.py +++ b/app/services/scholars.py @@ -1,14 +1,106 @@ from __future__ import annotations +import asyncio +from collections import OrderedDict +from dataclasses import dataclass +from dataclasses import replace +from datetime import datetime +from datetime import timedelta +from datetime import timezone +import logging +import os +import random import re +import time +from pathlib import Path +from urllib.parse import urlparse +from uuid import uuid4 from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import ScholarProfile +from app.services.scholar_parser import ( + ParseState, + ParsedAuthorSearchPage, + parse_author_search_page, + parse_profile_page, +) +from app.services.scholar_source import ScholarSource SCHOLAR_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{12}$") +MAX_IMAGE_URL_LENGTH = 2048 +MAX_AUTHOR_SEARCH_LIMIT = 25 +DEFAULT_AUTHOR_SEARCH_CACHE_MAX_ENTRIES = 512 +DEFAULT_AUTHOR_SEARCH_BLOCKED_CACHE_TTL_SECONDS = 300 +DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD = 1 +DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS = 1800 +DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS = 3.0 +DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS = 1.0 +ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", +} +SEARCH_DISABLED_REASON = "search_disabled_by_configuration" +SEARCH_COOLDOWN_REASON = "search_temporarily_disabled_due_to_repeated_blocks" +SEARCH_CACHED_BLOCK_REASON = "search_temporarily_disabled_from_cached_blocked_response" + +STATE_REASON_HINTS: dict[str, str] = { + SEARCH_DISABLED_REASON: ( + "Scholar name search is currently disabled by service policy. " + "Add scholars by profile URL or Scholar ID." + ), + SEARCH_COOLDOWN_REASON: ( + "Scholar name search is temporarily paused after repeated block responses. " + "Use Scholar URL/ID adds until cooldown expires." + ), + SEARCH_CACHED_BLOCK_REASON: ( + "A recent blocked response was cached to reduce traffic. " + "Retry later or add by Scholar URL/ID." + ), + "network_dns_resolution_failed": ( + "DNS resolution failed while reaching scholar.google.com. " + "Verify container DNS/network and retry." + ), + "network_timeout": ( + "Request timed out before Google Scholar responded. " + "Increase delay/backoff and retry." + ), + "network_tls_error": ( + "TLS handshake/certificate validation failed. " + "Verify outbound TLS/network configuration." + ), + "blocked_http_429_rate_limited": ( + "Google Scholar rate-limited the request. " + "Slow request cadence and retry later." + ), + "blocked_unusual_traffic_detected": ( + "Google Scholar flagged traffic as unusual. " + "Increase delay/jitter and reduce concurrent scraping." + ), + "blocked_accounts_redirect": ( + "Request was redirected to Google Account sign-in. " + "Treat as access block and retry later." + ), +} +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _AuthorSearchCacheEntry: + parsed: ParsedAuthorSearchPage + expires_at_monotonic: float + cached_at_utc: datetime + + +_AUTHOR_SEARCH_EXECUTION_LOCK = asyncio.Lock() +_AUTHOR_SEARCH_CACHE: OrderedDict[str, _AuthorSearchCacheEntry] = OrderedDict() +_AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = 0.0 +_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC: datetime | None = None +_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0 class ScholarServiceError(ValueError): @@ -27,6 +119,174 @@ def normalize_display_name(value: str) -> str | None: return normalized if normalized else None +def normalize_profile_image_url(value: str | None) -> str | None: + if value is None: + return None + + candidate = value.strip() + if not candidate: + return None + + if len(candidate) > MAX_IMAGE_URL_LENGTH: + raise ScholarServiceError( + f"Image URL must be {MAX_IMAGE_URL_LENGTH} characters or fewer." + ) + + parsed = urlparse(candidate) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: + raise ScholarServiceError("Image URL must be an absolute http(s) URL.") + + return candidate + + +def _ensure_upload_root(upload_dir: str, *, create: bool) -> Path: + root = Path(upload_dir).expanduser().resolve() + if create: + root.mkdir(parents=True, exist_ok=True) + return root + + +def _resolve_upload_path(upload_root: Path, relative_path: str) -> Path: + candidate = (upload_root / relative_path).resolve() + if upload_root != candidate and upload_root not in candidate.parents: + raise ScholarServiceError("Invalid scholar image path.") + return candidate + + +def _safe_remove_upload(upload_root: Path, relative_path: str | None) -> None: + if not relative_path: + return + try: + file_path = _resolve_upload_path(upload_root, relative_path) + except ScholarServiceError: + return + + try: + if file_path.exists() and file_path.is_file(): + file_path.unlink() + except OSError: + return + + +def resolve_profile_image( + profile: ScholarProfile, + *, + uploaded_image_url: str | None, +) -> tuple[str | None, str]: + if profile.profile_image_upload_path and uploaded_image_url: + return uploaded_image_url, "upload" + if profile.profile_image_override_url: + return profile.profile_image_override_url, "override" + if profile.profile_image_url: + return profile.profile_image_url, "scraped" + return None, "none" + + +def resolve_upload_file_path(*, upload_dir: str, relative_path: str) -> Path: + root = _ensure_upload_root(upload_dir, create=False) + return _resolve_upload_path(root, relative_path) + + +def scrape_state_hint(*, state: ParseState, state_reason: str) -> str | None: + if state not in {ParseState.NETWORK_ERROR, ParseState.BLOCKED_OR_CAPTCHA}: + return None + return STATE_REASON_HINTS.get(state_reason) + + +def _merge_warnings(base: list[str], extra: list[str]) -> list[str]: + if not extra: + return sorted(set(base)) + return sorted(set(base + extra)) + + +def _trim_author_search_result( + parsed: ParsedAuthorSearchPage, + *, + limit: int, + extra_warnings: list[str] | None = None, + state_reason_override: str | None = None, +) -> ParsedAuthorSearchPage: + return ParsedAuthorSearchPage( + state=parsed.state, + state_reason=state_reason_override or parsed.state_reason, + candidates=parsed.candidates[: max(1, min(int(limit), MAX_AUTHOR_SEARCH_LIMIT))], + marker_counts=parsed.marker_counts, + warnings=_merge_warnings(parsed.warnings, extra_warnings or []), + ) + + +def _policy_blocked_author_search_result( + *, + reason: str, + warning_codes: list[str], + limit: int, +) -> ParsedAuthorSearchPage: + _ = limit + return ParsedAuthorSearchPage( + state=ParseState.BLOCKED_OR_CAPTCHA, + state_reason=reason, + candidates=[], + marker_counts={}, + warnings=_merge_warnings([], warning_codes), + ) + + +def _cache_get_author_search_result(query_key: str, now_monotonic: float) -> _AuthorSearchCacheEntry | None: + entry = _AUTHOR_SEARCH_CACHE.get(query_key) + if entry is None: + return None + if entry.expires_at_monotonic <= now_monotonic: + _AUTHOR_SEARCH_CACHE.pop(query_key, None) + return None + _AUTHOR_SEARCH_CACHE.move_to_end(query_key) + return entry + + +def _cache_set_author_search_result( + *, + query_key: str, + parsed: ParsedAuthorSearchPage, + ttl_seconds: float, + max_entries: int, +) -> None: + ttl = max(float(ttl_seconds), 0.0) + if ttl <= 0.0: + _AUTHOR_SEARCH_CACHE.pop(query_key, None) + return + + _AUTHOR_SEARCH_CACHE[query_key] = _AuthorSearchCacheEntry( + parsed=parsed, + expires_at_monotonic=time.monotonic() + ttl, + cached_at_utc=datetime.now(timezone.utc), + ) + _AUTHOR_SEARCH_CACHE.move_to_end(query_key) + + bounded_max_entries = max(1, int(max_entries)) + while len(_AUTHOR_SEARCH_CACHE) > bounded_max_entries: + _AUTHOR_SEARCH_CACHE.popitem(last=False) + + +def _is_author_search_block_state(parsed: ParsedAuthorSearchPage) -> bool: + return parsed.state == ParseState.BLOCKED_OR_CAPTCHA + + +def _author_search_cooldown_remaining_seconds(now_utc: datetime) -> int: + if _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC is None: + return 0 + remaining_seconds = int((_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC - now_utc).total_seconds()) + return max(0, remaining_seconds) + + +def _reset_author_search_runtime_state_for_tests() -> None: + global _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC + global _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC + global _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT + _AUTHOR_SEARCH_CACHE.clear() + _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = 0.0 + _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC = None + _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0 + + async def list_scholars_for_user( db_session: AsyncSession, *, @@ -46,11 +306,13 @@ async def create_scholar_for_user( user_id: int, scholar_id: str, display_name: str, + profile_image_url: str | None = None, ) -> ScholarProfile: profile = ScholarProfile( user_id=user_id, scholar_id=validate_scholar_id(scholar_id), display_name=normalize_display_name(display_name), + profile_image_url=normalize_profile_image_url(profile_image_url), ) db_session.add(profile) try: @@ -92,7 +354,293 @@ async def delete_scholar( db_session: AsyncSession, *, profile: ScholarProfile, + upload_dir: str | None = None, ) -> None: + if upload_dir: + upload_root = _ensure_upload_root(upload_dir, create=True) + _safe_remove_upload(upload_root, profile.profile_image_upload_path) + await db_session.delete(profile) await db_session.commit() + +async def search_author_candidates( + *, + source: ScholarSource, + query: str, + limit: int, + network_error_retries: int = 1, + retry_backoff_seconds: float = 1.0, + search_enabled: bool = True, + cache_ttl_seconds: int = 21_600, + blocked_cache_ttl_seconds: int = DEFAULT_AUTHOR_SEARCH_BLOCKED_CACHE_TTL_SECONDS, + cache_max_entries: int = DEFAULT_AUTHOR_SEARCH_CACHE_MAX_ENTRIES, + min_interval_seconds: float = DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS, + interval_jitter_seconds: float = DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS, + cooldown_block_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD, + cooldown_seconds: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS, +) -> ParsedAuthorSearchPage: + global _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC + global _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC + global _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT + + normalized_query = query.strip() + if len(normalized_query) < 2: + raise ScholarServiceError("Search query must be at least 2 characters.") + bounded_limit = max(1, min(int(limit), MAX_AUTHOR_SEARCH_LIMIT)) + query_key = normalized_query.casefold() + + if not search_enabled: + logger.warning( + "scholar_search.disabled_by_configuration", + extra={ + "event": "scholar_search.disabled_by_configuration", + "query": normalized_query, + }, + ) + return _policy_blocked_author_search_result( + reason=SEARCH_DISABLED_REASON, + warning_codes=["author_search_disabled_by_configuration"], + limit=bounded_limit, + ) + + async with _AUTHOR_SEARCH_EXECUTION_LOCK: + now_utc = datetime.now(timezone.utc) + now_monotonic = time.monotonic() + + cooldown_remaining_seconds = _author_search_cooldown_remaining_seconds(now_utc) + if cooldown_remaining_seconds > 0: + logger.warning( + "scholar_search.cooldown_active", + extra={ + "event": "scholar_search.cooldown_active", + "query": normalized_query, + "cooldown_remaining_seconds": cooldown_remaining_seconds, + "cooldown_until_utc": _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC.isoformat() + if _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC + else None, + }, + ) + return _policy_blocked_author_search_result( + reason=SEARCH_COOLDOWN_REASON, + warning_codes=[ + "author_search_cooldown_active", + f"author_search_cooldown_remaining_{cooldown_remaining_seconds}s", + ], + limit=bounded_limit, + ) + + cached_entry = _cache_get_author_search_result(query_key, now_monotonic) + if cached_entry is not None: + cached = cached_entry.parsed + state_reason_override = ( + SEARCH_CACHED_BLOCK_REASON if _is_author_search_block_state(cached) else None + ) + logger.info( + "scholar_search.cache_hit", + extra={ + "event": "scholar_search.cache_hit", + "query": normalized_query, + "state": cached.state.value, + "state_reason": cached.state_reason, + }, + ) + return _trim_author_search_result( + cached, + limit=bounded_limit, + extra_warnings=["author_search_served_from_cache"], + state_reason_override=state_reason_override, + ) + + enforced_wait_seconds = ( + (_AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC + max(float(min_interval_seconds), 0.0)) + - now_monotonic + ) + jitter_seconds = random.uniform(0.0, max(float(interval_jitter_seconds), 0.0)) + sleep_seconds = max(0.0, enforced_wait_seconds) + jitter_seconds + if sleep_seconds > 0.0: + logger.info( + "scholar_search.throttle_wait", + extra={ + "event": "scholar_search.throttle_wait", + "query": normalized_query, + "sleep_seconds": round(sleep_seconds, 3), + }, + ) + await asyncio.sleep(sleep_seconds) + + max_attempts = max(1, int(network_error_retries) + 1) + parsed: ParsedAuthorSearchPage | None = None + retry_warnings: list[str] = [] + + for attempt_index in range(max_attempts): + fetch_result = await source.fetch_author_search_html(normalized_query, start=0) + parsed = parse_author_search_page(fetch_result) + if parsed.state != ParseState.NETWORK_ERROR or attempt_index >= max_attempts - 1: + break + + retry_warnings.append("network_retry_scheduled_for_author_search") + sleep_seconds = max(float(retry_backoff_seconds), 0.0) * (2**attempt_index) + if sleep_seconds > 0: + await asyncio.sleep(sleep_seconds) + + _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = time.monotonic() + + if parsed is None: + raise ScholarServiceError("Unable to complete scholar author search.") + + merged_parsed = replace( + parsed, + warnings=_merge_warnings(parsed.warnings, retry_warnings), + ) + + if _is_author_search_block_state(merged_parsed): + _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT += 1 + logger.warning( + "scholar_search.block_detected", + extra={ + "event": "scholar_search.block_detected", + "query": normalized_query, + "state_reason": merged_parsed.state_reason, + "consecutive_blocked_count": _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT, + }, + ) + if _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT >= max(1, int(cooldown_block_threshold)): + _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC = datetime.now(timezone.utc) + timedelta( + seconds=max(60, int(cooldown_seconds)) + ) + _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0 + merged_parsed = replace( + merged_parsed, + warnings=_merge_warnings( + merged_parsed.warnings, + ["author_search_circuit_breaker_armed"], + ), + ) + logger.error( + "scholar_search.cooldown_activated", + extra={ + "event": "scholar_search.cooldown_activated", + "query": normalized_query, + "cooldown_until_utc": _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC.isoformat(), + }, + ) + else: + _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0 + + ttl_seconds = ( + min(max(1, int(blocked_cache_ttl_seconds)), max(1, int(cache_ttl_seconds))) + if _is_author_search_block_state(merged_parsed) + else max(1, int(cache_ttl_seconds)) + ) + _cache_set_author_search_result( + query_key=query_key, + parsed=merged_parsed, + ttl_seconds=float(ttl_seconds), + max_entries=cache_max_entries, + ) + + return _trim_author_search_result( + merged_parsed, + limit=bounded_limit, + ) + + +async def hydrate_profile_metadata( + db_session: AsyncSession, + *, + profile: ScholarProfile, + source: ScholarSource, +) -> ScholarProfile: + fetch_result = await source.fetch_profile_html(profile.scholar_id) + parsed_page = parse_profile_page(fetch_result) + + if parsed_page.profile_name and not (profile.display_name or "").strip(): + profile.display_name = parsed_page.profile_name + if parsed_page.profile_image_url and not profile.profile_image_url: + profile.profile_image_url = parsed_page.profile_image_url + + await db_session.commit() + await db_session.refresh(profile) + return profile + + +async def set_profile_image_override_url( + db_session: AsyncSession, + *, + profile: ScholarProfile, + image_url: str | None, + upload_dir: str, +) -> ScholarProfile: + upload_root = _ensure_upload_root(upload_dir, create=True) + _safe_remove_upload(upload_root, profile.profile_image_upload_path) + + profile.profile_image_upload_path = None + profile.profile_image_override_url = normalize_profile_image_url(image_url) + + await db_session.commit() + await db_session.refresh(profile) + return profile + + +async def clear_profile_image_customization( + db_session: AsyncSession, + *, + profile: ScholarProfile, + upload_dir: str, +) -> ScholarProfile: + upload_root = _ensure_upload_root(upload_dir, create=True) + _safe_remove_upload(upload_root, profile.profile_image_upload_path) + + profile.profile_image_upload_path = None + profile.profile_image_override_url = None + + await db_session.commit() + await db_session.refresh(profile) + return profile + + +async def set_profile_image_upload( + db_session: AsyncSession, + *, + profile: ScholarProfile, + content_type: str | None, + image_bytes: bytes, + upload_dir: str, + max_upload_bytes: int, +) -> ScholarProfile: + normalized_content_type = (content_type or "").strip().lower() + extension = ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES.get(normalized_content_type) + if extension is None: + raise ScholarServiceError( + "Unsupported image type. Use JPEG, PNG, WEBP, or GIF." + ) + + if not image_bytes: + raise ScholarServiceError("Uploaded image file is empty.") + + if len(image_bytes) > max_upload_bytes: + raise ScholarServiceError( + f"Uploaded image exceeds {max_upload_bytes} bytes." + ) + + upload_root = _ensure_upload_root(upload_dir, create=True) + user_dir = upload_root / str(profile.user_id) + user_dir.mkdir(parents=True, exist_ok=True) + + filename = f"{profile.id}_{uuid4().hex}{extension}" + relative_path = os.path.join(str(profile.user_id), filename) + absolute_path = _resolve_upload_path(upload_root, relative_path) + absolute_path.write_bytes(image_bytes) + + old_path = profile.profile_image_upload_path + profile.profile_image_upload_path = relative_path + profile.profile_image_override_url = None + + await db_session.commit() + await db_session.refresh(profile) + + if old_path and old_path != relative_path: + _safe_remove_upload(upload_root, old_path) + + return profile diff --git a/app/settings.py b/app/settings.py index 826fa13..ea292e0 100644 --- a/app/settings.py +++ b/app/settings.py @@ -48,7 +48,7 @@ class Settings: log_format: str = _env_str("LOG_FORMAT", "console") log_requests: bool = _env_bool("LOG_REQUESTS", True) log_uvicorn_access: bool = _env_bool("LOG_UVICORN_ACCESS", False) - log_request_skip_paths: str = _env_str("LOG_REQUEST_SKIP_PATHS", "/healthz,/static/") + log_request_skip_paths: str = _env_str("LOG_REQUEST_SKIP_PATHS", "/healthz") log_redact_fields: str = os.getenv("LOG_REDACT_FIELDS", "") scheduler_enabled: bool = _env_bool("SCHEDULER_ENABLED", True) scheduler_tick_seconds: int = _env_int("SCHEDULER_TICK_SECONDS", 60) @@ -79,6 +79,45 @@ class Settings: 6, ) scheduler_queue_batch_size: int = _env_int("SCHEDULER_QUEUE_BATCH_SIZE", 10) + frontend_enabled: bool = _env_bool("FRONTEND_ENABLED", True) + frontend_dist_dir: str = _env_str("FRONTEND_DIST_DIR", "/app/frontend/dist") + scholar_image_upload_dir: str = _env_str( + "SCHOLAR_IMAGE_UPLOAD_DIR", + "/var/lib/scholarr/uploads", + ) + scholar_image_upload_max_bytes: int = _env_int( + "SCHOLAR_IMAGE_UPLOAD_MAX_BYTES", + 2_000_000, + ) + scholar_name_search_enabled: bool = _env_bool("SCHOLAR_NAME_SEARCH_ENABLED", True) + scholar_name_search_cache_ttl_seconds: int = _env_int( + "SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS", + 21_600, + ) + scholar_name_search_blocked_cache_ttl_seconds: int = _env_int( + "SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS", + 300, + ) + scholar_name_search_cache_max_entries: int = _env_int( + "SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES", + 512, + ) + scholar_name_search_min_interval_seconds: float = _env_float( + "SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS", + 3.0, + ) + scholar_name_search_interval_jitter_seconds: float = _env_float( + "SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS", + 1.0, + ) + scholar_name_search_cooldown_block_threshold: int = _env_int( + "SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD", + 1, + ) + scholar_name_search_cooldown_seconds: int = _env_int( + "SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS", + 1800, + ) settings = Settings() diff --git a/app/static/app.css b/app/static/app.css deleted file mode 100644 index 3b332c5..0000000 --- a/app/static/app.css +++ /dev/null @@ -1,501 +0,0 @@ -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-height: 100vh; - color: var(--md-sys-color-on-surface); - font-family: var(--font-body); - background: - radial-gradient(circle at 10% 5%, var(--md-sys-color-primary-container) 0%, transparent 30%), - radial-gradient(circle at 90% 0%, var(--md-sys-color-secondary-container) 0%, transparent 28%), - var(--md-sys-color-surface); -} - -.loading-indicator { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 3px; - z-index: 40; - transform: scaleX(0); - transform-origin: left; - background: linear-gradient(90deg, var(--md-sys-color-primary), var(--md-sys-color-tertiary)); - opacity: 0; -} - -body.is-page-loading .loading-indicator { - opacity: 1; - animation: loading-bar 1.2s ease-in-out infinite; -} - -.app-backdrop { - position: fixed; - inset: 0; - pointer-events: none; - background: - linear-gradient(130deg, var(--md-sys-color-primary-tint), transparent 40%), - linear-gradient(230deg, var(--md-sys-color-surface-tint), transparent 45%); - opacity: 0.28; -} - -.top-app-bar { - position: sticky; - top: 0; - z-index: 10; - display: flex; - align-items: center; - gap: 0.8rem; - flex-wrap: wrap; - padding: 0.8rem 1.1rem; - border-bottom: 1px solid var(--md-sys-color-outline-variant); - backdrop-filter: blur(9px); - background: color-mix(in srgb, var(--md-sys-color-surface) 88%, white 12%); -} - -.brand-wrap { - display: flex; - align-items: center; - gap: 0.65rem; -} - -.brand { - color: var(--md-sys-color-primary); - text-decoration: none; - font-family: var(--font-heading); - font-weight: 700; - font-size: 1.2rem; - letter-spacing: 0.02em; -} - -.session-user { - margin: 0; - padding: 0.24rem 0.58rem; - border-radius: 999px; - border: 1px solid var(--md-sys-color-outline-variant); - background: var(--md-sys-color-surface-container-high); - color: var(--md-sys-color-on-surface-variant); - font-size: 0.78rem; -} - -.site-nav { - display: flex; - align-items: center; - gap: 0.35rem; - flex-wrap: wrap; -} - -.nav-link { - color: var(--md-sys-color-on-surface-variant); - text-decoration: none; - padding: 0.42rem 0.78rem; - border-radius: 999px; - border: 1px solid transparent; - font-size: 0.92rem; -} - -.nav-link:hover { - background: var(--md-sys-color-surface-container); - border-color: var(--md-sys-color-outline-variant); -} - -.nav-link.is-active { - color: var(--md-sys-color-on-secondary-container); - background: var(--md-sys-color-secondary-container); - border-color: transparent; -} - -.header-actions { - margin-left: auto; - display: flex; - align-items: center; - gap: 0.7rem; -} - -.theme-control-wrap { - display: flex; - align-items: center; - gap: 0.45rem; -} - -.theme-control-wrap label { - color: var(--md-sys-color-on-surface-variant); - font-size: 0.82rem; -} - -.theme-control { - border: 1px solid var(--md-sys-color-outline); - border-radius: 999px; - background: var(--md-sys-color-surface-container-low); - color: var(--md-sys-color-on-surface); - padding: 0.32rem 0.64rem; - font: inherit; -} - -.logout-form { - margin: 0; -} - -.page-shell { - position: relative; - display: grid; - gap: 0.9rem; - width: min(1100px, calc(100vw - 1.4rem)); - margin: 0 auto; - padding: 1.1rem 0 1.6rem; -} - -.flash { - margin: 0; - padding: 0.72rem 0.82rem; - border-radius: 14px; - border: 1px solid var(--md-sys-color-outline-variant); - background: var(--md-sys-color-surface-container); -} - -.flash-notice { - color: var(--md-sys-color-on-tertiary-container); - border-color: var(--md-sys-color-tertiary-container); - background: color-mix(in srgb, var(--md-sys-color-tertiary-container) 30%, white 70%); -} - -.flash-error { - color: var(--md-sys-color-on-error-container); - border-color: var(--md-sys-color-error-container); - background: color-mix(in srgb, var(--md-sys-color-error-container) 25%, white 75%); -} - -.hero, -.sandbox { - border-radius: 24px; - border: 1px solid var(--md-sys-color-outline-variant); - background: var(--md-sys-color-surface-container-low); - box-shadow: var(--elevation-card); -} - -.hero { - padding: 1.3rem; -} - -.sandbox { - padding: 1.05rem; -} - -.eyebrow { - margin: 0; - font-size: 0.78rem; - color: var(--md-sys-color-primary); - text-transform: uppercase; - letter-spacing: 0.11em; - font-weight: 600; -} - -h1 { - margin: 0.28rem 0 0.5rem; - color: var(--md-sys-color-on-surface); - font-family: var(--font-heading); - font-size: clamp(1.45rem, 3vw, 2.05rem); - line-height: 1.15; -} - -h2 { - margin: 0; - color: var(--md-sys-color-on-surface); - font-family: var(--font-heading); - font-size: 1.08rem; -} - -.lede { - margin: 0; - color: var(--md-sys-color-on-surface-variant); - max-width: 68ch; -} - -.section-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.55rem; - margin-bottom: 0.7rem; - flex-wrap: wrap; -} - -.stat-strip { - margin-top: 0.95rem; - display: grid; - gap: 0.75rem; - grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); -} - -.stat-item { - margin: 0; - padding: 0.72rem 0.78rem; - border-radius: 16px; - border: 1px solid var(--md-sys-color-outline-variant); - background: var(--md-sys-color-surface-container); -} - -.stat-label { - margin: 0; - color: var(--md-sys-color-on-surface-variant); - font-size: 0.8rem; -} - -.stat-value { - margin: 0.2rem 0 0; - color: var(--md-sys-color-on-surface); - font-size: 1.36rem; - font-weight: 700; -} - -form { - display: grid; - gap: 0.45rem; -} - -.stack-form { - max-width: 560px; -} - -label { - font-size: 0.9rem; - color: var(--md-sys-color-on-surface-variant); -} - -input, -select { - width: 100%; - border: 1px solid var(--md-sys-color-outline); - border-radius: 12px; - padding: 0.56rem 0.64rem; - font: inherit; - color: var(--md-sys-color-on-surface); - background: var(--md-sys-color-surface); -} - -input:focus, -select:focus { - outline: 2px solid color-mix(in srgb, var(--md-sys-color-primary) 35%, transparent); - border-color: var(--md-sys-color-primary); -} - -.checkbox-row { - display: flex; - align-items: center; - gap: 0.54rem; -} - -.checkbox-row input { - width: auto; -} - -button, -.button { - border: 0; - border-radius: 999px; - padding: 0.54rem 0.86rem; - font: inherit; - font-weight: 600; - color: var(--md-sys-color-on-primary); - background: var(--md-sys-color-primary); - cursor: pointer; -} - -button:hover, -.button:hover { - filter: brightness(0.97); -} - -button:disabled, -.button:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -.button-text { - color: var(--md-sys-color-primary); - background: transparent; - border: 1px solid var(--md-sys-color-outline-variant); -} - -.danger-button { - color: var(--md-sys-color-on-error); - background: var(--md-sys-color-error); -} - -.inline-actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.45rem; -} - -.inline-actions form { - display: inline-flex; - gap: 0.42rem; - align-items: center; -} - -.inline-reset-form input { - min-width: 160px; -} - -form.is-loading button[type="submit"], -form.is-loading input[type="submit"] { - opacity: 0.75; -} - -table { - width: 100%; - border-collapse: collapse; - border-radius: 12px; - overflow: hidden; - background: var(--md-sys-color-surface); -} - -th, -td { - padding: 0.54rem; - text-align: left; - border-bottom: 1px solid var(--md-sys-color-outline-variant); - vertical-align: top; -} - -thead th { - color: var(--md-sys-color-on-surface-variant); - font-size: 0.84rem; - font-weight: 600; - background: var(--md-sys-color-surface-container-high); -} - -tbody tr:last-child td { - border-bottom: 0; -} - -a { - color: var(--md-sys-color-primary); -} - -.link-button { - display: inline-flex; - align-items: center; - padding: 0.45rem 0.78rem; - border: 1px solid var(--md-sys-color-outline-variant); - border-radius: 999px; - color: var(--md-sys-color-primary); - text-decoration: none; - background: var(--md-sys-color-surface-container-low); -} - -.link-button.is-active { - color: var(--md-sys-color-on-secondary-container); - background: var(--md-sys-color-secondary-container); - border-color: transparent; -} - -.badge { - display: inline-block; - border-radius: 999px; - padding: 0.16rem 0.48rem; - font-size: 0.76rem; - text-transform: lowercase; - border: 1px solid var(--md-sys-color-outline-variant); -} - -.badge.ok { - color: var(--md-sys-color-on-tertiary-container); - background: var(--md-sys-color-tertiary-container); - border-color: transparent; -} - -.badge.warn, -.badge.danger { - color: var(--md-sys-color-on-error-container); - background: var(--md-sys-color-error-container); - border-color: transparent; -} - -.helper-text { - margin: 0.24rem 0 0; - color: var(--md-sys-color-on-surface-variant); - font-size: 0.82rem; -} - -.auth-card { - max-width: 520px; -} - -.auth-form { - max-width: 360px; -} - -.form-error { - margin: 0.5rem 0 0; - color: var(--md-sys-color-error); - font-weight: 600; -} - -.form-note { - margin: 0.35rem 0 0; - color: var(--md-sys-color-on-surface-variant); -} - -code { - font-family: var(--font-code); - background: var(--md-sys-color-surface-container-high); - border-radius: 6px; - padding: 0.1rem 0.25rem; -} - -pre { - margin: 0.6rem 0 0; - border-radius: 12px; - border: 1px solid var(--md-sys-color-outline-variant); - background: var(--md-sys-color-surface-container-high); - color: var(--md-sys-color-on-surface); - padding: 0.75rem; - white-space: pre-wrap; - word-break: break-word; -} - -@media (max-width: 880px) { - .top-app-bar { - padding: 0.7rem 0.8rem; - } - - .header-actions { - width: 100%; - justify-content: space-between; - margin-left: 0; - } - - .page-shell { - width: min(100vw, calc(100vw - 0.9rem)); - padding-top: 0.8rem; - } - - .hero, - .sandbox { - border-radius: 18px; - padding: 0.9rem; - } - - .inline-actions form { - width: 100%; - justify-content: flex-start; - } -} - -@keyframes loading-bar { - 0% { - transform: scaleX(0.1); - } - 50% { - transform: scaleX(0.7); - } - 100% { - transform: scaleX(1); - } -} diff --git a/app/static/app.js b/app/static/app.js deleted file mode 100644 index a2d6d80..0000000 --- a/app/static/app.js +++ /dev/null @@ -1,76 +0,0 @@ -(() => { - const body = document.body; - if (!body) { - return; - } - - const defaultLoadingText = "Working..."; - - const shouldHandleAnchor = (anchor) => { - if (!anchor || !anchor.getAttribute) { - return false; - } - const href = anchor.getAttribute("href"); - if (!href) { - return false; - } - if (href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("javascript:")) { - return false; - } - if (anchor.hasAttribute("download")) { - return false; - } - if ((anchor.getAttribute("target") || "").toLowerCase() === "_blank") { - return false; - } - return true; - }; - - document.addEventListener("click", (event) => { - const target = event.target; - if (!(target instanceof Element)) { - return; - } - const anchor = target.closest("a"); - if (!shouldHandleAnchor(anchor)) { - return; - } - body.classList.add("is-page-loading"); - }); - - document.addEventListener("submit", (event) => { - const form = event.target; - if (!(form instanceof HTMLFormElement)) { - return; - } - if (form.dataset.noLoading === "1") { - return; - } - - body.classList.add("is-page-loading"); - form.classList.add("is-loading"); - - const submitElements = form.querySelectorAll("button[type='submit'], input[type='submit']"); - submitElements.forEach((element) => { - if (element.hasAttribute("disabled")) { - return; - } - element.setAttribute("disabled", "disabled"); - if (element instanceof HTMLInputElement) { - const loadingText = element.dataset.loadingText || defaultLoadingText; - element.dataset.originalValue = element.value; - element.value = loadingText; - return; - } - if (element instanceof HTMLButtonElement) { - const loadingText = element.dataset.loadingText || defaultLoadingText; - element.dataset.originalText = element.textContent || ""; - element.textContent = loadingText; - } - }); - }); - - window.addEventListener("pageshow", () => { - body.classList.remove("is-page-loading"); - }); -})(); diff --git a/app/static/theme.css b/app/static/theme.css deleted file mode 100644 index 8506a29..0000000 --- a/app/static/theme.css +++ /dev/null @@ -1,98 +0,0 @@ -:root { - --font-body: "Plus Jakarta Sans", "Segoe UI", sans-serif; - --font-heading: "DM Serif Text", "Georgia", serif; - --font-code: "JetBrains Mono", "Fira Code", monospace; - - --md-sys-color-primary: #73594d; - --md-sys-color-on-primary: #ffffff; - --md-sys-color-primary-container: #fbded0; - --md-sys-color-on-primary-container: #2b160f; - --md-sys-color-primary-tint: rgba(115, 89, 77, 0.18); - - --md-sys-color-secondary: #6f5b52; - --md-sys-color-on-secondary: #ffffff; - --md-sys-color-secondary-container: #f8ddd2; - --md-sys-color-on-secondary-container: #281913; - - --md-sys-color-tertiary: #5b6550; - --md-sys-color-on-tertiary: #ffffff; - --md-sys-color-tertiary-container: #ddebcf; - --md-sys-color-on-tertiary-container: #17200f; - - --md-sys-color-error: #b3261e; - --md-sys-color-on-error: #ffffff; - --md-sys-color-error-container: #f9dedc; - --md-sys-color-on-error-container: #410e0b; - - --md-sys-color-surface: #fff8f5; - --md-sys-color-surface-tint: rgba(111, 87, 74, 0.12); - --md-sys-color-surface-container-low: #fffaf8; - --md-sys-color-surface-container: #fdf2ec; - --md-sys-color-surface-container-high: #f8e9e1; - --md-sys-color-on-surface: #221a17; - --md-sys-color-on-surface-variant: #55423a; - --md-sys-color-outline: #85736b; - --md-sys-color-outline-variant: #d8c2b8; - - --elevation-card: 0 10px 30px rgba(55, 39, 28, 0.08); -} - -:root[data-theme="fjord"] { - --md-sys-color-primary: #2f6678; - --md-sys-color-on-primary: #ffffff; - --md-sys-color-primary-container: #cde9f4; - --md-sys-color-on-primary-container: #051f28; - --md-sys-color-primary-tint: rgba(47, 102, 120, 0.2); - - --md-sys-color-secondary: #4d626b; - --md-sys-color-on-secondary: #ffffff; - --md-sys-color-secondary-container: #d0e6f2; - --md-sys-color-on-secondary-container: #091f27; - - --md-sys-color-tertiary: #456179; - --md-sys-color-on-tertiary: #ffffff; - --md-sys-color-tertiary-container: #d2e5ff; - --md-sys-color-on-tertiary-container: #031d33; - - --md-sys-color-surface: #f5fbff; - --md-sys-color-surface-tint: rgba(47, 102, 120, 0.12); - --md-sys-color-surface-container-low: #fcfeff; - --md-sys-color-surface-container: #ecf5fa; - --md-sys-color-surface-container-high: #dfeef6; - --md-sys-color-on-surface: #172126; - --md-sys-color-on-surface-variant: #3f4f57; - --md-sys-color-outline: #6f7f88; - --md-sys-color-outline-variant: #becfd8; - - --elevation-card: 0 10px 30px rgba(22, 43, 53, 0.09); -} - -:root[data-theme="spruce"] { - --md-sys-color-primary: #39684c; - --md-sys-color-on-primary: #ffffff; - --md-sys-color-primary-container: #bcefc9; - --md-sys-color-on-primary-container: #062111; - --md-sys-color-primary-tint: rgba(57, 104, 76, 0.18); - - --md-sys-color-secondary: #4f6354; - --md-sys-color-on-secondary: #ffffff; - --md-sys-color-secondary-container: #d2e8d4; - --md-sys-color-on-secondary-container: #0d1f12; - - --md-sys-color-tertiary: #3f655f; - --md-sys-color-on-tertiary: #ffffff; - --md-sys-color-tertiary-container: #c2ece4; - --md-sys-color-on-tertiary-container: #021f1a; - - --md-sys-color-surface: #f5fbf5; - --md-sys-color-surface-tint: rgba(57, 104, 76, 0.1); - --md-sys-color-surface-container-low: #fcfffb; - --md-sys-color-surface-container: #ebf4ea; - --md-sys-color-surface-container-high: #deecdd; - --md-sys-color-on-surface: #172119; - --md-sys-color-on-surface-variant: #415246; - --md-sys-color-outline: #708174; - --md-sys-color-outline-variant: #c1d3c2; - - --elevation-card: 0 10px 30px rgba(24, 48, 32, 0.08); -} diff --git a/app/static/theme.js b/app/static/theme.js deleted file mode 100644 index 47b95a1..0000000 --- a/app/static/theme.js +++ /dev/null @@ -1,56 +0,0 @@ -(() => { - const STORAGE_KEY = "scholarr_theme"; - const LEGACY_STORAGE_KEY = "scholar_tracker_theme"; - const root = document.documentElement; - const themeControl = document.querySelector("[data-theme-control]"); - - if (!root) { - return; - } - - const defaultTheme = root.dataset.defaultTheme || "terracotta"; - const supportedThemes = themeControl - ? Array.from(themeControl.options).map((option) => option.value) - : []; - - const isSupported = (theme) => - supportedThemes.length === 0 || supportedThemes.includes(theme); - - const applyTheme = (theme) => { - if (!isSupported(theme)) { - return; - } - root.dataset.theme = theme; - }; - - let activeTheme = defaultTheme; - try { - const savedTheme = window.localStorage.getItem(STORAGE_KEY); - const legacyTheme = window.localStorage.getItem(LEGACY_STORAGE_KEY); - if (savedTheme && isSupported(savedTheme)) { - activeTheme = savedTheme; - } else if (legacyTheme && isSupported(legacyTheme)) { - activeTheme = legacyTheme; - window.localStorage.setItem(STORAGE_KEY, legacyTheme); - } - } catch { - activeTheme = defaultTheme; - } - - applyTheme(activeTheme); - - if (!themeControl) { - return; - } - - themeControl.value = activeTheme; - themeControl.addEventListener("change", (event) => { - const selectedTheme = event.target.value; - applyTheme(selectedTheme); - try { - window.localStorage.setItem(STORAGE_KEY, selectedTheme); - } catch { - // Ignore storage errors in locked-down browsers. - } - }); -})(); diff --git a/app/templates/account_password.html b/app/templates/account_password.html deleted file mode 100644 index 687ec98..0000000 --- a/app/templates/account_password.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Account

-

Change Password

-

Use a strong password. This updates only your own account.

-
- -
-

Password Update

-
- - - - - - - - -
-
-{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html deleted file mode 100644 index fcdd653..0000000 --- a/app/templates/base.html +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - {{ page_title }} | scholarr - - - - - - -
-
- scholarr - {% if session_user %} -

{{ session_user.email }}

- {% endif %} -
- - - -
-
- - -
- {% if session_user %} -
- - -
- {% endif %} -
-
-
- {% if notice %} -

{{ notice }}

- {% endif %} - {% if page_error %} -

{{ page_error }}

- {% endif %} - {% block content %}{% endblock %} -
- - - - diff --git a/app/templates/dashboard/_run_controls.html b/app/templates/dashboard/_run_controls.html deleted file mode 100644 index 24779cd..0000000 --- a/app/templates/dashboard/_run_controls.html +++ /dev/null @@ -1,23 +0,0 @@ -
- -

Manual runs use your request delay of {{ dashboard.run_controls.request_delay_seconds }} second(s).

-

- Queue state: - {{ dashboard.run_controls.queue_queued_count }} queued, - {{ dashboard.run_controls.queue_retrying_count }} retrying, - {{ dashboard.run_controls.queue_dropped_count }} dropped. -

-
- - -
-
diff --git a/app/templates/dashboard/_run_history.html b/app/templates/dashboard/_run_history.html deleted file mode 100644 index aa6f9f1..0000000 --- a/app/templates/dashboard/_run_history.html +++ /dev/null @@ -1,38 +0,0 @@ -
-
-

Run History

- -
- {% if dashboard.run_history %} - - - - - - - - - - - {% for run in dashboard.run_history %} - - - - - - - {% endfor %} - -
StartedStatusScholarsNew Publications
- {% if run.detail_url %} - {{ run.started_at_display }} - {% else %} - {{ run.started_at_display }} - {% endif %} - {{ run.status_label }}{{ run.scholar_count }}{{ run.new_publication_count }}
- {% else %} -

No runs yet.

- {% endif %} -
diff --git a/app/templates/dashboard/_unread_publications.html b/app/templates/dashboard/_unread_publications.html deleted file mode 100644 index 1354215..0000000 --- a/app/templates/dashboard/_unread_publications.html +++ /dev/null @@ -1,47 +0,0 @@ -
-
-

New Since Last Run

-
- View All -
- - - -
-
-
- - {% if dashboard.unread_publications %} - - - - - - - - - - - {% for item in dashboard.unread_publications %} - - - - - - - {% endfor %} - -
TitleScholarYearCitations
- {% if item.pub_url %} - {{ item.title }} - {% else %} - {{ item.title }} - {% endif %} - {% if item.venue_text %} -

{{ item.venue_text }}

- {% endif %} -
{{ item.scholar_label }}{{ item.year_display }}{{ item.citation_count }}
- {% else %} -

No new publications in the latest run.

- {% endif %} -
diff --git a/app/templates/index.html b/app/templates/index.html deleted file mode 100644 index f5fd22a..0000000 --- a/app/templates/index.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Dashboard

-

Keep up with your tracked scholars

-

Run ingestion, review fresh publications, and inspect recent outcomes from one screen.

-
-
-

New Since Last Run

-

{{ dashboard.unread_publications | length }}

-
-
-

Recent Runs

-

{{ dashboard.run_history | length }}

-
-
-

Automation Delay

-

{{ dashboard.run_controls.request_delay_seconds }}s

-
-
-
- -{% for section_template in dashboard.section_templates %} -{% include section_template %} -{% endfor %} -{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html deleted file mode 100644 index 8b0f383..0000000 --- a/app/templates/login.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Secure Access

-

Sign in

-

Use an admin-created account to access your tracked scholars and settings.

- {% if error_message %} - - {% endif %} - {% if retry_after_seconds %} -

Retry after {{ retry_after_seconds }} seconds.

- {% endif %} -
- - - - - - -
-
-{% endblock %} diff --git a/app/templates/publications.html b/app/templates/publications.html deleted file mode 100644 index 89054a2..0000000 --- a/app/templates/publications.html +++ /dev/null @@ -1,104 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Publications

-

Publications

-

- Browse publications discovered in the latest run or your complete tracked library. - {% if selected_scholar %} - Current scholar filter: {{ selected_scholar.display_name or selected_scholar.scholar_id }}. - {% endif %} -

-
- New Since Last Run ({{ new_count }}) - All ({{ total_count }}) - Manage Scholars - Run Diagnostics - {% if mode == 'new' and new_count > 0 %} -
- - - -
- {% endif %} -
-
- -
-
-

Filter

-
-
- - - - - - -
- -
-

Results

-
- {% if publications %} - - - - - - - - - - - - - {% for item in publications %} - - - - - - - - - {% endfor %} - -
TitleScholarYearCitationsNew This RunStatus
- {% if item.pub_url %} - {{ item.title }} - {% else %} - {{ item.title }} - {% endif %} - {% if item.venue_text %} -

{{ item.venue_text }}

- {% endif %} -
{{ item.scholar_label }}{{ item.year if item.year is not none else "-" }}{{ item.citation_count }} - {% if item.is_new_in_latest_run %} - new - {% else %} - older - {% endif %} - - {% if item.is_read %} - read - {% else %} - new - {% endif %} -
- {% else %} -

No publications match this mode yet.

- {% endif %} -
-{% endblock %} diff --git a/app/templates/run_detail.html b/app/templates/run_detail.html deleted file mode 100644 index 06920ee..0000000 --- a/app/templates/run_detail.html +++ /dev/null @@ -1,98 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Diagnostics

-

Run #{{ run.id }}

-

Detailed state and failure context for this execution.

-
- -
-

Summary

- - - - - - - - - - - - -
Started{{ run.started_at }}
Finished{{ run.finished_at }}
Status{{ run.status }}
Trigger{{ run.trigger_type }}
Scholars{{ run.scholar_count }}
New Publications{{ run.new_publication_count }}
Succeeded{{ run_summary.succeeded_count or 0 }}
Failed{{ run_summary.failed_count or 0 }}
Partial{{ run_summary.partial_count or 0 }}
- {% if run_summary.failed_state_counts or run_summary.failed_reason_counts %} -
- Failure Breakdown -
{{ {"failed_state_counts": run_summary.failed_state_counts, "failed_reason_counts": run_summary.failed_reason_counts} | tojson(indent=2) }}
-
- {% endif %} -
- -
-

Scholar Results

- {% if scholar_results %} - - - - - - - - - - - - - - - - {% for item in scholar_results %} - - - - - - - - - - - - {% if item.debug %} - - - - {% endif %} - {% endfor %} - -
ScholarOutcomeStateReasonPublicationsPagesAttemptsContinuationWarnings
{{ item.scholar_id }}{{ item.outcome or "-" }}{{ item.state }}{{ item.state_reason or "-" }}{{ item.publication_count }}{{ item.pages_fetched or 0 }} / {{ item.pages_attempted or 0 }}{{ item.attempt_count or 1 }} - {% if item.continuation_enqueued %} - queued -

- reason: {{ item.continuation_reason or "-" }}, - cstart: {{ item.continuation_cstart or "-" }} -

- {% elif item.continuation_cleared %} - cleared - {% else %} - - - {% endif %} -
- {% if item.warnings %} - {{ item.warnings | join(", ") }} - {% else %} - - - {% endif %} -
-
- Debug Context -
{{ item.debug | tojson(indent=2) }}
-
-
- {% else %} -

No scholar result details were captured.

- {% endif %} -
-{% endblock %} diff --git a/app/templates/runs.html b/app/templates/runs.html deleted file mode 100644 index 6a153c1..0000000 --- a/app/templates/runs.html +++ /dev/null @@ -1,129 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Runs

-

Run History

-

Review execution outcomes and drill into run diagnostics when needed.

-
- -
-

Filters

-
- - - {% if failed_only %} - Clear - {% endif %} -
-
- -
-

Recent Runs

- {% if runs %} - - - - - - - - - - - - - - {% for run in runs %} - - - - - - - - - - {% endfor %} - -
IDStartedStatusTriggerScholarsNew PublicationsFailures
#{{ run.id }}{{ run.started_at }}{{ run.status }}{{ run.trigger_type }}{{ run.scholar_count }}{{ run.new_publication_count }}{{ run.failed_count }} failed / {{ run.partial_count }} partial
- {% else %} -

No runs match the current filter.

- {% endif %} -
- -
-
-

Continuation Queue

- Focus Failed Runs -
- {% if queue_items %} - - - - - - - - - - - - - - {% for item in queue_items %} - - - - - - - - - - {% endfor %} - -
ScholarStatusReasonAttemptsNext AttemptLast ErrorActions
- {{ item.scholar_label }} -

resume cstart: {{ item.resume_cstart }}

-
{{ item.status }} - {% if item.status == "dropped" and item.dropped_reason %} - {{ item.dropped_reason }} - {% else %} - {{ item.reason }} - {% endif %} - {{ item.attempt_count }}{{ item.next_attempt_at }} - {% if item.last_error %} -
- show -
{{ item.last_error }}
-
- {% else %} - - - {% endif %} -
-
-
- - -
- {% if item.status != "dropped" %} -
- - -
- {% endif %} -
- - -
-
-
- {% else %} -

No queued continuation items.

- {% endif %} -
-{% endblock %} diff --git a/app/templates/scholars.html b/app/templates/scholars.html deleted file mode 100644 index 0cc7821..0000000 --- a/app/templates/scholars.html +++ /dev/null @@ -1,99 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Scholars

-

Your Scholars

-

Add, disable, or remove profiles scoped to your account.

-
- -
-

Add Scholar

-
- - - - - - -
-
- -
-

Tracked Scholars

- {% if scholars %} - - - - - - - - - - - - - {% for scholar in scholars %} - - - - - - - - - {% endfor %} - -
NameScholar IDStatusLast RunPublicationsActions
{{ scholar.display_name }}{{ scholar.scholar_id }} - {% if scholar.is_enabled %} - enabled - {% else %} - disabled - {% endif %} - {% if scholar.baseline_completed %} -

baseline complete

- {% else %} -

awaiting first run

- {% endif %} -
- - {{ scholar.last_run_status }} - -

{{ scholar.last_run_dt }}

-
-
- New - All -
-
-
-
- - -
-
- - -
-
-
- {% else %} -

No scholars tracked yet.

- {% endif %} -
-{% endblock %} diff --git a/app/templates/settings.html b/app/templates/settings.html deleted file mode 100644 index c07d064..0000000 --- a/app/templates/settings.html +++ /dev/null @@ -1,44 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Settings

-

Your Settings

-

Control automation cadence and request pacing for your own account.

-
- -
-

Automation Settings

-
- - - - - - - - - - -
-
-{% endblock %} diff --git a/app/templates/users.html b/app/templates/users.html deleted file mode 100644 index 34b4b7d..0000000 --- a/app/templates/users.html +++ /dev/null @@ -1,75 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Admin

-

User Management

-

Admin-only controls for account creation, activation, and password resets.

-
- -
-

Create User

-
- - - - - - - -
-
- -
-

Users

- - - - - - - - - - - {% for user in users %} - - - - - - - {% endfor %} - -
EmailRoleStatusActions
{{ user.email }} - {% if user.is_admin %} - admin - {% else %} - user - {% endif %} - - {% if user.is_active %} - active - {% else %} - inactive - {% endif %} - -
-
- - -
-
- - - -
-
-
-
-{% endblock %} diff --git a/app/theme.py b/app/theme.py deleted file mode 100644 index 78e152b..0000000 --- a/app/theme.py +++ /dev/null @@ -1,24 +0,0 @@ -from dataclasses import dataclass -from typing import Final - - -@dataclass(frozen=True) -class ThemeDefinition: - slug: str - label: str - - -THEMES: Final[tuple[ThemeDefinition, ...]] = ( - ThemeDefinition(slug="terracotta", label="Terracotta"), - ThemeDefinition(slug="fjord", label="Fjord"), - ThemeDefinition(slug="spruce", label="Spruce"), -) -_THEME_SLUGS: Final[frozenset[str]] = frozenset(theme.slug for theme in THEMES) -DEFAULT_THEME: Final[str] = THEMES[0].slug - - -def resolve_theme(candidate: str | None) -> str: - if candidate and candidate in _THEME_SLUGS: - return candidate - return DEFAULT_THEME - diff --git a/app/web/__init__.py b/app/web/__init__.py deleted file mode 100644 index 4403b00..0000000 --- a/app/web/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Web-layer routers, middleware, and shared helpers.""" - diff --git a/app/web/common.py b/app/web/common.py deleted file mode 100644 index 8c03915..0000000 --- a/app/web/common.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations - -import logging -from urllib.parse import urlencode - -from fastapi import Request, status -from fastapi.responses import HTMLResponse, RedirectResponse -from fastapi.templating import Jinja2Templates -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth.session import ( - SessionUser, - clear_session_user, - get_session_user, - set_session_user, -) -from app.db.models import User -from app.security.csrf import CSRF_SESSION_KEY, ensure_csrf_token -from app.services import users as user_service -from app.theme import THEMES - -logger = logging.getLogger(__name__) - -templates = Jinja2Templates(directory="app/templates") - - -def to_session_user(user: User | None) -> SessionUser | None: - if user is None: - return None - return SessionUser(id=user.id, email=user.email, is_admin=user.is_admin) - - -def build_template_context( - request: Request, - *, - page_title: str, - active_nav: str, - theme_name: str, - session_user: SessionUser | None, - notice: str | None = None, - page_error: str | None = None, -) -> dict[str, object]: - return { - "active_nav": active_nav, - "page_title": page_title, - "theme_name": theme_name, - "themes": THEMES, - "session_user": session_user, - "csrf_token": ensure_csrf_token(request), - "notice": notice, - "page_error": page_error, - } - - -def redirect_with_message( - path: str, - *, - notice: str | None = None, - error: str | None = None, -) -> RedirectResponse: - params: dict[str, str] = {} - if notice: - params["notice"] = notice - if error: - params["error"] = error - if params: - path = f"{path}?{urlencode(params)}" - return RedirectResponse(path, status_code=status.HTTP_303_SEE_OTHER) - - -def redirect_to_login() -> RedirectResponse: - return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER) - - -def invalidate_session(request: Request) -> None: - clear_session_user(request) - request.session.pop(CSRF_SESSION_KEY, None) - - -async def get_authenticated_user( - request: Request, - db_session: AsyncSession, -) -> User | None: - session_user = get_session_user(request) - if session_user is None: - return None - - user = await user_service.get_user_by_id(db_session, session_user.id) - if user is None or not user.is_active: - logger.info( - "auth.session_invalidated", - extra={ - "event": "auth.session_invalidated", - "session_user_id": session_user.id, - }, - ) - invalidate_session(request) - return None - - if user.email != session_user.email or user.is_admin != session_user.is_admin: - set_session_user( - request, - user_id=user.id, - email=user.email, - is_admin=user.is_admin, - ) - - return user - - -def login_rate_limit_key(request: Request, email: str) -> str: - client_host = request.client.host if request.client is not None else "unknown" - normalized_email = email.strip().lower() - return f"{client_host}:{normalized_email or ''}" - - -def render_login_page( - request: Request, - *, - theme_name: str, - error_message: str | None = None, - status_code: int = status.HTTP_200_OK, - retry_after_seconds: int | None = None, -) -> HTMLResponse: - context = build_template_context( - request, - page_title="Login", - active_nav="login", - theme_name=theme_name, - session_user=None, - ) - context["error_message"] = error_message - context["retry_after_seconds"] = retry_after_seconds - return templates.TemplateResponse( - request=request, - name="login.html", - context=context, - status_code=status_code, - ) - diff --git a/app/web/routers/__init__.py b/app/web/routers/__init__.py deleted file mode 100644 index cbf15d9..0000000 --- a/app/web/routers/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Application routers grouped by concern.""" - diff --git a/app/web/routers/admin.py b/app/web/routers/admin.py deleted file mode 100644 index fda4f45..0000000 --- a/app/web/routers/admin.py +++ /dev/null @@ -1,209 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Annotated - -from fastapi import APIRouter, Depends, Form, HTTPException, Request, status -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth.deps import get_auth_service -from app.auth.service import AuthService -from app.db.session import get_db_session -from app.services import users as user_service -from app.theme import resolve_theme -from app.web import common - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -async def _render_users_page( - request: Request, - *, - db_session: AsyncSession, - current_user, - theme_name: str, - notice: str | None = None, - page_error: str | None = None, - status_code: int = status.HTTP_200_OK, - form_email: str = "", - form_is_admin: bool = False, -) -> HTMLResponse: - users = await user_service.list_users(db_session) - context = common.build_template_context( - request, - page_title="Users", - active_nav="users", - theme_name=theme_name, - session_user=common.to_session_user(current_user), - notice=notice, - page_error=page_error, - ) - context["users"] = users - context["current_user_id"] = current_user.id - context["form_email"] = form_email - context["form_is_admin"] = form_is_admin - return common.templates.TemplateResponse( - request=request, - name="users.html", - context=context, - status_code=status_code, - ) - - -@router.get("/users", response_class=HTMLResponse) -async def users_page( - request: Request, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required.") - return await _render_users_page( - request, - db_session=db_session, - current_user=current_user, - theme_name=resolve_theme(theme), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ) - - -@router.post("/users") -async def create_user( - request: Request, - email: Annotated[str, Form()], - password: Annotated[str, Form()], - is_admin: Annotated[str | None, Form()] = None, - db_session: AsyncSession = Depends(get_db_session), - auth_service: AuthService = Depends(get_auth_service), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required.") - - active_theme = resolve_theme(None) - try: - validated_email = user_service.validate_email(email) - validated_password = user_service.validate_password(password) - created_user = await user_service.create_user( - db_session, - email=validated_email, - password_hash=auth_service.hash_password(validated_password), - is_admin=is_admin == "on", - ) - except user_service.UserServiceError as exc: - logger.info( - "admin.user_create_failed", - extra={ - "event": "admin.user_create_failed", - "admin_user_id": current_user.id, - "reason": "validation_or_conflict", - }, - ) - return await _render_users_page( - request, - db_session=db_session, - current_user=current_user, - theme_name=active_theme, - page_error=str(exc), - status_code=status.HTTP_400_BAD_REQUEST, - form_email=email, - form_is_admin=is_admin == "on", - ) - - logger.info( - "admin.user_created", - extra={ - "event": "admin.user_created", - "admin_user_id": current_user.id, - "target_user_id": created_user.id, - "target_is_admin": created_user.is_admin, - }, - ) - return common.redirect_with_message( - "/users", - notice=f"User created: {created_user.email}", - ) - - -@router.post("/users/{user_id}/toggle-active") -async def toggle_user_active( - request: Request, - user_id: int, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required.") - - target_user = await user_service.get_user_by_id(db_session, user_id) - if target_user is None: - return common.redirect_with_message("/users", error="User not found.") - if target_user.id == current_user.id and target_user.is_active: - return common.redirect_with_message("/users", error="You cannot deactivate your own account.") - - updated_user = await user_service.set_user_active( - db_session, - user=target_user, - is_active=not target_user.is_active, - ) - status_label = "activated" if updated_user.is_active else "deactivated" - logger.info( - "admin.user_active_toggled", - extra={ - "event": "admin.user_active_toggled", - "admin_user_id": current_user.id, - "target_user_id": updated_user.id, - "is_active": updated_user.is_active, - }, - ) - return common.redirect_with_message("/users", notice=f"User {status_label}: {updated_user.email}") - - -@router.post("/users/{user_id}/reset-password") -async def reset_user_password( - request: Request, - user_id: int, - new_password: Annotated[str, Form()], - db_session: AsyncSession = Depends(get_db_session), - auth_service: AuthService = Depends(get_auth_service), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required.") - - target_user = await user_service.get_user_by_id(db_session, user_id) - if target_user is None: - return common.redirect_with_message("/users", error="User not found.") - try: - validated_password = user_service.validate_password(new_password) - except user_service.UserServiceError as exc: - return common.redirect_with_message("/users", error=str(exc)) - - await user_service.set_user_password_hash( - db_session, - user=target_user, - password_hash=auth_service.hash_password(validated_password), - ) - logger.info( - "admin.user_password_reset", - extra={ - "event": "admin.user_password_reset", - "admin_user_id": current_user.id, - "target_user_id": target_user.id, - }, - ) - return common.redirect_with_message("/users", notice=f"Password reset: {target_user.email}") - diff --git a/app/web/routers/auth.py b/app/web/routers/auth.py deleted file mode 100644 index 8c1c819..0000000 --- a/app/web/routers/auth.py +++ /dev/null @@ -1,214 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Annotated - -from fastapi import APIRouter, Depends, Form, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth.deps import get_auth_service, get_login_rate_limiter -from app.auth.rate_limit import SlidingWindowRateLimiter -from app.auth.service import AuthService -from app.auth.session import get_session_user, set_session_user -from app.db.session import get_db_session -from app.security.csrf import ensure_csrf_token -from app.services import users as user_service -from app.theme import resolve_theme -from app.web import common - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -@router.get("/login", response_class=HTMLResponse) -async def login_page(request: Request, theme: str | None = None) -> HTMLResponse: - active_theme = resolve_theme(theme) - ensure_csrf_token(request) - if get_session_user(request) is not None: - return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) - return common.render_login_page(request, theme_name=active_theme) - - -@router.post("/login", response_class=HTMLResponse) -async def login( - request: Request, - email: Annotated[str, Form()], - password: Annotated[str, Form()], - db_session: AsyncSession = Depends(get_db_session), - auth_service: AuthService = Depends(get_auth_service), - rate_limiter: SlidingWindowRateLimiter = Depends(get_login_rate_limiter), -) -> HTMLResponse: - active_theme = resolve_theme(None) - limiter_key = common.login_rate_limit_key(request, email) - decision = rate_limiter.check(limiter_key) - normalized_email = email.strip().lower() - if not decision.allowed: - logger.warning( - "auth.login_rate_limited", - extra={ - "event": "auth.login_rate_limited", - "email": normalized_email, - "retry_after_seconds": decision.retry_after_seconds, - }, - ) - response = common.render_login_page( - request, - theme_name=active_theme, - error_message="Too many login attempts. Please try again later.", - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - retry_after_seconds=decision.retry_after_seconds, - ) - response.headers["Retry-After"] = str(decision.retry_after_seconds) - return response - - user = await auth_service.authenticate_user( - db_session, - email=email, - password=password, - ) - if user is None: - rate_limiter.record_failure(limiter_key) - logger.info( - "auth.login_failed", - extra={ - "event": "auth.login_failed", - "email": normalized_email, - }, - ) - return common.render_login_page( - request, - theme_name=active_theme, - error_message="Invalid email or password.", - status_code=status.HTTP_401_UNAUTHORIZED, - ) - - rate_limiter.reset(limiter_key) - set_session_user( - request, - user_id=user.id, - email=user.email, - is_admin=user.is_admin, - ) - ensure_csrf_token(request) - logger.info( - "auth.login_succeeded", - extra={ - "event": "auth.login_succeeded", - "user_id": user.id, - "is_admin": user.is_admin, - }, - ) - return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) - - -@router.post("/logout") -async def logout(request: Request) -> RedirectResponse: - session_user = get_session_user(request) - common.invalidate_session(request) - logger.info( - "auth.logout", - extra={ - "event": "auth.logout", - "user_id": session_user.id if session_user else None, - }, - ) - return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER) - - -@router.get("/account/password", response_class=HTMLResponse) -async def account_password_page( - request: Request, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - return common.templates.TemplateResponse( - request=request, - name="account_password.html", - context=common.build_template_context( - request, - page_title="Change Password", - active_nav="account_password", - theme_name=resolve_theme(theme), - session_user=common.to_session_user(current_user), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ), - ) - - -@router.post("/account/password") -async def update_account_password( - request: Request, - current_password: Annotated[str, Form()], - new_password: Annotated[str, Form()], - confirm_password: Annotated[str, Form()], - db_session: AsyncSession = Depends(get_db_session), - auth_service: AuthService = Depends(get_auth_service), -) -> RedirectResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - if not auth_service.verify_password( - password_hash=current_user.password_hash, - password=current_password, - ): - logger.info( - "account.password_change_failed", - extra={ - "event": "account.password_change_failed", - "user_id": current_user.id, - "reason": "invalid_current_password", - }, - ) - return common.redirect_with_message( - "/account/password", - error="Current password is incorrect.", - ) - if new_password != confirm_password: - logger.info( - "account.password_change_failed", - extra={ - "event": "account.password_change_failed", - "user_id": current_user.id, - "reason": "confirmation_mismatch", - }, - ) - return common.redirect_with_message( - "/account/password", - error="New password and confirmation do not match.", - ) - try: - validated_password = user_service.validate_password(new_password) - except user_service.UserServiceError as exc: - logger.info( - "account.password_change_failed", - extra={ - "event": "account.password_change_failed", - "user_id": current_user.id, - "reason": "validation_error", - }, - ) - return common.redirect_with_message("/account/password", error=str(exc)) - - await user_service.set_user_password_hash( - db_session, - user=current_user, - password_hash=auth_service.hash_password(validated_password), - ) - logger.info( - "account.password_changed", - extra={ - "event": "account.password_changed", - "user_id": current_user.id, - }, - ) - return common.redirect_with_message( - "/account/password", - notice="Password updated successfully.", - ) - diff --git a/app/web/routers/dashboard.py b/app/web/routers/dashboard.py deleted file mode 100644 index efbff30..0000000 --- a/app/web/routers/dashboard.py +++ /dev/null @@ -1,167 +0,0 @@ -from __future__ import annotations - -import logging - -from fastapi import APIRouter, Depends, Request -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import RunTriggerType -from app.db.session import get_db_session -from app.presentation import dashboard as dashboard_presenter -from app.services import ingestion as ingestion_service -from app.services import publications as publication_service -from app.services import runs as run_service -from app.services import user_settings as user_settings_service -from app.settings import settings -from app.theme import resolve_theme -from app.web import common -from app.web.deps import get_ingestion_service - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -async def _render_dashboard_page( - request: Request, - *, - db_session: AsyncSession, - current_user, - theme_name: str, - notice: str | None = None, - page_error: str | None = None, -) -> HTMLResponse: - unread_publications = await publication_service.list_new_for_latest_run_for_user( - db_session, - user_id=current_user.id, - limit=50, - ) - recent_runs = await run_service.list_recent_runs_for_user( - db_session, - user_id=current_user.id, - limit=20, - ) - user_settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=current_user.id, - ) - queue_counts = await run_service.queue_status_counts_for_user( - db_session, - user_id=current_user.id, - ) - context = common.build_template_context( - request, - page_title="Dashboard", - active_nav="home", - theme_name=theme_name, - session_user=common.to_session_user(current_user), - notice=notice, - page_error=page_error, - ) - context["dashboard"] = dashboard_presenter.build_dashboard_view_model( - unread_publications=unread_publications, - recent_runs=recent_runs, - request_delay_seconds=user_settings.request_delay_seconds, - queue_counts=queue_counts, - ) - return common.templates.TemplateResponse( - request=request, - name="index.html", - context=context, - ) - - -@router.get("/", response_class=HTMLResponse) -async def home( - request: Request, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - return await _render_dashboard_page( - request, - db_session=db_session, - current_user=current_user, - theme_name=resolve_theme(theme), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ) - - -@router.post("/runs/manual") -async def run_manual_ingestion( - request: Request, - db_session: AsyncSession = Depends(get_db_session), - ingest_service: ingestion_service.ScholarIngestionService = Depends(get_ingestion_service), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - user_settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=current_user.id, - ) - logger.info( - "runs.manual_started", - extra={ - "event": "runs.manual_started", - "user_id": current_user.id, - "request_delay_seconds": user_settings.request_delay_seconds, - "network_error_retries": settings.ingestion_network_error_retries, - "max_pages_per_scholar": settings.ingestion_max_pages_per_scholar, - "page_size": settings.ingestion_page_size, - }, - ) - try: - run_summary = await ingest_service.run_for_user( - db_session, - user_id=current_user.id, - trigger_type=RunTriggerType.MANUAL, - request_delay_seconds=user_settings.request_delay_seconds, - network_error_retries=settings.ingestion_network_error_retries, - retry_backoff_seconds=settings.ingestion_retry_backoff_seconds, - max_pages_per_scholar=settings.ingestion_max_pages_per_scholar, - page_size=settings.ingestion_page_size, - auto_queue_continuations=settings.ingestion_continuation_queue_enabled, - queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds, - ) - except ingestion_service.RunAlreadyInProgressError: - await db_session.rollback() - return common.redirect_with_message( - "/", - error="A run is already in progress for this account.", - ) - except Exception: - await db_session.rollback() - logger.exception( - "runs.manual_failed", - extra={ - "event": "runs.manual_failed", - "user_id": current_user.id, - }, - ) - return common.redirect_with_message("/", error="Manual run failed. Check logs for details.") - - logger.info( - "runs.manual_completed", - extra={ - "event": "runs.manual_completed", - "user_id": current_user.id, - "run_id": run_summary.crawl_run_id, - "status": run_summary.status.value, - "scholar_count": run_summary.scholar_count, - "new_publication_count": run_summary.new_publication_count, - }, - ) - return common.redirect_with_message( - "/", - notice=( - f"Run #{run_summary.crawl_run_id} complete ({run_summary.status.value}). " - f"Scholars: {run_summary.scholar_count}, " - f"new publications: {run_summary.new_publication_count}." - ), - ) diff --git a/app/web/routers/health.py b/app/web/routers/health.py deleted file mode 100644 index 3c7c860..0000000 --- a/app/web/routers/health.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from fastapi import APIRouter, HTTPException - -from app.db.session import check_database - -router = APIRouter() - - -@router.get("/healthz") -async def healthz() -> dict[str, str]: - if await check_database(): - return {"status": "ok"} - raise HTTPException(status_code=500, detail="database unavailable") - diff --git a/app/web/routers/publications.py b/app/web/routers/publications.py deleted file mode 100644 index c4ce7a2..0000000 --- a/app/web/routers/publications.py +++ /dev/null @@ -1,165 +0,0 @@ -from __future__ import annotations - -import logging -from urllib.parse import urlencode, urlparse - -from fastapi import APIRouter, Depends, Form, Request -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.session import get_db_session -from app.services import publications as publication_service -from app.services import scholars as scholar_service -from app.theme import resolve_theme -from app.web import common - -logger = logging.getLogger(__name__) - -router = APIRouter() - -MODE_NEW = "new" -MODE_ALL = "all" - - -def _resolve_mode(raw_mode: str | None) -> str: - return publication_service.resolve_mode(raw_mode) - - -def _parse_scholar_profile_id(value: str | None) -> int | None: - if not value: - return None - try: - parsed = int(value) - except ValueError: - return None - return parsed if parsed > 0 else None - - -def _build_publications_url(*, mode: str, scholar_profile_id: int | None) -> str: - params: dict[str, str] = {"mode": mode} - if scholar_profile_id is not None: - params["scholar_profile_id"] = str(scholar_profile_id) - return f"/publications?{urlencode(params)}" - - -def _is_safe_return_to(value: str | None) -> bool: - if not value: - return False - parsed = urlparse(value) - if parsed.scheme or parsed.netloc: - return False - if not value.startswith("/"): - return False - return value == "/" or value.startswith("/publications") - - -@router.get("/publications", response_class=HTMLResponse) -async def publications_page( - request: Request, - mode: str | None = None, - scholar_profile_id: str | None = None, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - resolved_mode = _resolve_mode(mode) - scholar_id_filter = _parse_scholar_profile_id(scholar_profile_id) - scholars = await scholar_service.list_scholars_for_user( - db_session, - user_id=current_user.id, - ) - scholar_lookup = {scholar.id: scholar for scholar in scholars} - if scholar_id_filter not in scholar_lookup: - scholar_id_filter = None - - publications = await publication_service.list_for_user( - db_session, - user_id=current_user.id, - mode=resolved_mode, - scholar_profile_id=scholar_id_filter, - limit=500, - ) - new_count = await publication_service.count_for_user( - db_session, - user_id=current_user.id, - mode=MODE_NEW, - scholar_profile_id=scholar_id_filter, - ) - total_count = await publication_service.count_for_user( - db_session, - user_id=current_user.id, - mode=MODE_ALL, - scholar_profile_id=scholar_id_filter, - ) - mode_new_url = _build_publications_url( - mode=MODE_NEW, - scholar_profile_id=scholar_id_filter, - ) - mode_all_url = _build_publications_url( - mode=MODE_ALL, - scholar_profile_id=scholar_id_filter, - ) - selected_scholar = scholar_lookup.get(scholar_id_filter) if scholar_id_filter else None - current_publications_url = _build_publications_url( - mode=resolved_mode, - scholar_profile_id=scholar_id_filter, - ) - - context = common.build_template_context( - request, - page_title="Publications", - active_nav="publications", - theme_name=resolve_theme(theme), - session_user=common.to_session_user(current_user), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ) - context["mode"] = resolved_mode - context["publications"] = publications - context["new_count"] = new_count - context["total_count"] = total_count - context["mode_new_url"] = mode_new_url - context["mode_all_url"] = mode_all_url - context["scholars"] = scholars - context["selected_scholar_id"] = scholar_id_filter - context["selected_scholar"] = selected_scholar - context["current_publications_url"] = current_publications_url - return common.templates.TemplateResponse( - request=request, - name="publications.html", - context=context, - ) - - -@router.post("/publications/mark-all-read") -async def mark_all_publications_read( - request: Request, - return_to: str | None = Form(default=None), - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - updated_count = await publication_service.mark_all_unread_as_read_for_user( - db_session, - user_id=current_user.id, - ) - logger.info( - "publications.mark_all_read", - extra={ - "event": "publications.mark_all_read", - "user_id": current_user.id, - "updated_count": updated_count, - }, - ) - redirect_target = "/publications?mode=new" - if _is_safe_return_to(return_to): - redirect_target = return_to - return common.redirect_with_message( - redirect_target, - notice=f"Marked {updated_count} publication(s) as read.", - ) diff --git a/app/web/routers/runs.py b/app/web/routers/runs.py deleted file mode 100644 index c6e395f..0000000 --- a/app/web/routers/runs.py +++ /dev/null @@ -1,274 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone - -from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.session import get_db_session -from app.services import runs as run_service -from app.theme import resolve_theme -from app.web import common - -router = APIRouter() - - -def _as_bool(value: str | None) -> bool: - if value is None: - return False - return value.strip().lower() in {"1", "true", "yes", "on"} - - -def _status_badge(status_value: str) -> str: - if status_value == "success": - return "ok" - if status_value == "failed": - return "danger" - return "warn" - - -def _queue_status_badge(status_value: str) -> str: - if status_value == "dropped": - return "danger" - if status_value == "retrying": - return "ok" - return "warn" - - -def _format_dt(value: datetime | None) -> str: - if value is None: - return "-" - return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - - -def _summary_dict(error_log: object) -> dict[str, object]: - if not isinstance(error_log, dict): - return {} - summary = error_log.get("summary") - if not isinstance(summary, dict): - return {} - return summary - - -@router.get("/runs", response_class=HTMLResponse) -async def runs_page( - request: Request, - theme: str | None = None, - failed_only: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - failed_only_enabled = _as_bool(failed_only) - runs = await run_service.list_runs_for_user( - db_session, - user_id=current_user.id, - limit=200, - failed_only=failed_only_enabled, - ) - - run_items = [] - for run in runs: - summary = _summary_dict(run.error_log) - failed_count = summary.get("failed_count", 0) - partial_count = summary.get("partial_count", 0) - run_items.append( - { - "id": run.id, - "started_at": _format_dt(run.start_dt), - "finished_at": _format_dt(run.end_dt), - "status": run.status.value, - "status_badge": _status_badge(run.status.value), - "trigger_type": run.trigger_type.value, - "scholar_count": run.scholar_count, - "new_publication_count": run.new_pub_count, - "failed_count": failed_count, - "partial_count": partial_count, - } - ) - - queue_entries = await run_service.list_queue_items_for_user( - db_session, - user_id=current_user.id, - limit=200, - ) - queue_items = [] - for item in queue_entries: - queue_items.append( - { - "id": item.id, - "scholar_profile_id": item.scholar_profile_id, - "scholar_label": item.scholar_label, - "status": item.status, - "status_badge": _queue_status_badge(item.status), - "reason": item.reason, - "dropped_reason": item.dropped_reason, - "attempt_count": item.attempt_count, - "resume_cstart": item.resume_cstart, - "next_attempt_at": _format_dt(item.next_attempt_dt), - "updated_at": _format_dt(item.updated_at), - "last_error": item.last_error, - "last_run_id": item.last_run_id, - } - ) - - context = common.build_template_context( - request, - page_title="Runs", - active_nav="runs", - theme_name=resolve_theme(theme), - session_user=common.to_session_user(current_user), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ) - context["runs"] = run_items - context["failed_only"] = failed_only_enabled - context["queue_items"] = queue_items - return common.templates.TemplateResponse( - request=request, - name="runs.html", - context=context, - ) - - -@router.get("/runs/{run_id}", response_class=HTMLResponse) -async def run_detail_page( - request: Request, - run_id: int, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - run = await run_service.get_run_for_user( - db_session, - user_id=current_user.id, - run_id=run_id, - ) - if run is None: - raise HTTPException(status_code=404, detail="Run not found.") - - error_log = run.error_log if isinstance(run.error_log, dict) else {} - scholar_results = error_log.get("scholar_results", []) - if not isinstance(scholar_results, list): - scholar_results = [] - - context = common.build_template_context( - request, - page_title=f"Run #{run.id}", - active_nav="runs", - theme_name=resolve_theme(theme), - session_user=common.to_session_user(current_user), - ) - context["run"] = { - "id": run.id, - "started_at": _format_dt(run.start_dt), - "finished_at": _format_dt(run.end_dt), - "status": run.status.value, - "status_badge": _status_badge(run.status.value), - "trigger_type": run.trigger_type.value, - "scholar_count": run.scholar_count, - "new_publication_count": run.new_pub_count, - } - context["run_summary"] = _summary_dict(error_log) - context["scholar_results"] = scholar_results - return common.templates.TemplateResponse( - request=request, - name="run_detail.html", - context=context, - ) - - -@router.post("/runs/queue/{queue_item_id}/drop") -async def drop_queue_item( - request: Request, - queue_item_id: int, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - try: - dropped = await run_service.drop_queue_item_for_user( - db_session, - user_id=current_user.id, - queue_item_id=queue_item_id, - ) - except run_service.QueueTransitionError as exc: - return common.redirect_with_message( - "/runs", - error=f"Queue item #{queue_item_id}: {exc.message}", - ) - if dropped is None: - raise HTTPException(status_code=404, detail="Queue item not found.") - return common.redirect_with_message( - "/runs", - notice=f"Queue item #{queue_item_id} marked as dropped.", - ) - - -@router.post("/runs/queue/{queue_item_id}/clear") -async def clear_queue_item( - request: Request, - queue_item_id: int, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - try: - cleared = await run_service.clear_queue_item_for_user( - db_session, - user_id=current_user.id, - queue_item_id=queue_item_id, - ) - except run_service.QueueTransitionError as exc: - return common.redirect_with_message( - "/runs", - error=f"Queue item #{queue_item_id}: {exc.message}", - ) - if cleared is None: - raise HTTPException(status_code=404, detail="Queue item not found.") - return common.redirect_with_message( - "/runs", - notice=f"Queue item #{queue_item_id} cleared.", - ) - - -@router.post("/runs/queue/{queue_item_id}/retry") -async def retry_queue_item( - request: Request, - queue_item_id: int, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - try: - queue_item = await run_service.retry_queue_item_for_user( - db_session, - user_id=current_user.id, - queue_item_id=queue_item_id, - ) - except run_service.QueueTransitionError as exc: - return common.redirect_with_message( - "/runs", - error=f"Queue item #{queue_item_id}: {exc.message}", - ) - if queue_item is None: - raise HTTPException(status_code=404, detail="Queue item not found.") - return common.redirect_with_message( - "/runs", - notice=( - f"Queue item #{queue_item_id} queued for retry " - f"(scholar: {queue_item.scholar_label})." - ), - ) diff --git a/app/web/routers/scholars.py b/app/web/routers/scholars.py deleted file mode 100644 index a761f20..0000000 --- a/app/web/routers/scholars.py +++ /dev/null @@ -1,212 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -import logging -from typing import Annotated - -from fastapi import APIRouter, Depends, Form, HTTPException, Request, status -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.session import get_db_session -from app.services import scholars as scholar_service -from app.theme import resolve_theme -from app.web import common - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -def _format_dt(value: datetime | None) -> str: - if value is None: - return "-" - return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - - -async def _render_scholars_page( - request: Request, - *, - db_session: AsyncSession, - current_user, - theme_name: str, - notice: str | None = None, - page_error: str | None = None, - status_code: int = status.HTTP_200_OK, - form_scholar_id: str = "", - form_display_name: str = "", -) -> HTMLResponse: - scholars = await scholar_service.list_scholars_for_user( - db_session, - user_id=current_user.id, - ) - scholar_items = [] - for scholar in scholars: - scholar_items.append( - { - "id": scholar.id, - "scholar_id": scholar.scholar_id, - "display_name": scholar.display_name or "Unnamed", - "is_enabled": scholar.is_enabled, - "baseline_completed": scholar.baseline_completed, - "last_run_status": ( - scholar.last_run_status.value - if scholar.last_run_status is not None - else "never" - ), - "last_run_dt": _format_dt(scholar.last_run_dt), - } - ) - context = common.build_template_context( - request, - page_title="Scholars", - active_nav="scholars", - theme_name=theme_name, - session_user=common.to_session_user(current_user), - notice=notice, - page_error=page_error, - ) - context["scholars"] = scholar_items - context["form_scholar_id"] = form_scholar_id - context["form_display_name"] = form_display_name - return common.templates.TemplateResponse( - request=request, - name="scholars.html", - context=context, - status_code=status_code, - ) - - -@router.get("/scholars", response_class=HTMLResponse) -async def scholars_page( - request: Request, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - return await _render_scholars_page( - request, - db_session=db_session, - current_user=current_user, - theme_name=resolve_theme(theme), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ) - - -@router.post("/scholars") -async def create_scholar( - request: Request, - scholar_id: Annotated[str, Form()], - display_name: Annotated[str, Form()] = "", - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - active_theme = resolve_theme(None) - try: - created_profile = await scholar_service.create_scholar_for_user( - db_session, - user_id=current_user.id, - scholar_id=scholar_id, - display_name=display_name, - ) - except scholar_service.ScholarServiceError as exc: - logger.info( - "scholars.create_failed", - extra={ - "event": "scholars.create_failed", - "user_id": current_user.id, - "scholar_id": scholar_id.strip(), - }, - ) - return await _render_scholars_page( - request, - db_session=db_session, - current_user=current_user, - theme_name=active_theme, - page_error=str(exc), - status_code=status.HTTP_400_BAD_REQUEST, - form_scholar_id=scholar_id, - form_display_name=display_name, - ) - label = created_profile.display_name or created_profile.scholar_id - logger.info( - "scholars.created", - extra={ - "event": "scholars.created", - "user_id": current_user.id, - "scholar_profile_id": created_profile.id, - }, - ) - return common.redirect_with_message("/scholars", notice=f"Scholar added: {label}") - - -@router.post("/scholars/{scholar_profile_id}/toggle") -async def toggle_scholar( - request: Request, - scholar_profile_id: int, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - profile = await scholar_service.get_user_scholar_by_id( - db_session, - user_id=current_user.id, - scholar_profile_id=scholar_profile_id, - ) - if profile is None: - raise HTTPException(status_code=404, detail="Scholar not found.") - updated_profile = await scholar_service.toggle_scholar_enabled( - db_session, - profile=profile, - ) - status_label = "enabled" if updated_profile.is_enabled else "disabled" - logger.info( - "scholars.toggled", - extra={ - "event": "scholars.toggled", - "user_id": current_user.id, - "scholar_profile_id": updated_profile.id, - "is_enabled": updated_profile.is_enabled, - }, - ) - return common.redirect_with_message( - "/scholars", - notice=f"Scholar {status_label}: {updated_profile.scholar_id}", - ) - - -@router.post("/scholars/{scholar_profile_id}/delete") -async def delete_scholar( - request: Request, - scholar_profile_id: int, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - profile = await scholar_service.get_user_scholar_by_id( - db_session, - user_id=current_user.id, - scholar_profile_id=scholar_profile_id, - ) - if profile is None: - raise HTTPException(status_code=404, detail="Scholar not found.") - deleted_label = profile.display_name or profile.scholar_id - await scholar_service.delete_scholar(db_session, profile=profile) - logger.info( - "scholars.deleted", - extra={ - "event": "scholars.deleted", - "user_id": current_user.id, - "scholar_profile_id": scholar_profile_id, - }, - ) - return common.redirect_with_message("/scholars", notice=f"Scholar removed: {deleted_label}") diff --git a/app/web/routers/settings.py b/app/web/routers/settings.py deleted file mode 100644 index e5b7ba5..0000000 --- a/app/web/routers/settings.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Annotated - -from fastapi import APIRouter, Depends, Form, Request -from fastapi.responses import HTMLResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.session import get_db_session -from app.services import user_settings as user_settings_service -from app.theme import resolve_theme -from app.web import common - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -@router.get("/settings", response_class=HTMLResponse) -async def settings_page( - request: Request, - theme: str | None = None, - db_session: AsyncSession = Depends(get_db_session), -) -> HTMLResponse: - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - - user_settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=current_user.id, - ) - context = common.build_template_context( - request, - page_title="Settings", - active_nav="settings", - theme_name=resolve_theme(theme), - session_user=common.to_session_user(current_user), - notice=request.query_params.get("notice"), - page_error=request.query_params.get("error"), - ) - context["user_settings"] = user_settings - return common.templates.TemplateResponse( - request=request, - name="settings.html", - context=context, - ) - - -@router.post("/settings") -async def update_settings( - request: Request, - run_interval_minutes: Annotated[str, Form()], - request_delay_seconds: Annotated[str, Form()], - auto_run_enabled: Annotated[str | None, Form()] = None, - db_session: AsyncSession = Depends(get_db_session), -): - current_user = await common.get_authenticated_user(request, db_session) - if current_user is None: - return common.redirect_to_login() - try: - parsed_interval = user_settings_service.parse_run_interval_minutes( - run_interval_minutes - ) - parsed_delay = user_settings_service.parse_request_delay_seconds( - request_delay_seconds - ) - except user_settings_service.UserSettingsServiceError as exc: - return common.redirect_with_message("/settings", error=str(exc)) - - user_settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=current_user.id, - ) - await user_settings_service.update_settings( - db_session, - settings=user_settings, - auto_run_enabled=auto_run_enabled == "on", - run_interval_minutes=parsed_interval, - request_delay_seconds=parsed_delay, - ) - logger.info( - "settings.updated", - extra={ - "event": "settings.updated", - "user_id": current_user.id, - "auto_run_enabled": auto_run_enabled == "on", - "run_interval_minutes": parsed_interval, - "request_delay_seconds": parsed_delay, - }, - ) - return common.redirect_with_message("/settings", notice="Settings updated.") - diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..1c5e574 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,31 @@ +services: + app: + image: scholarr-dev:local + build: + context: . + target: dev + environment: + APP_RELOAD: ${APP_RELOAD:-1} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-0} + FRONTEND_ENABLED: ${FRONTEND_ENABLED:-0} + volumes: + - ./:/app + + frontend: + image: node:20-alpine + working_dir: /frontend + command: ["sh", "-lc", "npm install && npm run dev"] + environment: + CHOKIDAR_USEPOLLING: ${CHOKIDAR_USEPOLLING:-1} + VITE_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://app:8000} + ports: + - "${FRONTEND_HOST_PORT:-5173}:5173" + volumes: + - ./frontend:/frontend + - frontend_node_modules:/frontend/node_modules + depends_on: + app: + condition: service_healthy + +volumes: + frontend_node_modules: diff --git a/docker-compose.yml b/docker-compose.yml index a6d4cda..764ce81 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,12 +3,11 @@ name: scholarr services: db: image: postgres:15-alpine + restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB:-scholar} POSTGRES_USER: ${POSTGRES_USER:-scholar} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scholar} - ports: - - "${POSTGRES_PORT:-5432}:5432" + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -18,26 +17,26 @@ services: retries: 20 app: - build: - context: . - target: dev + image: ${SCHOLARR_IMAGE:-justinzeus/scholarr:latest} + restart: unless-stopped environment: - DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://scholar:scholar@db:5432/scholar} - TEST_DATABASE_URL: ${TEST_DATABASE_URL:-} + DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${POSTGRES_USER:-scholar}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-scholar}} MIGRATE_ON_START: ${MIGRATE_ON_START:-1} APP_NAME: ${APP_NAME:-scholarr} APP_HOST: ${APP_HOST:-0.0.0.0} APP_PORT: ${APP_PORT:-8000} - APP_RELOAD: ${APP_RELOAD:-1} - SESSION_SECRET_KEY: ${SESSION_SECRET_KEY:-dev-insecure-session-key} - SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-0} + APP_RELOAD: ${APP_RELOAD:-0} + SESSION_SECRET_KEY: ${SESSION_SECRET_KEY:?set SESSION_SECRET_KEY} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-1} + FRONTEND_ENABLED: ${FRONTEND_ENABLED:-1} + FRONTEND_DIST_DIR: ${FRONTEND_DIST_DIR:-/app/frontend/dist} LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-5} LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-60} LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_FORMAT: ${LOG_FORMAT:-console} LOG_REQUESTS: ${LOG_REQUESTS:-1} LOG_UVICORN_ACCESS: ${LOG_UVICORN_ACCESS:-0} - LOG_REQUEST_SKIP_PATHS: ${LOG_REQUEST_SKIP_PATHS:-/healthz,/static/} + LOG_REQUEST_SKIP_PATHS: ${LOG_REQUEST_SKIP_PATHS:-/healthz} LOG_REDACT_FIELDS: ${LOG_REDACT_FIELDS:-} SCHEDULER_ENABLED: ${SCHEDULER_ENABLED:-1} SCHEDULER_TICK_SECONDS: ${SCHEDULER_TICK_SECONDS:-60} @@ -45,6 +44,21 @@ services: INGESTION_RETRY_BACKOFF_SECONDS: ${INGESTION_RETRY_BACKOFF_SECONDS:-1.0} INGESTION_MAX_PAGES_PER_SCHOLAR: ${INGESTION_MAX_PAGES_PER_SCHOLAR:-30} INGESTION_PAGE_SIZE: ${INGESTION_PAGE_SIZE:-100} + INGESTION_CONTINUATION_QUEUE_ENABLED: ${INGESTION_CONTINUATION_QUEUE_ENABLED:-1} + INGESTION_CONTINUATION_BASE_DELAY_SECONDS: ${INGESTION_CONTINUATION_BASE_DELAY_SECONDS:-120} + INGESTION_CONTINUATION_MAX_DELAY_SECONDS: ${INGESTION_CONTINUATION_MAX_DELAY_SECONDS:-3600} + INGESTION_CONTINUATION_MAX_ATTEMPTS: ${INGESTION_CONTINUATION_MAX_ATTEMPTS:-6} + SCHEDULER_QUEUE_BATCH_SIZE: ${SCHEDULER_QUEUE_BATCH_SIZE:-10} + SCHOLAR_IMAGE_UPLOAD_DIR: ${SCHOLAR_IMAGE_UPLOAD_DIR:-/var/lib/scholarr/uploads} + SCHOLAR_IMAGE_UPLOAD_MAX_BYTES: ${SCHOLAR_IMAGE_UPLOAD_MAX_BYTES:-2000000} + SCHOLAR_NAME_SEARCH_ENABLED: ${SCHOLAR_NAME_SEARCH_ENABLED:-1} + SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS: ${SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS:-21600} + SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS: ${SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS:-300} + SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES: ${SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES:-512} + SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS: ${SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS:-8.0} + SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS: ${SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS:-2.0} + SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD: ${SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD:-1} + SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS: ${SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS:-1800} BOOTSTRAP_ADMIN_ON_START: ${BOOTSTRAP_ADMIN_ON_START:-0} BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} @@ -54,7 +68,7 @@ services: ports: - "${APP_HOST_PORT:-8000}:8000" volumes: - - ./:/app + - scholar_uploads:/var/lib/scholarr/uploads depends_on: db: condition: service_healthy @@ -66,3 +80,4 @@ services: volumes: postgres_data: + scholar_uploads: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3ff38cc --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.vite/ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..91a3496 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,31 @@ + + + + + + scholarr + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..81441d5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2985 @@ +{ + "name": "scholarr-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scholarr-frontend", + "version": "0.1.0", + "dependencies": { + "pinia": "^2.1.7", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vitest": "^2.1.8", + "vue-tsc": "^2.1.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.28.tgz", + "integrity": "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.28", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/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==", + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz", + "integrity": "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", + "integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.28", + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/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==", + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz", + "integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz", + "integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz", + "integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz", + "integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/runtime-core": "3.5.28", + "@vue/shared": "3.5.28", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz", + "integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "vue": "3.5.28" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.28.tgz", + "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "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.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "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/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", + "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-sfc": "3.5.28", + "@vue/runtime-dom": "3.5.28", + "@vue/server-renderer": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2b5f7d3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "scholarr-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview --host 0.0.0.0 --port 4173", + "typecheck": "vue-tsc --noEmit", + "test": "vitest", + "test:run": "vitest run" + }, + "dependencies": { + "pinia": "^2.1.7", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vitest": "^2.1.8", + "vue-tsc": "^2.1.10" + } +} diff --git a/frontend/postcss.config.cjs b/frontend/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/app/AppShell.vue b/frontend/src/app/AppShell.vue new file mode 100644 index 0000000..5bee674 --- /dev/null +++ b/frontend/src/app/AppShell.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/app/guards.ts b/frontend/src/app/guards.ts new file mode 100644 index 0000000..0a63a9a --- /dev/null +++ b/frontend/src/app/guards.ts @@ -0,0 +1,23 @@ +import type { Router } from "vue-router"; + +import { useAuthStore } from "@/stores/auth"; + +export function applyRouteGuards(router: Router): void { + router.beforeEach((to) => { + const auth = useAuthStore(); + + if (to.meta.requiresAuth && !auth.isAuthenticated) { + return { name: "login" }; + } + + if (to.meta.requiresAdmin && !auth.isAdmin) { + return { name: "dashboard" }; + } + + if (to.meta.guestOnly && auth.isAuthenticated) { + return { name: "dashboard" }; + } + + return true; + }); +} diff --git a/frontend/src/app/providers.ts b/frontend/src/app/providers.ts new file mode 100644 index 0000000..4f6db55 --- /dev/null +++ b/frontend/src/app/providers.ts @@ -0,0 +1,12 @@ +import { setCsrfTokenProvider } from "@/lib/api/client"; +import { useAuthStore } from "@/stores/auth"; +import { useThemeStore } from "@/stores/theme"; + +export async function bootstrapAppProviders(): Promise { + const theme = useThemeStore(); + theme.initialize(); + + const auth = useAuthStore(); + setCsrfTokenProvider(() => auth.csrfToken); + await auth.bootstrapSession(); +} diff --git a/frontend/src/app/router.ts b/frontend/src/app/router.ts new file mode 100644 index 0000000..1527b7a --- /dev/null +++ b/frontend/src/app/router.ts @@ -0,0 +1,84 @@ +import { createRouter, createWebHistory } from "vue-router"; + +import { applyRouteGuards } from "@/app/guards"; +import LoginPage from "@/pages/LoginPage.vue"; +import DashboardPage from "@/pages/DashboardPage.vue"; +import ScholarsPage from "@/pages/ScholarsPage.vue"; +import PublicationsPage from "@/pages/PublicationsPage.vue"; +import RunsPage from "@/pages/RunsPage.vue"; +import RunDetailPage from "@/pages/RunDetailPage.vue"; +import SettingsPage from "@/pages/SettingsPage.vue"; +import AdminUsersPage from "@/pages/AdminUsersPage.vue"; +import StyleGuidePage from "@/pages/StyleGuidePage.vue"; + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/login", + name: "login", + component: LoginPage, + meta: { guestOnly: true }, + }, + { + path: "/", + redirect: "/dashboard", + }, + { + path: "/dashboard", + name: "dashboard", + component: DashboardPage, + meta: { requiresAuth: true }, + }, + { + path: "/scholars", + name: "scholars", + component: ScholarsPage, + meta: { requiresAuth: true }, + }, + { + path: "/publications", + name: "publications", + component: PublicationsPage, + meta: { requiresAuth: true }, + }, + { + path: "/settings", + name: "settings", + component: SettingsPage, + meta: { requiresAuth: true }, + }, + { + path: "/admin/style-guide", + name: "style-guide", + component: StyleGuidePage, + meta: { requiresAuth: true, requiresAdmin: true }, + }, + { + path: "/admin/runs", + name: "runs", + component: RunsPage, + meta: { requiresAuth: true, requiresAdmin: true }, + }, + { + path: "/admin/runs/:id", + name: "run-detail", + component: RunDetailPage, + meta: { requiresAuth: true, requiresAdmin: true }, + }, + { + path: "/admin/users", + name: "admin-users", + component: AdminUsersPage, + meta: { requiresAuth: true, requiresAdmin: true }, + }, + { + path: "/:pathMatch(.*)*", + redirect: "/dashboard", + }, + ], +}); + +applyRouteGuards(router); + +export default router; diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue new file mode 100644 index 0000000..281a257 --- /dev/null +++ b/frontend/src/components/layout/AppHeader.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/src/components/layout/AppNav.vue b/frontend/src/components/layout/AppNav.vue new file mode 100644 index 0000000..fc871e7 --- /dev/null +++ b/frontend/src/components/layout/AppNav.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/components/layout/AppPage.vue b/frontend/src/components/layout/AppPage.vue new file mode 100644 index 0000000..bd02771 --- /dev/null +++ b/frontend/src/components/layout/AppPage.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/components/patterns/QueueHealthBadge.vue b/frontend/src/components/patterns/QueueHealthBadge.vue new file mode 100644 index 0000000..71b15e3 --- /dev/null +++ b/frontend/src/components/patterns/QueueHealthBadge.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/patterns/RequestErrorPanel.vue b/frontend/src/components/patterns/RequestErrorPanel.vue new file mode 100644 index 0000000..874d694 --- /dev/null +++ b/frontend/src/components/patterns/RequestErrorPanel.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/patterns/RunStatusBadge.vue b/frontend/src/components/patterns/RunStatusBadge.vue new file mode 100644 index 0000000..cb73162 --- /dev/null +++ b/frontend/src/components/patterns/RunStatusBadge.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/src/components/ui/AppAlert.vue b/frontend/src/components/ui/AppAlert.vue new file mode 100644 index 0000000..e119708 --- /dev/null +++ b/frontend/src/components/ui/AppAlert.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/components/ui/AppBadge.vue b/frontend/src/components/ui/AppBadge.vue new file mode 100644 index 0000000..53edb75 --- /dev/null +++ b/frontend/src/components/ui/AppBadge.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/components/ui/AppButton.vue b/frontend/src/components/ui/AppButton.vue new file mode 100644 index 0000000..1df0991 --- /dev/null +++ b/frontend/src/components/ui/AppButton.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/components/ui/AppCard.vue b/frontend/src/components/ui/AppCard.vue new file mode 100644 index 0000000..fbbed0d --- /dev/null +++ b/frontend/src/components/ui/AppCard.vue @@ -0,0 +1,5 @@ + diff --git a/frontend/src/components/ui/AppCheckbox.vue b/frontend/src/components/ui/AppCheckbox.vue new file mode 100644 index 0000000..481918a --- /dev/null +++ b/frontend/src/components/ui/AppCheckbox.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/AppEmptyState.vue b/frontend/src/components/ui/AppEmptyState.vue new file mode 100644 index 0000000..fe87ca7 --- /dev/null +++ b/frontend/src/components/ui/AppEmptyState.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/components/ui/AppInput.vue b/frontend/src/components/ui/AppInput.vue new file mode 100644 index 0000000..aa02f8d --- /dev/null +++ b/frontend/src/components/ui/AppInput.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/AppModal.vue b/frontend/src/components/ui/AppModal.vue new file mode 100644 index 0000000..920a267 --- /dev/null +++ b/frontend/src/components/ui/AppModal.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/AppSelect.vue b/frontend/src/components/ui/AppSelect.vue new file mode 100644 index 0000000..b9ad991 --- /dev/null +++ b/frontend/src/components/ui/AppSelect.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/ui/AppSkeleton.vue b/frontend/src/components/ui/AppSkeleton.vue new file mode 100644 index 0000000..876d146 --- /dev/null +++ b/frontend/src/components/ui/AppSkeleton.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/AppTable.vue b/frontend/src/components/ui/AppTable.vue new file mode 100644 index 0000000..d369eaa --- /dev/null +++ b/frontend/src/components/ui/AppTable.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/src/features/admin_users/index.ts b/frontend/src/features/admin_users/index.ts new file mode 100644 index 0000000..d933a47 --- /dev/null +++ b/frontend/src/features/admin_users/index.ts @@ -0,0 +1,49 @@ +import { apiRequest } from "@/lib/api/client"; + +export interface AdminUser { + id: number; + email: string; + is_active: boolean; + is_admin: boolean; + created_at: string; + updated_at: string; +} + +interface UsersListData { + users: AdminUser[]; +} + +export interface CreateAdminUserPayload { + email: string; + password: string; + is_admin: boolean; +} + +export async function listAdminUsers(): Promise { + const response = await apiRequest("/admin/users", { method: "GET" }); + return response.data.users; +} + +export async function createAdminUser(payload: CreateAdminUserPayload): Promise { + const response = await apiRequest("/admin/users", { + method: "POST", + body: payload, + }); + return response.data; +} + +export async function setAdminUserActive(userId: number, isActive: boolean): Promise { + const response = await apiRequest(`/admin/users/${userId}/active`, { + method: "PATCH", + body: { is_active: isActive }, + }); + return response.data; +} + +export async function resetAdminUserPassword(userId: number, newPassword: string): Promise<{ message: string }> { + const response = await apiRequest<{ message: string }>(`/admin/users/${userId}/reset-password`, { + method: "POST", + body: { new_password: newPassword }, + }); + return response.data; +} diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts new file mode 100644 index 0000000..80d73fd --- /dev/null +++ b/frontend/src/features/auth/index.ts @@ -0,0 +1,9 @@ +export { fetchCsrfBootstrap, type CsrfBootstrapData } from "@/lib/api/csrf"; +export { + fetchMe, + loginSession, + logoutSession, + type AuthSessionData, + type MessageData, + type SessionUser, +} from "@/lib/auth/session"; diff --git a/frontend/src/features/dashboard/index.ts b/frontend/src/features/dashboard/index.ts new file mode 100644 index 0000000..dfba916 --- /dev/null +++ b/frontend/src/features/dashboard/index.ts @@ -0,0 +1,58 @@ +import { + listPublications, + type PublicationItem, + type PublicationMode, +} from "@/features/publications"; +import { listQueueItems, listRuns, type RunListItem } from "@/features/runs"; + +export interface QueueHealth { + queued: number; + retrying: number; + dropped: number; +} + +export interface DashboardSnapshot { + newCount: number; + totalCount: number; + mode: PublicationMode; + latestRun: RunListItem | null; + recentRuns: RunListItem[]; + recentPublications: PublicationItem[]; + queue: QueueHealth; +} + +function countQueueStatuses(statuses: string[]): QueueHealth { + return statuses.reduce( + (acc, status) => { + if (status === "queued") { + acc.queued += 1; + } else if (status === "retrying") { + acc.retrying += 1; + } else if (status === "dropped") { + acc.dropped += 1; + } + return acc; + }, + { queued: 0, retrying: 0, dropped: 0 }, + ); +} + +export async function fetchDashboardSnapshot(): Promise { + const [publications, runs, queueItems] = await Promise.all([ + listPublications({ mode: "new", limit: 20 }), + listRuns({ limit: 5 }), + listQueueItems(200), + ]); + + const queueHealth = countQueueStatuses(queueItems.map((item) => item.status)); + + return { + newCount: publications.new_count, + totalCount: publications.total_count, + mode: publications.mode, + latestRun: runs[0] ?? null, + recentRuns: runs, + recentPublications: publications.publications, + queue: queueHealth, + }; +} diff --git a/frontend/src/features/publications/index.ts b/frontend/src/features/publications/index.ts new file mode 100644 index 0000000..dff2b55 --- /dev/null +++ b/frontend/src/features/publications/index.ts @@ -0,0 +1,81 @@ +import { apiRequest } from "@/lib/api/client"; + +export type PublicationMode = "all" | "new"; + +export interface PublicationItem { + publication_id: number; + scholar_profile_id: number; + scholar_label: string; + title: string; + year: number | null; + citation_count: number; + venue_text: string | null; + pub_url: string | null; + is_read: boolean; + first_seen_at: string; + is_new_in_latest_run: boolean; +} + +export interface PublicationsResult { + mode: PublicationMode; + selected_scholar_profile_id: number | null; + new_count: number; + total_count: number; + publications: PublicationItem[]; +} + +export interface PublicationsQuery { + mode?: PublicationMode; + scholarProfileId?: number; + limit?: number; +} + +export interface PublicationSelection { + scholar_profile_id: number; + publication_id: number; +} + +export async function listPublications(query: PublicationsQuery = {}): Promise { + const params = new URLSearchParams(); + + if (query.mode) { + params.set("mode", query.mode); + } + if (query.scholarProfileId) { + params.set("scholar_profile_id", String(query.scholarProfileId)); + } + if (query.limit) { + params.set("limit", String(query.limit)); + } + + const suffix = params.toString(); + const response = await apiRequest( + `/publications${suffix ? `?${suffix}` : ""}`, + { method: "GET" }, + ); + return response.data; +} + +export async function markAllRead(): Promise<{ message: string; updated_count: number }> { + const response = await apiRequest<{ message: string; updated_count: number }>( + "/publications/mark-all-read", + { method: "POST" }, + ); + return response.data; +} + +export async function markSelectedRead(selections: PublicationSelection[]): Promise<{ + message: string; + requested_count: number; + updated_count: number; +}> { + const response = await apiRequest<{ + message: string; + requested_count: number; + updated_count: number; + }>("/publications/mark-read", { + method: "POST", + body: { selections }, + }); + return response.data; +} diff --git a/frontend/src/features/runs/index.ts b/frontend/src/features/runs/index.ts new file mode 100644 index 0000000..c8c47e8 --- /dev/null +++ b/frontend/src/features/runs/index.ts @@ -0,0 +1,171 @@ +import { apiRequest } from "@/lib/api/client"; + +export interface RunListItem { + id: number; + trigger_type: string; + status: string; + start_dt: string; + end_dt: string | null; + scholar_count: number; + new_publication_count: number; + failed_count: number; + partial_count: number; +} + +export interface RunSummary { + succeeded_count: number; + failed_count: number; + partial_count: number; + failed_state_counts: Record; + failed_reason_counts: Record; +} + +export interface RunScholarResult { + scholar_profile_id: number; + scholar_id: string; + state: string; + state_reason: string | null; + outcome: string; + attempt_count: number; + publication_count: number; + start_cstart: number; + continuation_cstart: number | null; + continuation_enqueued: boolean; + continuation_cleared: boolean; + warnings: string[]; + error: string | null; + debug: Record | null; +} + +export interface RunDetail { + run: RunListItem; + summary: RunSummary; + scholar_results: RunScholarResult[]; +} + +export interface QueueItem { + id: number; + scholar_profile_id: number; + scholar_label: string; + status: string; + reason: string; + dropped_reason: string | null; + attempt_count: number; + resume_cstart: number; + next_attempt_dt: string | null; + updated_at: string; + last_error: string | null; + last_run_id: number | null; +} + +interface RunsListData { + runs: RunListItem[]; +} + +interface QueueListData { + queue_items: QueueItem[]; +} + +export interface RunsListQuery { + failedOnly?: boolean; + limit?: number; +} + +export async function listRuns(query: RunsListQuery = {}): Promise { + const params = new URLSearchParams(); + if (query.failedOnly) { + params.set("failed_only", "true"); + } + if (query.limit) { + params.set("limit", String(query.limit)); + } + + const suffix = params.toString(); + const response = await apiRequest(`/runs${suffix ? `?${suffix}` : ""}`, { + method: "GET", + }); + return response.data.runs; +} + +export async function getRunDetail(runId: number): Promise { + const response = await apiRequest(`/runs/${runId}`, { method: "GET" }); + return response.data; +} + +function generateIdempotencyKey(): string { + const randomUuid = globalThis.crypto?.randomUUID?.(); + if (randomUuid) { + return randomUuid; + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export async function triggerManualRun(): Promise<{ + run_id: number; + status: string; + scholar_count: number; + succeeded_count: number; + failed_count: number; + partial_count: number; + new_publication_count: number; + reused_existing_run: boolean; + idempotency_key: string | null; +}> { + const headers: Record = { + "Idempotency-Key": generateIdempotencyKey(), + }; + + const response = await apiRequest<{ + run_id: number; + status: string; + scholar_count: number; + succeeded_count: number; + failed_count: number; + partial_count: number; + new_publication_count: number; + reused_existing_run: boolean; + idempotency_key: string | null; + }>("/runs/manual", { + method: "POST", + headers, + }); + return response.data; +} + +export async function listQueueItems(limit = 200): Promise { + const response = await apiRequest(`/runs/queue/items?limit=${limit}`, { + method: "GET", + }); + return response.data.queue_items; +} + +export async function retryQueueItem(queueItemId: number): Promise { + const response = await apiRequest(`/runs/queue/${queueItemId}/retry`, { + method: "POST", + }); + return response.data; +} + +export async function dropQueueItem(queueItemId: number): Promise { + const response = await apiRequest(`/runs/queue/${queueItemId}/drop`, { + method: "POST", + }); + return response.data; +} + +export async function clearQueueItem(queueItemId: number): Promise<{ + queue_item_id: number; + previous_status: string; + status: string; + message: string; +}> { + const response = await apiRequest<{ + queue_item_id: number; + previous_status: string; + status: string; + message: string; + }>(`/runs/queue/${queueItemId}`, { + method: "DELETE", + }); + return response.data; +} diff --git a/frontend/src/features/scholars/index.ts b/frontend/src/features/scholars/index.ts new file mode 100644 index 0000000..006082d --- /dev/null +++ b/frontend/src/features/scholars/index.ts @@ -0,0 +1,117 @@ +import { apiRequest } from "@/lib/api/client"; + +export interface ScholarProfile { + id: number; + scholar_id: string; + display_name: string | null; + profile_image_url: string | null; + profile_image_source: "upload" | "override" | "scraped" | "none"; + is_enabled: boolean; + baseline_completed: boolean; + last_run_dt: string | null; + last_run_status: string | null; +} + +export interface ScholarCreatePayload { + scholar_id: string; + profile_image_url?: string; +} + +export interface ScholarSearchCandidate { + scholar_id: string; + display_name: string; + affiliation: string | null; + email_domain: string | null; + cited_by_count: number | null; + interests: string[]; + profile_url: string; + profile_image_url: string | null; +} + +export interface ScholarSearchResult { + query: string; + state: "ok" | "no_results" | "blocked_or_captcha" | "layout_changed" | "network_error"; + state_reason: string; + action_hint: string | null; + candidates: ScholarSearchCandidate[]; + warnings: string[]; +} + +interface ScholarsListData { + scholars: ScholarProfile[]; +} + +interface ScholarSearchData extends ScholarSearchResult {} + +export async function listScholars(): Promise { + const response = await apiRequest("/scholars", { method: "GET" }); + return response.data.scholars; +} + +export async function createScholar(payload: ScholarCreatePayload): Promise { + const response = await apiRequest("/scholars", { + method: "POST", + body: payload, + }); + return response.data; +} + +export async function toggleScholar(scholarProfileId: number): Promise { + const response = await apiRequest(`/scholars/${scholarProfileId}/toggle`, { + method: "PATCH", + }); + return response.data; +} + +export async function deleteScholar(scholarProfileId: number): Promise { + await apiRequest<{ message: string }>(`/scholars/${scholarProfileId}`, { + method: "DELETE", + }); +} + +export async function searchScholarsByName( + query: string, + limit = 10, +): Promise { + const searchParams = new URLSearchParams({ + query, + limit: String(limit), + }); + const response = await apiRequest(`/scholars/search?${searchParams.toString()}`, { + method: "GET", + }); + return response.data; +} + +export async function setScholarImageUrl( + scholarProfileId: number, + imageUrl: string, +): Promise { + const response = await apiRequest(`/scholars/${scholarProfileId}/image/url`, { + method: "PUT", + body: { image_url: imageUrl }, + }); + return response.data; +} + +export async function uploadScholarImage( + scholarProfileId: number, + file: File, +): Promise { + const form = new FormData(); + form.append("image", file); + const response = await apiRequest(`/scholars/${scholarProfileId}/image/upload`, { + method: "POST", + body: form, + }); + return response.data; +} + +export async function clearScholarImage( + scholarProfileId: number, +): Promise { + const response = await apiRequest(`/scholars/${scholarProfileId}/image`, { + method: "DELETE", + }); + return response.data; +} diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts new file mode 100644 index 0000000..d09d55e --- /dev/null +++ b/frontend/src/features/settings/index.ts @@ -0,0 +1,34 @@ +import { apiRequest } from "@/lib/api/client"; + +export interface UserSettings { + auto_run_enabled: boolean; + run_interval_minutes: number; + request_delay_seconds: number; +} + +export interface ChangePasswordPayload { + current_password: string; + new_password: string; + confirm_password: string; +} + +export async function fetchSettings(): Promise { + const response = await apiRequest("/settings", { method: "GET" }); + return response.data; +} + +export async function updateSettings(payload: UserSettings): Promise { + const response = await apiRequest("/settings", { + method: "PUT", + body: payload, + }); + return response.data; +} + +export async function changePassword(payload: ChangePasswordPayload): Promise<{ message: string }> { + const response = await apiRequest<{ message: string }>("/auth/change-password", { + method: "POST", + body: payload, + }); + return response.data; +} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..dd72adc --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,102 @@ +import { + isErrorEnvelope, + isSuccessEnvelope, + readRequestId, + type ApiSuccessEnvelope, +} from "@/lib/api/envelope"; +import { ApiRequestError } from "@/lib/api/errors"; + +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export interface ApiRequestOptions { + method?: HttpMethod; + body?: unknown | FormData; + headers?: Record; +} + +const UNSAFE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); +const API_BASE = "/api/v1"; + +let csrfTokenProvider: (() => string | null) | null = null; + +export function setCsrfTokenProvider(provider: () => string | null): void { + csrfTokenProvider = provider; +} + +export async function apiRequest(path: string, options: ApiRequestOptions = {}): Promise> { + const method = options.method ?? "GET"; + const headers = new Headers(options.headers ?? {}); + headers.set("Accept", "application/json"); + + const hasBody = options.body !== undefined; + const isFormData = typeof FormData !== "undefined" && options.body instanceof FormData; + let requestBody: BodyInit | undefined; + + if (hasBody && !isFormData && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + if (hasBody) { + requestBody = isFormData ? (options.body as FormData) : JSON.stringify(options.body); + } + + if (UNSAFE_METHODS.has(method) && csrfTokenProvider) { + const csrfToken = csrfTokenProvider(); + if (csrfToken) { + headers.set("X-CSRF-Token", csrfToken); + } + } + + const response = await fetch(`${API_BASE}${path}`, { + method, + headers, + credentials: "include", + body: requestBody, + }); + + const raw = await parseResponseBody(response); + const requestId = readRequestId(raw) ?? response.headers.get("X-Request-ID"); + + if (!response.ok) { + if (isErrorEnvelope(raw)) { + throw new ApiRequestError({ + status: response.status, + code: raw.error.code || "error", + message: raw.error.message || "Request failed.", + details: raw.error.details, + requestId, + }); + } + + throw new ApiRequestError({ + status: response.status, + code: "http_error", + message: `Request failed with status ${response.status}.`, + requestId, + }); + } + + if (!isSuccessEnvelope(raw)) { + throw new ApiRequestError({ + status: response.status, + code: "invalid_envelope", + message: "Server returned an unexpected response format.", + requestId, + details: raw, + }); + } + + return raw; +} + +async function parseResponseBody(response: Response): Promise { + const contentType = response.headers.get("Content-Type") || ""; + if (!contentType.includes("application/json")) { + return null; + } + try { + return await response.json(); + } catch (_err) { + return null; + } +} diff --git a/frontend/src/lib/api/csrf.ts b/frontend/src/lib/api/csrf.ts new file mode 100644 index 0000000..38d11ca --- /dev/null +++ b/frontend/src/lib/api/csrf.ts @@ -0,0 +1,10 @@ +import { apiRequest } from "@/lib/api/client"; + +export interface CsrfBootstrapData { + csrf_token: string; + authenticated: boolean; +} + +export async function fetchCsrfBootstrap() { + return apiRequest("/auth/csrf", { method: "GET" }); +} diff --git a/frontend/src/lib/api/envelope.test.ts b/frontend/src/lib/api/envelope.test.ts new file mode 100644 index 0000000..e1bfd14 --- /dev/null +++ b/frontend/src/lib/api/envelope.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { + isErrorEnvelope, + isSuccessEnvelope, + readRequestId, + type ApiSuccessEnvelope, +} from "@/lib/api/envelope"; + +describe("api envelope helpers", () => { + it("recognizes a success envelope", () => { + const payload: ApiSuccessEnvelope<{ ok: boolean }> = { + data: { ok: true }, + meta: { request_id: "req_123" }, + }; + + expect(isSuccessEnvelope(payload)).toBe(true); + expect(isErrorEnvelope(payload)).toBe(false); + expect(readRequestId(payload)).toBe("req_123"); + }); + + it("recognizes an error envelope", () => { + const payload = { + error: { + code: "invalid", + message: "Invalid request", + details: null, + }, + meta: { request_id: "req_456" }, + }; + + expect(isErrorEnvelope(payload)).toBe(true); + expect(isSuccessEnvelope(payload)).toBe(false); + expect(readRequestId(payload)).toBe("req_456"); + }); + + it("returns null when request id is missing or invalid", () => { + expect(readRequestId(null)).toBeNull(); + expect(readRequestId({})).toBeNull(); + expect(readRequestId({ meta: {} })).toBeNull(); + expect(readRequestId({ meta: { request_id: "" } })).toBeNull(); + }); +}); diff --git a/frontend/src/lib/api/envelope.ts b/frontend/src/lib/api/envelope.ts new file mode 100644 index 0000000..f74bbfe --- /dev/null +++ b/frontend/src/lib/api/envelope.ts @@ -0,0 +1,44 @@ +import type { ApiErrorPayload } from "@/lib/api/errors"; + +export interface ApiMeta { + request_id: string | null; +} + +export interface ApiSuccessEnvelope { + data: T; + meta: ApiMeta; +} + +export interface ApiErrorEnvelope { + error: ApiErrorPayload; + meta: ApiMeta; +} + +export function isSuccessEnvelope(value: unknown): value is ApiSuccessEnvelope { + if (typeof value !== "object" || value === null) { + return false; + } + return "data" in value && "meta" in value; +} + +export function isErrorEnvelope(value: unknown): value is ApiErrorEnvelope { + if (typeof value !== "object" || value === null) { + return false; + } + return "error" in value && "meta" in value; +} + +export function readRequestId(payload: unknown): string | null { + if (typeof payload !== "object" || payload === null) { + return null; + } + const meta = (payload as { meta?: unknown }).meta; + if (typeof meta !== "object" || meta === null) { + return null; + } + const requestId = (meta as { request_id?: unknown }).request_id; + if (typeof requestId !== "string" || !requestId.trim()) { + return null; + } + return requestId; +} diff --git a/frontend/src/lib/api/errors.test.ts b/frontend/src/lib/api/errors.test.ts new file mode 100644 index 0000000..5bbac5d --- /dev/null +++ b/frontend/src/lib/api/errors.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { ApiRequestError } from "@/lib/api/errors"; + +describe("ApiRequestError", () => { + it("preserves structured metadata", () => { + const error = new ApiRequestError({ + status: 409, + code: "run_in_progress", + message: "A run is already in progress", + details: { run_id: 42 }, + requestId: "req_789", + }); + + expect(error.name).toBe("ApiRequestError"); + expect(error.status).toBe(409); + expect(error.code).toBe("run_in_progress"); + expect(error.message).toBe("A run is already in progress"); + expect(error.details).toEqual({ run_id: 42 }); + expect(error.requestId).toBe("req_789"); + }); +}); diff --git a/frontend/src/lib/api/errors.ts b/frontend/src/lib/api/errors.ts new file mode 100644 index 0000000..779370e --- /dev/null +++ b/frontend/src/lib/api/errors.ts @@ -0,0 +1,27 @@ +export interface ApiErrorPayload { + code: string; + message: string; + details: unknown; +} + +export class ApiRequestError extends Error { + readonly status: number; + readonly code: string; + readonly details: unknown; + readonly requestId: string | null; + + constructor(params: { + status: number; + code: string; + message: string; + details?: unknown; + requestId?: string | null; + }) { + super(params.message); + this.name = "ApiRequestError"; + this.status = params.status; + this.code = params.code; + this.details = params.details ?? null; + this.requestId = params.requestId ?? null; + } +} diff --git a/frontend/src/lib/auth/session.ts b/frontend/src/lib/auth/session.ts new file mode 100644 index 0000000..050c144 --- /dev/null +++ b/frontend/src/lib/auth/session.ts @@ -0,0 +1,35 @@ +import { apiRequest } from "@/lib/api/client"; + +export interface SessionUser { + id: number; + email: string; + is_admin: boolean; + is_active: boolean; +} + +export interface AuthSessionData { + authenticated: boolean; + csrf_token: string; + user: SessionUser; +} + +export interface MessageData { + message: string; +} + +export async function fetchMe() { + return apiRequest("/auth/me", { method: "GET" }); +} + +export async function loginSession(params: { email: string; password: string }) { + return apiRequest("/auth/login", { + method: "POST", + body: params, + }); +} + +export async function logoutSession() { + return apiRequest("/auth/logout", { + method: "POST", + }); +} diff --git a/frontend/src/lib/utils/request_id.ts b/frontend/src/lib/utils/request_id.ts new file mode 100644 index 0000000..b1293d6 --- /dev/null +++ b/frontend/src/lib/utils/request_id.ts @@ -0,0 +1,8 @@ +import { ApiRequestError } from "@/lib/api/errors"; + +export function toRequestId(value: unknown): string | null { + if (value instanceof ApiRequestError) { + return value.requestId; + } + return null; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..00fe74b --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,22 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; + +import AppShell from "@/app/AppShell.vue"; +import router from "@/app/router"; +import { bootstrapAppProviders } from "@/app/providers"; +import "@/styles.css"; + +async function main(): Promise { + const app = createApp(AppShell); + const pinia = createPinia(); + + app.use(pinia); + await bootstrapAppProviders(); + + app.use(router); + await router.isReady(); + + app.mount("#app"); +} + +void main(); diff --git a/frontend/src/pages/AdminUsersPage.vue b/frontend/src/pages/AdminUsersPage.vue new file mode 100644 index 0000000..7f3b438 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage.vue @@ -0,0 +1,199 @@ + + + diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue new file mode 100644 index 0000000..075f70a --- /dev/null +++ b/frontend/src/pages/DashboardPage.vue @@ -0,0 +1,187 @@ + + + diff --git a/frontend/src/pages/LoginPage.vue b/frontend/src/pages/LoginPage.vue new file mode 100644 index 0000000..5833211 --- /dev/null +++ b/frontend/src/pages/LoginPage.vue @@ -0,0 +1,129 @@ + + + diff --git a/frontend/src/pages/PublicationsPage.vue b/frontend/src/pages/PublicationsPage.vue new file mode 100644 index 0000000..0b2cccb --- /dev/null +++ b/frontend/src/pages/PublicationsPage.vue @@ -0,0 +1,449 @@ + + + diff --git a/frontend/src/pages/RunDetailPage.vue b/frontend/src/pages/RunDetailPage.vue new file mode 100644 index 0000000..d858ee3 --- /dev/null +++ b/frontend/src/pages/RunDetailPage.vue @@ -0,0 +1,134 @@ + + + diff --git a/frontend/src/pages/RunsPage.vue b/frontend/src/pages/RunsPage.vue new file mode 100644 index 0000000..9e12237 --- /dev/null +++ b/frontend/src/pages/RunsPage.vue @@ -0,0 +1,266 @@ + + + diff --git a/frontend/src/pages/ScholarsPage.vue b/frontend/src/pages/ScholarsPage.vue new file mode 100644 index 0000000..5ec0f76 --- /dev/null +++ b/frontend/src/pages/ScholarsPage.vue @@ -0,0 +1,921 @@ + + +