diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..37f63f90 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +# Exclude everything from the Docker context by default. +# The backend image is built from the repository root because uv keeps the +# workspace lockfile at the root level. +* + +# Workspace dependency files +!pyproject.toml +!uv.lock + +# Backend source and config +!backend/ +!backend/pyproject.toml +!backend/alembic.ini +!backend/alembic/ +!backend/alembic/** +!backend/app/ +!backend/app/** +!backend/entrypoint.sh + +# Do not ship test code in the image +backend/tests/ diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 083f2e9b..b33aa92c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -75,7 +75,7 @@ jobs: - name: backend image: librislog-api dockerfile: ./backend/Dockerfile - context: ./backend + context: . arch: [amd64, arm64] diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6c078b5f..e52af498 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -112,7 +112,8 @@ jobs: - name: Build backend image uses: docker/build-push-action@v7 with: - context: ./backend + context: . + file: ./backend/Dockerfile tags: librislog-e2e-backend load: true build-args: | diff --git a/.gitignore b/.gitignore index edabd7dd..87c957de 100644 --- a/.gitignore +++ b/.gitignore @@ -216,8 +216,6 @@ __marimo__/ # Streamlit .streamlit/secrets.toml -/.memsearch -/.playwright-mcp /ideas.txt /backend/data/ @@ -227,7 +225,14 @@ __marimo__/ !frontend/src/lib cookies.txt profile-snapshot -.plan/ + node_modules/ /*.png -*-snapshot.md \ No newline at end of file +*-snapshot.md + +# AI tools +/.memsearch +/.playwright-mcp +/.sverklo +.plan/ +/.opencode \ No newline at end of file diff --git a/README.md b/README.md index 5ef60260..52f90e6c 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Docs Build Python Svelte - FastAPI + FastAPI License

@@ -95,8 +95,8 @@ The backend is a standalone FastAPI application. The full API is documented via Create API keys from the web UI (Profile → API Keys) for headless access. See the [API Reference](https://docs.librislog.app/api/) for details. ```bash -cd backend uv sync +cd backend uv run alembic upgrade head uv run uvicorn app.main:app --reload ``` @@ -128,10 +128,12 @@ MIT ## Star History +## Star History + - - - Star History Chart + + + Star History Chart diff --git a/backend/Dockerfile b/backend/Dockerfile index 3394ca7a..f35b7eb2 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,26 +5,32 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv WORKDIR /app -# Copy dependency files first for layer caching +# Copy workspace dependency files first for layer caching. +# The backend is a uv workspace member, so the lockfile lives at the repo root. COPY pyproject.toml uv.lock ./ +COPY backend/pyproject.toml ./backend/ -# Install production dependencies only (no dev extras) -RUN uv sync --no-dev --frozen +# Sync only the backend workspace member's production dependencies. +# The workspace root keeps the lockfile, so we build from the repo root context. +RUN uv sync --project backend --no-dev --frozen # Copy application source -COPY alembic.ini ./ -COPY alembic/ ./alembic/ -COPY app/ ./app/ +COPY backend/alembic.ini ./backend/ +COPY backend/alembic/ ./backend/alembic/ +COPY backend/app/ ./backend/app/ + +# Make the backend source importable from the workspace root +ENV PYTHONPATH=/app/backend # Inject version from build args (overwrites fallback in _build_info.py) ARG APP_VERSION=v0.0.0-dev ARG GIT_SHA=unknown -RUN echo "__version__ = \"$APP_VERSION\"" > app/_build_info.py && \ - echo "__git_sha__ = \"$GIT_SHA\"" >> app/_build_info.py +RUN echo "__version__ = \"$APP_VERSION\"" > ./backend/app/_build_info.py && \ + echo "__git_sha__ = \"$GIT_SHA\"" >> ./backend/app/_build_info.py # Entrypoint: run migrations then start server -COPY entrypoint.sh ./ -RUN chmod +x entrypoint.sh +COPY backend/entrypoint.sh ./backend/ +RUN chmod +x ./backend/entrypoint.sh EXPOSE 8000 -ENTRYPOINT ["./entrypoint.sh"] +ENTRYPOINT ["./backend/entrypoint.sh"] diff --git a/backend/alembic/versions/e2f3a4b5c6d7_add_acquisition_status.py b/backend/alembic/versions/e2f3a4b5c6d7_add_acquisition_status.py new file mode 100644 index 00000000..b6385f88 --- /dev/null +++ b/backend/alembic/versions/e2f3a4b5c6d7_add_acquisition_status.py @@ -0,0 +1,32 @@ +"""add acquisition status to books + +Revision ID: e2f3a4b5c6d7 +Revises: 1a2b3c4d5e6f +Create Date: 2026-08-22 20:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "e2f3a4b5c6d7" +down_revision: Union[str, Sequence[str], None] = "784de5d2bf69" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("book") as batch_op: + batch_op.add_column(sa.Column("acquisition_status", sa.String(length=32), nullable=True)) + op.execute("UPDATE book SET acquisition_status = 'owned' WHERE acquisition_status IS NULL") + with op.batch_alter_table("book") as batch_op: + batch_op.alter_column("acquisition_status", nullable=False) + batch_op.create_index("ix_book_acquisition_status", ["acquisition_status"]) + + +def downgrade() -> None: + with op.batch_alter_table("book") as batch_op: + batch_op.drop_index("ix_book_acquisition_status") + batch_op.drop_column("acquisition_status") diff --git a/backend/app/auth.py b/backend/app/auth.py index e1fbc294..a623e78b 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -12,7 +12,7 @@ from fastapi.security import APIKeyHeader from passlib.exc import UnknownHashError from passlib.context import CryptContext -from sqlmodel import Session, select +from sqlmodel import Session, col, select from itsdangerous import URLSafeTimedSerializer @@ -27,7 +27,7 @@ class _BcryptAbout: __version__: str = getattr(bcrypt, "__version__", "") - bcrypt.__about__ = _BcryptAbout() # type: ignore[attr-defined] + bcrypt.__about__ = _BcryptAbout() # ty: ignore[unresolved-attribute] bcrypt_context: CryptContext = CryptContext(schemes=["bcrypt"], deprecated="auto") fallback_context: CryptContext = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") @@ -190,7 +190,7 @@ def require_user_by_api_key( key_hash = hash_api_key(x_api_key) key = session.exec( - select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.revoked_at.is_(None)) + select(ApiKey).where(ApiKey.key_hash == key_hash, col(ApiKey.revoked_at).is_(None)) ).first() if not key: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") diff --git a/backend/app/models.py b/backend/app/models.py index ade3412a..b3a48178 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -44,6 +44,15 @@ class ReadingStatus(str, Enum): did_not_finish = "did_not_finish" +class AcquisitionStatus(str, Enum): + """Enum of a book's current availability to the user.""" + + owned = "owned" + borrowed = "borrowed" + digital_access = "digital_access" + to_acquire = "to_acquire" + + class UserRole(str, Enum): """Enum of possible user roles.""" @@ -79,6 +88,7 @@ def normalize_empty_cover_url(cls, data: dict) -> dict: blurb: Optional[str] = None rating: Optional[int] = Field(default=None, ge=1, le=5) reading_status: ReadingStatus = Field(default=ReadingStatus.want_to_read, index=True) + acquisition_status: AcquisitionStatus = Field(default=AcquisitionStatus.owned, index=True) user_id: Optional[int] = Field(default=None, foreign_key="user.id", index=True) date_added: datetime = Field( default_factory=utcnow, @@ -97,7 +107,7 @@ def normalize_empty_cover_url(cls, data: dict) -> dict: class Tag(SQLModel, table=True): """A user-specific tag that can be applied to books.""" - __tablename__ = "tag" + __tablename__: str = "tag" __table_args__ = (sa.UniqueConstraint("user_id", "name", name="uq_tag_user_id_name"),) id: Optional[int] = Field(default=None, primary_key=True) @@ -112,7 +122,7 @@ class Tag(SQLModel, table=True): class BookTag(SQLModel, table=True): """Many-to-many association between books and tags.""" - __tablename__ = "book_tag" + __tablename__: str = "book_tag" book_id: int = Field(foreign_key="book.id", primary_key=True) tag_id: int = Field(foreign_key="tag.id", primary_key=True, index=True) @@ -178,7 +188,7 @@ class ApiKey(SQLModel, table=True): class ReadingProgress(SQLModel, table=True): """A page-number reading progress entry for a book.""" - __tablename__ = "reading_progress" + __tablename__: str = "reading_progress" id: Optional[int] = Field(default=None, primary_key=True) book_id: int = Field(foreign_key="book.id", index=True) @@ -212,7 +222,7 @@ class OidcLink(SQLModel, table=True): class EmbedToken(SQLModel, table=True): """A scoped embed token for iframe/dashboard integrations.""" - __tablename__ = "embed_token" + __tablename__: str = "embed_token" id: Optional[int] = Field(default=None, primary_key=True) user_id: int = Field(foreign_key="user.id", index=True) @@ -242,7 +252,7 @@ class EmbedToken(SQLModel, table=True): class ImportMapping(SQLModel, table=True): """A saved column-mapping configuration for data import.""" - __tablename__ = "import_mapping" + __tablename__: str = "import_mapping" __table_args__ = ( sa.UniqueConstraint("user_id", "name", name="uq_import_mapping_user_id_name"), ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index de90bea0..75071e9f 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -63,6 +63,7 @@ def setup( session.add(user) session.commit() session.refresh(user) + assert user.id is not None session.add(UserSettings(user_id=user.id, language="en")) session.commit() @@ -81,6 +82,7 @@ def login( user = session.exec(select(User).where(User.email == credentials.email)).first() if not user or not verify_password(credentials.password, user.hashed_password): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password") + assert user.id is not None start_browser_session(http_request, user.id, user.credentials_version) return {"user": UserRead.model_validate(user)} diff --git a/backend/app/routers/books.py b/backend/app/routers/books.py index 05f96f30..eea30027 100644 --- a/backend/app/routers/books.py +++ b/backend/app/routers/books.py @@ -7,12 +7,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status import sqlalchemy as sa from sqlalchemy.exc import IntegrityError -from sqlmodel import Session, func, or_, select +from sqlmodel import Session, col, func, or_, select from app.auth import require_user from app.config import settings from app.database import get_session -from app.models import Book, BookTag, ReadingProgress, ReadingStatus, Tag, User +from app.models import AcquisitionStatus, Book, BookTag, ReadingProgress, ReadingStatus, Tag, User from app.schemas import ( BookCreate, BookListResponse, @@ -140,6 +140,7 @@ def _build_book_read_with_tags(book: Book, tags_text: str | None) -> BookRead: @router.get("", response_model=BookListResponse) def list_books( status: Optional[ReadingStatus] = Query(default=None), + acquisition_status: Optional[AcquisitionStatus] = Query(default=None), q: Optional[str] = Query(default=None), has_cover: Optional[bool] = Query(default=None), sort: Literal["title", "date_added", "date_started", "date_finished", "rating"] = Query( @@ -167,28 +168,31 @@ def list_books( if status is not None: base_statement = base_statement.where(Book.reading_status == status) + if acquisition_status is not None: + base_statement = base_statement.where(Book.acquisition_status == acquisition_status) + if q: pattern = f"%{q}%" - matching_tag_book_ids = select(BookTag.book_id).join(Tag, Tag.id == BookTag.tag_id).where( + matching_tag_book_ids = select(BookTag.book_id).join(Tag, col(Tag.id) == BookTag.tag_id).where( Tag.user_id == current_user.id, - Tag.name.ilike(pattern), + col(Tag.name).ilike(pattern), ) base_statement = base_statement.where( or_( - Book.title.ilike(pattern), - Book.subtitle.ilike(pattern), - Book.author.ilike(pattern), - Book.blurb.ilike(pattern), - Book.id.in_(matching_tag_book_ids), + col(Book.title).ilike(pattern), + col(Book.subtitle).ilike(pattern), + col(Book.author).ilike(pattern), + col(Book.blurb).ilike(pattern), + col(Book.id).in_(matching_tag_book_ids), ) ) if has_cover is not None: if has_cover: - base_statement = base_statement.where(Book.cover_url.is_not(None), Book.cover_url != "") + base_statement = base_statement.where(col(Book.cover_url).is_not(None), Book.cover_url != "") else: base_statement = base_statement.where( - sa.or_(Book.cover_url.is_(None), Book.cover_url == "") + sa.or_(col(Book.cover_url).is_(None), col(Book.cover_url) == "") ) total = session.exec( @@ -214,7 +218,7 @@ def list_books( sort_col = Book.date_added sort_order = order - sort_expression = sort_col.desc() if sort_order == "desc" else sort_col.asc() + sort_expression = col(sort_col).desc() if sort_order == "desc" else col(sort_col).asc() if sort_col in (Book.date_started, Book.date_finished): sort_expression = sort_expression.nullslast() @@ -302,13 +306,13 @@ def get_tag_cloud( session: Session = Depends(get_session), ) -> List[TagCloudEntry]: """Return tags sorted by usage count (descending) for the authenticated user.""" - count_label = func.count(BookTag.book_id).label("cnt") + count_label = func.count(col(BookTag.book_id)).label("cnt") rows = session.exec( select(Tag.name, count_label) - .join(BookTag, BookTag.tag_id == Tag.id) + .join(BookTag, col(BookTag.tag_id) == col(Tag.id)) .where(Tag.user_id == current_user.id) - .group_by(Tag.id) - .order_by(count_label.desc(), Tag.name.asc()) + .group_by(col(Tag.id)) + .order_by(count_label.desc(), col(Tag.name).asc()) .limit(limit) ).all() return [TagCloudEntry(tag=name, count=count) for name, count in rows] @@ -348,6 +352,7 @@ def suggest_authors( session: Session = Depends(get_session), ) -> SuggestionList: """Autocomplete author names from the user's existing books.""" + assert current_user.id is not None suggestions = _suggest_field(session, current_user.id, "author", q, limit) return SuggestionList(suggestions=suggestions) @@ -360,6 +365,7 @@ def suggest_publishers( session: Session = Depends(get_session), ) -> SuggestionList: """Autocomplete publisher names from the user's existing books.""" + assert current_user.id is not None suggestions = _suggest_field(session, current_user.id, "publisher", q, limit) return SuggestionList(suggestions=suggestions) @@ -379,7 +385,7 @@ def suggest_tags( select(Tag.name) .where( Tag.user_id == current_user.id, - Tag.name.ilike(pattern), + col(Tag.name).ilike(pattern), ) .distinct() .order_by(Tag.name) @@ -396,11 +402,12 @@ async def create_book( ) -> BookRead: """Create a new book, downloading the cover if an external URL is provided.""" logger.debug("create_book — title=%r", book_in.title) + assert current_user.id is not None cover_url = book_in.cover_url if is_external_cover_url(cover_url): filename = await import_cover_from_url( - cover_url, + cover_url or "", settings.covers_dir, current_user.id, settings.cover_import_timeout_seconds, @@ -460,6 +467,7 @@ async def update_book( ) -> BookRead: """Partially update a book, handling cover download and tag sync.""" logger.debug("update_book — id=%s fields=%s", book_id, list(book_in.model_dump(exclude_unset=True))) + assert current_user.id is not None book = session.get(Book, book_id) if not book or book.user_id != current_user.id: logger.debug("update_book — id=%s not found", book_id) @@ -512,6 +520,7 @@ async def update_book( session.rollback() _raise_integrity_conflict(exc) if tags_provided: + assert book.id is not None sync_book_tags(session, current_user.id, book.id, tags_raw) cleanup_orphan_tags(session, current_user.id) try: @@ -654,6 +663,7 @@ def delete_book( ) -> None: """Delete a book, its tags, progress entries, and orphaned cover files.""" logger.debug("delete_book — id=%s", book_id) + assert current_user.id is not None book = session.get(Book, book_id) if not book or book.user_id != current_user.id: logger.debug("delete_book — id=%s not found", book_id) diff --git a/backend/app/routers/cover_candidates.py b/backend/app/routers/cover_candidates.py index 03bc319a..6446ad75 100644 --- a/backend/app/routers/cover_candidates.py +++ b/backend/app/routers/cover_candidates.py @@ -2,7 +2,7 @@ import asyncio import logging -from typing import Optional +from typing import Any, Optional import httpx from fastapi import APIRouter, Depends, HTTPException, Query @@ -24,7 +24,7 @@ _THALIA_FETCHER_CLASS: object = None -def _get_thalia_fetcher_class() -> object: +def _get_thalia_fetcher_class() -> Any: """Lazily import and configure the Scrapling Fetcher for Thalia.de.""" global _THALIA_FETCHER_CLASS if _THALIA_FETCHER_CLASS is None: @@ -41,7 +41,7 @@ def _get_thalia_fetcher_class() -> object: return _THALIA_FETCHER_CLASS -def _extract_css_adaptive(page: object, selector: str, attr: str | None = None) -> str | None: +def _extract_css_adaptive(page: Any, selector: str, attr: str | None = None) -> str | None: """Extract a CSS value with adaptive fallback. First tries exact selector with ``auto_save`` (to refresh stored fingerprint). diff --git a/backend/app/routers/data.py b/backend/app/routers/data.py index 699e4784..94cb0b23 100644 --- a/backend/app/routers/data.py +++ b/backend/app/routers/data.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile from fastapi.responses import StreamingResponse from sqlalchemy.exc import IntegrityError -from sqlmodel import Session, select +from sqlmodel import Session, col, select from app.auth import require_user from app.config import settings @@ -90,6 +90,7 @@ async def parse_import_file( current_user: User = Depends(require_user), ) -> DataImportParseResponse: """Parse an uploaded CSV or JSON import file and return field info and samples.""" + assert current_user.id is not None allowed_content_types = { "text/csv", "application/csv", @@ -112,6 +113,7 @@ def suggest_import_mapping( current_user: User = Depends(require_user), ) -> DataImportSuggestResponse: """Suggest a field-name mapping based on the parsed import file.""" + assert current_user.id is not None try: parsed = load_parsed_upload(body.file_id, current_user.id) except FileNotFoundError as exc: @@ -130,6 +132,7 @@ def save_import_mapping( session: Session = Depends(get_session), ) -> DataImportMappingRead: """Create or update a saved column-mapping configuration.""" + assert current_user.id is not None now = utcnow() schema_fingerprint = compute_schema_fingerprint(body.source_fields) @@ -191,7 +194,7 @@ def list_import_mappings( session.exec( select(ImportMapping) .where(ImportMapping.user_id == current_user.id) - .order_by(ImportMapping.updated_at.desc()) + .order_by(col(ImportMapping.updated_at).desc()) ).all() ) user_mappings = [ @@ -261,6 +264,7 @@ def validate_import_data( payload = validate_import( body.file_id, current_user, body.mapping, session, create_progress_for_read=body.create_progress_for_read, + require_acquisition_status=True, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @@ -274,7 +278,7 @@ def preview_import_data( ) -> DataImportPreviewResponse: """Preview how a mapping and transforms will affect the first rows.""" try: - payload = preview_import(body.file_id, current_user, body.mapping) + payload = preview_import(body.file_id, current_user, body.mapping, require_acquisition_status=True) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return DataImportPreviewResponse.model_validate(payload) @@ -302,6 +306,7 @@ async def event_generator(): session=session, import_mode=body.import_mode, create_progress_for_read=body.create_progress_for_read, + require_acquisition_status=True, ): if event.get("event") == "complete": completed = True diff --git a/backend/app/routers/embed.py b/backend/app/routers/embed.py index 5bc33af9..f596ee67 100644 --- a/backend/app/routers/embed.py +++ b/backend/app/routers/embed.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi.responses import HTMLResponse, Response -from sqlmodel import Session, select +from sqlmodel import Session, col, select from app.auth import EMBED_TOKEN_SCOPE_STATS_READ, hash_embed_token from app.database import get_session @@ -68,7 +68,7 @@ def _verify_embed_token( db_token = session.exec( select(EmbedToken).where( EmbedToken.token_hash == token_hash_val, - EmbedToken.revoked_at.is_(None), + col(EmbedToken.revoked_at).is_(None), ) ).first() @@ -270,14 +270,14 @@ def get_embed_stats( invalid = keys - VALID_STAT_KEYS if invalid: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"Invalid stat keys: {', '.join(sorted(invalid))}. Valid: {', '.join(sorted(VALID_STAT_KEYS))}", ) show_set = keys if keys else None if layout not in LAYOUT_MODES: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"Invalid layout '{layout}'. Valid: {', '.join(sorted(LAYOUT_MODES))}", ) diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py index 8c3d3ec1..80457b83 100644 --- a/backend/app/routers/health.py +++ b/backend/app/routers/health.py @@ -40,7 +40,7 @@ def _result(*, healthy: bool, detail: str | None = None) -> dict: db_ok = True db_detail = None try: - db_session.execute(text("SELECT 1")) + db_session.connection().execute(text("SELECT 1")) except Exception as exc: db_ok = False db_detail = str(exc) @@ -53,6 +53,8 @@ def _result(*, healthy: bool, detail: str | None = None) -> dict: schema_detail = None try: inspector = inspect(db_session.bind) + if inspector is None: + raise RuntimeError("Engine binding returned no inspector") existing = set(inspector.get_table_names()) required = {"user", "book"} missing = required - existing diff --git a/backend/app/routers/hygiene.py b/backend/app/routers/hygiene.py index aead46ae..8ea294ba 100644 --- a/backend/app/routers/hygiene.py +++ b/backend/app/routers/hygiene.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import and_, or_ -from sqlmodel import Session, func, select, update as sqlmodel_update +from sqlmodel import Session, col, func, select, update as sqlmodel_update from app.auth import require_user from app.config import settings @@ -207,7 +207,7 @@ async def batch_update( filename = await import_cover_from_url( url, settings.covers_dir, - current_user.id, # type: ignore[arg-type] + current_user.id, # ty: ignore[invalid-argument-type] settings.cover_import_timeout_seconds, ) if filename: @@ -218,7 +218,7 @@ async def batch_update( books = session.exec( select(Book).where( - Book.id.in_(req.book_ids), # type: ignore[union-attr] + col(Book.id).in_(req.book_ids), Book.user_id == current_user.id, ) ).all() @@ -236,16 +236,16 @@ async def batch_update( for book in books: current_val = getattr(book, req.field.value) if current_val == req.value: - skipped_ids.append(book.id) # type: ignore[arg-type] + skipped_ids.append(book.id) # ty: ignore[invalid-argument-type] else: - to_update_ids.append(book.id) # type: ignore[arg-type] + to_update_ids.append(book.id) # ty: ignore[invalid-argument-type] updated = 0 if to_update_ids: try: stmt = ( sqlmodel_update(Book) - .where(Book.id.in_(to_update_ids)) # type: ignore[union-attr] + .where(col(Book.id).in_(to_update_ids)) .values({req.field.value: req.value}) ) updated = len(to_update_ids) diff --git a/backend/app/routers/import_.py b/backend/app/routers/import_.py index 3690966e..d094bdc9 100644 --- a/backend/app/routers/import_.py +++ b/backend/app/routers/import_.py @@ -117,6 +117,7 @@ async def import_book( Checks for duplicate ISBNs, downloads cover images, and syncs tags. """ c = body.candidate + assert current_user.id is not None # Reject duplicates by ISBN when an ISBN is present if c.isbn: @@ -144,15 +145,16 @@ async def import_book( book = Book( title=c.title, subtitle=c.subtitle, - author=c.author, + author=c.author or "", isbn=c.isbn, cover_url=cover_url, publisher=c.publisher, published_year=c.published_year, - page_count=c.page_count, + page_count=c.page_count or 0, language=_normalize_language(c.language), blurb=c.blurb, reading_status=body.reading_status, + acquisition_status=body.acquisition_status, user_id=current_user.id, ) session.add(book) diff --git a/backend/app/routers/oidc.py b/backend/app/routers/oidc.py index fde68a4c..0e4fc0c9 100644 --- a/backend/app/routers/oidc.py +++ b/backend/app/routers/oidc.py @@ -142,6 +142,7 @@ async def oidc_callback( if not user: logger.error("OIDC link points to missing user: link_id=%s user_id=%s", link.id, link.user_id) return _frontend_warning_redirect("Linked user account no longer exists") + assert user.id is not None start_browser_session(request, user.id, user.credentials_version) return _frontend_success_redirect() diff --git a/backend/app/routers/profile.py b/backend/app/routers/profile.py index fa6c169b..eddff7e4 100644 --- a/backend/app/routers/profile.py +++ b/backend/app/routers/profile.py @@ -3,7 +3,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Request -from sqlmodel import Session, select +from sqlmodel import Session, col, select from app.auth import ( clear_browser_session, @@ -89,6 +89,7 @@ def get_settings( session: Session = Depends(get_session), ) -> UserSettingsRead: """Return the current user's settings.""" + assert current_user.id is not None settings = session.exec( select(UserSettings).where(UserSettings.user_id == current_user.id) ).first() @@ -113,6 +114,7 @@ def update_settings( session: Session = Depends(get_session), ) -> UserSettingsRead: """Update the current user's settings.""" + assert current_user.id is not None settings = session.exec( select(UserSettings).where(UserSettings.user_id == current_user.id) ).first() @@ -146,6 +148,7 @@ def reset_data( Requires exact confirmation phrase. """ _validate_confirmation(body.confirmation, RESET_DATA_PHRASE) + assert current_user.id is not None try: deleted = delete_user_reading_data(session, current_user.id, app_settings.covers_dir) @@ -183,6 +186,7 @@ def delete_own_account( """ _validate_confirmation(body.confirmation, DELETE_ACCOUNT_PHRASE) assert_not_last_admin(session, current_user) + assert current_user.id is not None try: delete_user_account_data(session, current_user, app_settings.covers_dir) @@ -204,8 +208,8 @@ def list_api_keys( keys = session.exec( select(ApiKey).where( ApiKey.user_id == current_user.id, - ApiKey.revoked_at.is_(None), - ).order_by(ApiKey.created_at.desc()) + col(ApiKey.revoked_at).is_(None), + ).order_by(col(ApiKey.created_at).desc()) ).all() return [ApiKeyRead.model_validate(k) for k in keys] @@ -217,6 +221,7 @@ def create_api_key( session: Session = Depends(get_session), ) -> ApiKeyCreateResponse: """Create a new API key for the current user.""" + assert current_user.id is not None plain_key = generate_api_key() key = ApiKey( user_id=current_user.id, @@ -255,8 +260,8 @@ def list_embed_tokens( tokens = session.exec( select(EmbedToken).where( EmbedToken.user_id == current_user.id, - EmbedToken.revoked_at.is_(None), - ).order_by(EmbedToken.created_at.desc()) + col(EmbedToken.revoked_at).is_(None), + ).order_by(col(EmbedToken.created_at).desc()) ).all() return [EmbedTokenRead.model_validate(t) for t in tokens] @@ -268,6 +273,7 @@ def create_embed_token( session: Session = Depends(get_session), ) -> EmbedTokenCreateResponse: """Create a new embed token for the current user.""" + assert current_user.id is not None plain_token = generate_embed_token() token = EmbedToken( user_id=current_user.id, diff --git a/backend/app/routers/progress.py b/backend/app/routers/progress.py index 85b73698..f0839574 100644 --- a/backend/app/routers/progress.py +++ b/backend/app/routers/progress.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func -from sqlmodel import Session, select +from sqlmodel import Session, col, select from app.auth import require_user from app.database import get_session @@ -28,6 +28,7 @@ def create_progress_entry( The page must not exceed the book's page_count (if set). """ + assert current_user.id is not None book = session.get(Book, book_id) if not book or book.user_id != current_user.id: raise HTTPException(status_code=404, detail="Book not found") @@ -46,6 +47,7 @@ def create_progress_entry( session.add(entry) session.commit() session.refresh(entry) + assert entry.id is not None logger.debug("Created progress entry: book_id=%s page=%s", book_id, data.page) return ReadingProgressRead( id=entry.id, @@ -73,11 +75,11 @@ def list_progress_entries( ReadingProgress.book_id == book_id, ReadingProgress.user_id == current_user.id, ) - .order_by(ReadingProgress.created_at.desc()) + .order_by(col(ReadingProgress.created_at).desc()) ).all() return [ ReadingProgressRead( - id=r.id, + id=r.id, # ty: ignore[invalid-argument-type] book_id=r.book_id, page=r.page, created_at=r.created_at, @@ -106,7 +108,7 @@ def update_progress_entry( session.refresh(entry) logger.debug("Updated progress entry date: entry_id=%s", entry_id) return ReadingProgressRead( - id=entry.id, + id=entry.id, # ty: ignore[invalid-argument-type] book_id=entry.book_id, page=entry.page, created_at=entry.created_at, @@ -147,11 +149,11 @@ def get_latest_progress_batch( ReadingProgress.book_id, ReadingProgress.page, func.row_number() - .over(partition_by=ReadingProgress.book_id, order_by=ReadingProgress.created_at.desc()) + .over(partition_by=col(ReadingProgress.book_id), order_by=col(ReadingProgress.created_at).desc()) .label("rn"), ) .where( - ReadingProgress.book_id.in_(ids), + col(ReadingProgress.book_id).in_(ids), ReadingProgress.user_id == current_user.id, ) .subquery() diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py index ec5028b4..c14d2db4 100644 --- a/backend/app/routers/statistics.py +++ b/backend/app/routers/statistics.py @@ -1,7 +1,7 @@ """Statistics dashboard — full stats, pages-per-day breakdown, and book-level fallback.""" import calendar -from collections import Counter +from collections import Counter, defaultdict from datetime import datetime, timedelta, timezone from statistics import mean from types import SimpleNamespace @@ -10,12 +10,13 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func -from sqlmodel import Session, select +from sqlmodel import Session, col, select from app.auth import require_user from app.database import get_session -from app.models import Book, ReadingProgress, ReadingStatus, User, UserSettings +from app.models import AcquisitionStatus, Book, ReadingProgress, ReadingStatus, User, UserSettings from app.schemas import ( + AcquisitionStatusDistribution, DailyPages, DailyPagesResponse, LanguageDistribution, @@ -104,14 +105,14 @@ def _naive_utc(dt: datetime) -> datetime: def _extract_progress_daily_pages( entries: list, tz: ZoneInfo, window_start: datetime | None = None, window_end: datetime | None = None, -) -> Counter[str]: +) -> dict[str, float]: """Distribute reading progress page-deltas across calendar days. When *window_start*/*window_end* are provided, only days within that window are emitted. The daily average is still computed from the full span so the values stay correct. """ - daily: Counter[str] = Counter() + daily: dict[str, float] = defaultdict(float) grouped: dict[int, list] = {} for entry in entries: grouped.setdefault(entry.book_id, []).append(entry) @@ -126,7 +127,7 @@ def _extract_progress_daily_pages( if day_diff > 0: daily_avg = delta / day_diff start, end = _clamp_window(prev.created_at, curr.created_at, window_start, window_end) - if start is None: + if start is None or end is None: continue while start <= end: date_key = start.astimezone(tz).strftime("%Y-%m-%d") @@ -139,14 +140,14 @@ def _extract_progress_daily_pages( def _extract_book_level_daily_pages( books: list[Book], tz: ZoneInfo, window_start: datetime | None = None, window_end: datetime | None = None, -) -> Counter[str]: +) -> dict[str, float]: """Distribute page counts across the reading period for books finished without progress entries. When *window_start*/*window_end* are provided, only days within that window are emitted. The daily average is still computed from the full span so the values stay correct. """ - daily: Counter[str] = Counter() + daily: dict[str, float] = defaultdict(float) for book in books: if not (book.date_started and book.date_finished and book.page_count): continue @@ -157,7 +158,7 @@ def _extract_book_level_daily_pages( continue daily_avg = book.page_count / total_days start, end = _clamp_window(book.date_started, book.date_finished, window_start, window_end) - if start is None: + if start is None or end is None: continue while start <= end: date_key = start.astimezone(tz).strftime("%Y-%m-%d") @@ -168,9 +169,9 @@ def _extract_book_level_daily_pages( def _allocate_daily_avg_across_months( daily_avg: float, start: datetime, end: datetime, tz: ZoneInfo -) -> Counter[str]: +) -> dict[str, float]: """Spread a per-day value proportionally across months from *start* to *end* inclusive.""" - monthly: Counter[str] = Counter() + monthly: dict[str, float] = defaultdict(float) current = start while current <= end: _, last_dom = calendar.monthrange(current.year, current.month) @@ -182,9 +183,9 @@ def _allocate_daily_avg_across_months( return monthly -def _compute_pages_per_month_from_progress(entries: list, tz: ZoneInfo) -> Counter[str]: +def _compute_pages_per_month_from_progress(entries: list, tz: ZoneInfo) -> dict[str, float]: """Compute pages read per month from reading progress entries.""" - monthly: Counter[str] = Counter() + monthly: dict[str, float] = defaultdict(float) grouped: dict[int, list] = {} for entry in entries: grouped.setdefault(entry.book_id, []).append(entry) @@ -203,9 +204,9 @@ def _compute_pages_per_month_from_progress(entries: list, tz: ZoneInfo) -> Count return monthly -def _compute_pages_per_month_from_books(books: list[Book], tz: ZoneInfo) -> Counter[str]: +def _compute_pages_per_month_from_books(books: list[Book], tz: ZoneInfo) -> dict[str, float]: """Compute pages read per month for finished books without progress entries.""" - monthly: Counter[str] = Counter() + monthly: dict[str, float] = defaultdict(float) for book in books: if not (book.date_started and book.date_finished and book.page_count): continue @@ -229,10 +230,8 @@ def get_pages_per_day( session: Session = Depends(get_session), ) -> DailyPagesResponse: """Return a daily page-count breakdown for the last N days. - - Combines reading progress entries with book-level fallback for finished - books that have no fine-grained progress entries. """ + assert current_user.id is not None tz = _user_timezone(session, current_user.id) end_date = datetime.now(tz) start_date = end_date - timedelta(days=days) @@ -261,9 +260,9 @@ def get_pages_per_day( select(ReadingProgress) .where( ReadingProgress.user_id == current_user.id, - ReadingProgress.book_id.in_(book_ids_with_window_progress), + col(ReadingProgress.book_id).in_(book_ids_with_window_progress), ) - .order_by(ReadingProgress.book_id, ReadingProgress.created_at) + .order_by(col(ReadingProgress.book_id), col(ReadingProgress.created_at)) ).all() ) else: @@ -315,7 +314,7 @@ def get_pages_per_day( ] fallback_daily = _extract_book_level_daily_pages(fallback_books, tz, start_date_utc, end_date_utc) - combined: Counter[str] = Counter() + combined: dict[str, float] = defaultdict(float) for k, v in progress_daily.items(): combined[k] += v for k, v in fallback_daily.items(): @@ -343,6 +342,7 @@ def get_statistics( session: Session = Depends(get_session), ) -> StatisticsResponse: """Return the full statistics dashboard for the authenticated user.""" + assert current_user.id is not None tz = _user_timezone(session, current_user.id) now = datetime.now(tz) current_month_key = f"{now.year:04d}-{now.month:02d}" @@ -357,6 +357,14 @@ def get_statistics( did_not_finish=status_counts.get(ReadingStatus.did_not_finish, 0), ) + acquisition_counts = Counter(book.acquisition_status for book in books) + acquisition_status_distribution = AcquisitionStatusDistribution( + owned=acquisition_counts.get(AcquisitionStatus.owned, 0), + borrowed=acquisition_counts.get(AcquisitionStatus.borrowed, 0), + digital_access=acquisition_counts.get(AcquisitionStatus.digital_access, 0), + to_acquire=acquisition_counts.get(AcquisitionStatus.to_acquire, 0), + ) + page_values = [book.page_count for book in books if book.page_count is not None] avg_page_count = round(mean(page_values), 2) if page_values else None @@ -391,9 +399,9 @@ def get_statistics( select(ReadingProgress.book_id, func.max(ReadingProgress.page)) .where( ReadingProgress.user_id == current_user.id, - ReadingProgress.book_id.in_(dnf_book_ids), + col(ReadingProgress.book_id).in_(dnf_book_ids), ) - .group_by(ReadingProgress.book_id) + .group_by(col(ReadingProgress.book_id)) ).all() pages_wasted = int(sum((max_page or 0) for _, max_page in wasted_rows)) @@ -411,6 +419,7 @@ def get_statistics( finished_books_per_month: Counter[str] = Counter() for book in finished_books: + assert book.date_finished is not None month = _month_key(book.date_finished, tz) finished_books_per_month[month] += 1 @@ -418,7 +427,7 @@ def get_statistics( session.exec( select(ReadingProgress) .where(ReadingProgress.user_id == current_user.id) - .order_by(ReadingProgress.book_id, ReadingProgress.created_at) + .order_by(col(ReadingProgress.book_id), col(ReadingProgress.created_at)) ).all() ) @@ -519,9 +528,9 @@ def get_statistics( .where( Book.user_id == current_user.id, Book.author == author_name, - Book.cover_url.is_not(None), + col(Book.cover_url).is_not(None), ) - .order_by(Book.id) + .order_by(col(Book.id)) .limit(max_slots) ).all() results = [ @@ -536,9 +545,9 @@ def get_statistics( .where( Book.user_id == current_user.id, Book.author == author_name, - Book.cover_url.is_(None), + col(Book.cover_url).is_(None), ) - .order_by(Book.id) + .order_by(col(Book.id)) .limit(remaining) ).all() results.extend( @@ -565,15 +574,25 @@ def get_statistics( rated_books = [b for b in books if b.rating is not None] - top_rated_books = [ - TopRatedBook(book_id=b.id, title=b.title or "", author=b.author, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) - for b in sorted(rated_books, key=lambda x: (-x.rating, -(x.date_added or datetime.min).timestamp())) - ] + def _rating_sort_key(book: Book) -> tuple[int, float]: + assert book.rating is not None + return (book.rating, -(book.date_added or datetime.min).timestamp()) - worst_rated_books = [ - TopRatedBook(book_id=b.id, title=b.title or "", author=b.author, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) - for b in sorted(rated_books, key=lambda x: (x.rating, -(x.date_added or datetime.min).timestamp())) - ] + top_rated_books = [] + for b in sorted(rated_books, key=_rating_sort_key): + assert b.id is not None + assert b.rating is not None + top_rated_books.append( + TopRatedBook(book_id=b.id, title=b.title or "", author=b.author, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) + ) + + worst_rated_books = [] + for b in sorted(rated_books, key=lambda x: (-_rating_sort_key(x)[0], -_rating_sort_key(x)[1])): + assert b.id is not None + assert b.rating is not None + worst_rated_books.append( + TopRatedBook(book_id=b.id, title=b.title or "", author=b.author, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) + ) return StatisticsResponse( avg_books_per_month=avg_books_per_month, @@ -584,6 +603,7 @@ def get_statistics( most_popular_language_count=most_popular_language_count, language_distribution=language_distribution, status_distribution=status_distribution, + acquisition_status_distribution=acquisition_status_distribution, page_buckets=page_buckets, pages_read_per_month=pages_read_per_month, books_finished_per_month=books_finished_per_month, diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 2ae41bdd..19a28b9c 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -1,7 +1,7 @@ """Admin user management endpoints — list, create, update, delete users.""" from fastapi import APIRouter, Depends, HTTPException, status -from sqlmodel import Session, select +from sqlmodel import Session, col, select from app.auth import ( ensure_password_complexity, @@ -23,7 +23,7 @@ def list_users( session: Session = Depends(get_session), ) -> list[User]: """List all users (admin only).""" - users = session.exec(select(User).order_by(User.created_at)).all() + users = session.exec(select(User).order_by(col(User.created_at))).all() return list(users) @@ -50,6 +50,7 @@ def create_user( session.add(user) session.commit() session.refresh(user) + assert user.id is not None session.add(UserSettings(user_id=user.id, language="en")) session.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e44ec19c..4d127e69 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -5,10 +5,11 @@ from enum import Enum from typing import Literal -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, field_validator from sqlmodel import Field, SQLModel +from sqlmodel._compat import SQLModelConfig -from app.models import ReadingStatus, UserRole +from app.models import AcquisitionStatus, ReadingStatus, UserRole class ReadingProgressCreate(SQLModel): @@ -52,6 +53,7 @@ class BookCreate(SQLModel): blurb: Optional[str] = None rating: Optional[int] = Field(default=None, ge=1, le=5) reading_status: ReadingStatus = ReadingStatus.want_to_read + acquisition_status: AcquisitionStatus = AcquisitionStatus.owned date_started: Optional[datetime] = None date_finished: Optional[datetime] = None @@ -72,6 +74,7 @@ class BookUpdate(SQLModel): blurb: Optional[str] = None rating: Optional[int] = Field(default=None, ge=1, le=5) reading_status: Optional[ReadingStatus] = None + acquisition_status: Optional[AcquisitionStatus] = None date_started: Optional[datetime] = None date_finished: Optional[datetime] = None @@ -118,6 +121,7 @@ class BookImportRequest(SQLModel): """Persists a BookImportCandidate into the local DB.""" candidate: BookImportCandidate reading_status: ReadingStatus = ReadingStatus.want_to_read + acquisition_status: AcquisitionStatus = AcquisitionStatus.owned class BookRead(SQLModel): @@ -137,6 +141,7 @@ class BookRead(SQLModel): blurb: Optional[str] rating: Optional[int] reading_status: ReadingStatus + acquisition_status: AcquisitionStatus date_added: datetime date_started: Optional[datetime] date_finished: Optional[datetime] @@ -188,6 +193,14 @@ class StatusDistribution(SQLModel): did_not_finish: int +class AcquisitionStatusDistribution(SQLModel): + """Count of books per acquisition status.""" + owned: int + borrowed: int + digital_access: int + to_acquire: int + + class PageBuckets(SQLModel): """Page count buckets for the statistics dashboard.""" pages_to_read: int @@ -248,6 +261,7 @@ class StatisticsResponse(SQLModel): most_popular_language_count: Optional[int] language_distribution: list[LanguageDistribution] status_distribution: StatusDistribution + acquisition_status_distribution: AcquisitionStatusDistribution page_buckets: PageBuckets pages_read_per_month: list[MonthlyPages] books_finished_per_month: list[MonthlyBooks] @@ -315,7 +329,7 @@ class UserUpdate(SQLModel): class ProfileUpdate(SQLModel): """Profile update request (non-admin).""" - model_config = ConfigDict(extra="forbid") + model_config = SQLModelConfig(extra="forbid") firstname: Optional[str] = None lastname: Optional[str] = None diff --git a/backend/app/services/book_import.py b/backend/app/services/book_import.py index 38e4fbee..d1c34d16 100644 --- a/backend/app/services/book_import.py +++ b/backend/app/services/book_import.py @@ -272,7 +272,7 @@ async def search( results_list = await asyncio.gather(*tasks, return_exceptions=True) - ol_results = results_list[0] if not isinstance(results_list[0], Exception) else [] + ol_results = results_list[0] if not isinstance(results_list[0], BaseException) else [] if isinstance(results_list[0], SourceBackendError): logger.warning("Open Library backend error for %r: status=%s", query, results_list[0].status_code) elif isinstance(results_list[0], Exception): @@ -280,7 +280,7 @@ async def search( hc_results: list[BookImportCandidate] = [] if len(results_list) > 1: - if not isinstance(results_list[1], Exception): + if not isinstance(results_list[1], BaseException): hc_results = results_list[1] else: logger.warning("Hardcover error for %r: %s", query, results_list[1]) diff --git a/backend/app/services/data_export.py b/backend/app/services/data_export.py index 2cd309be..c6f047f2 100644 --- a/backend/app/services/data_export.py +++ b/backend/app/services/data_export.py @@ -5,10 +5,10 @@ import json from datetime import datetime, timezone from pathlib import Path -from typing import Optional +from typing import Optional, Sequence from zipfile import ZIP_DEFLATED, ZipFile -from sqlmodel import Session, select +from sqlmodel import Session, col, select from app._build_info import __git_sha__, __version__ from app.models import Book, BookTag, ReadingProgress, Tag, User @@ -30,6 +30,7 @@ "blurb", "rating", "reading_status", + "acquisition_status", "date_added", "date_started", "date_finished", @@ -63,6 +64,7 @@ def _book_to_dict(session: Session, book: Book) -> dict: "blurb": book.blurb, "rating": book.rating, "reading_status": book.reading_status.value, + "acquisition_status": book.acquisition_status.value, "date_added": _serialize_datetime(book.date_added), "date_started": _serialize_datetime(book.date_started), "date_finished": _serialize_datetime(book.date_finished), @@ -104,7 +106,7 @@ def _dump_csv(rows: list[dict], fields: list[str]) -> str: def build_export_zip( session: Session, user: User, - datasets: list[str], + datasets: Sequence[str], export_format: str, covers_dir: str, ) -> tuple[bytes, str]: @@ -128,7 +130,7 @@ def build_export_zip( progress_entries = list( session.exec( select(ReadingProgress) - .join(Book, Book.id == ReadingProgress.book_id) + .join(Book, col(Book.id) == col(ReadingProgress.book_id)) .where(ReadingProgress.user_id == user.id, Book.user_id == user.id) ).all() ) @@ -136,8 +138,8 @@ def build_export_zip( tag_counts_rows = list( session.exec( select(BookTag.tag_id, BookTag.book_id) - .join(Tag, Tag.id == BookTag.tag_id) - .join(Book, Book.id == BookTag.book_id) + .join(Tag, col(Tag.id) == col(BookTag.tag_id)) + .join(Book, col(Book.id) == col(BookTag.book_id)) .where(Tag.user_id == user.id, Book.user_id == user.id) ).all() ) diff --git a/backend/app/services/data_import.py b/backend/app/services/data_import.py index 6605a245..1b353cd1 100644 --- a/backend/app/services/data_import.py +++ b/backend/app/services/data_import.py @@ -12,10 +12,10 @@ import httpx from sqlalchemy.exc import IntegrityError -from sqlmodel import Session, select +from sqlmodel import Session, col, select from app.config import settings -from app.models import Book, ReadingProgress, ReadingStatus, User +from app.models import AcquisitionStatus, Book, ReadingProgress, ReadingStatus, User from app.schemas import ImportFieldConfig logger = logging.getLogger(__name__) @@ -38,6 +38,7 @@ "blurb", "rating", "reading_status", + "acquisition_status", "date_started", "date_finished", "cover_url", @@ -74,6 +75,10 @@ "my rating": "rating", "status": "reading_status", "reading status": "reading_status", + "acquisition status": "acquisition_status", + "acquisition": "acquisition_status", + "availability": "acquisition_status", + "ownership": "acquisition_status", "date started": "date_started", "started": "date_started", "date finished": "date_finished", @@ -278,6 +283,18 @@ def _parse_int(value: object, field: str) -> int | None: ) +def _parse_acquisition_status(value: object) -> AcquisitionStatus: + """Parse a required acquisition-status value from an import row.""" + if value is None or not str(value).strip(): + raise ValueError("Missing required field 'acquisition_status'") + normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_") + try: + return AcquisitionStatus(normalized) + except ValueError as exc: + choices = ", ".join(status.value for status in AcquisitionStatus) + raise ValueError(_format_value_error("acquisition_status", f"one of: {choices}", value)) from exc + + def _parse_year(value: object, field: str) -> int | None: """Parse a year value, accepting 4-digit integers and date strings.""" if value is None or value == "": @@ -411,15 +428,16 @@ def _mapped_row( def _validate_mapping( - mapping: dict[str, ImportFieldConfig], source_fields: set[str] + mapping: dict[str, ImportFieldConfig], source_fields: set[str], require_acquisition_status: bool = False ) -> tuple[list[str], list[str]]: """Validate an import mapping, returning (warnings, errors).""" warnings: list[str] = [] errors: list[str] = [] mapped_targets = [target for target in mapping.keys() if target] - if "title" not in mapped_targets: - errors.append("Mapping missing required field: title") + for field in (["title", "acquisition_status"] if require_acquisition_status else ["title"]): + if field not in mapped_targets: + errors.append(f"Mapping missing required field: {field}") invalid_targets = sorted({target for target in mapped_targets if target not in BOOK_IMPORT_FIELDS}) for target in invalid_targets: @@ -448,6 +466,7 @@ def validate_import( mapping: dict[str, ImportFieldConfig], session: Session, create_progress_for_read: bool = False, + require_acquisition_status: bool = False, ) -> dict: """Validate a parsed import file against the DB schema and existing data. @@ -461,11 +480,12 @@ def validate_import( Returns: A dict with keys: valid, row_count, warnings, errors. """ + assert user.id is not None parsed = load_parsed_upload(file_id, user.id) rows = parsed.get("rows", []) source_fields = set(parsed.get("source_fields", [])) - warnings, errors = _validate_mapping(mapping, source_fields) + warnings, errors = _validate_mapping(mapping, source_fields, require_acquisition_status) if errors: return {"valid": False, "row_count": len(rows), "warnings": warnings, "errors": errors} @@ -495,6 +515,8 @@ def validate_import( _parse_year(row_data.get("published_year"), "published_year") _parse_int(row_data.get("page_count"), "page_count") reading_status = _parse_reading_status(row_data.get("reading_status")) + if require_acquisition_status: + _parse_acquisition_status(row_data.get("acquisition_status")) _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) ) @@ -541,7 +563,7 @@ def validate_import( existing_isbns: set[str] = set() if isbns_in_file: results = session.exec( - select(Book.isbn).where(Book.user_id == user.id, Book.isbn.in_(isbns_in_file)) + select(Book.isbn).where(Book.user_id == user.id, col(Book.isbn).in_(isbns_in_file)) ).all() existing_isbns = set(results) @@ -564,16 +586,18 @@ def preview_import( user: User, mapping: dict[str, ImportFieldConfig], limit: int = 5, + require_acquisition_status: bool = False, ) -> dict: """Preview how a mapping and transforms will affect the first *limit* rows. Returns a dict with keys: preview_rows, row_count, errors. """ + assert user.id is not None parsed = load_parsed_upload(file_id, user.id) rows = parsed.get("rows", []) source_fields = set(parsed.get("source_fields", [])) - _warnings, mapping_errors = _validate_mapping(mapping, source_fields) + _warnings, mapping_errors = _validate_mapping(mapping, source_fields, require_acquisition_status) if mapping_errors: return {"preview_rows": [], "row_count": len(rows), "errors": mapping_errors} @@ -590,6 +614,7 @@ def preview_import( if not title: row_errors.append("Missing required field 'title'") + reading_status: ReadingStatus | None = None try: rating = _parse_int(row_data.get("rating"), "rating") if rating is not None and (rating < 1 or rating > 5): @@ -597,6 +622,8 @@ def preview_import( _parse_year(row_data.get("published_year"), "published_year") _parse_int(row_data.get("page_count"), "page_count") reading_status = _parse_reading_status(row_data.get("reading_status")) + if require_acquisition_status: + _parse_acquisition_status(row_data.get("acquisition_status")) _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) ) @@ -649,6 +676,7 @@ async def execute_import( session: Session, import_mode: str, create_progress_for_read: bool = False, + require_acquisition_status: bool = False, ): """Execute an import, yielding progress and result events. @@ -663,6 +691,7 @@ async def execute_import( Yields: Dicts with event type and data. """ + assert user.id is not None parsed = load_parsed_upload(file_id, user.id) rows: list[dict] = parsed.get("rows", []) total = len(rows) @@ -675,7 +704,7 @@ async def execute_import( rollback_all = import_mode == "rollback_all" source_fields = set(parsed.get("source_fields", [])) - _warnings, mapping_errors = _validate_mapping(mapping, source_fields) + _warnings, mapping_errors = _validate_mapping(mapping, source_fields, require_acquisition_status) if mapping_errors: yield {"event": "error", "message": "; ".join(mapping_errors)} return @@ -699,6 +728,11 @@ async def execute_import( rating = None reading_status = _parse_reading_status(row_data.get("reading_status")) + acquisition_status = ( + _parse_acquisition_status(row_data.get("acquisition_status")) + if require_acquisition_status + else AcquisitionStatus.owned + ) language = _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) @@ -738,23 +772,25 @@ async def execute_import( book = Book( title=title, subtitle=None if row_data.get("subtitle") in (None, "") else str(row_data.get("subtitle")), - author=None if row_data.get("author") in (None, "") else str(row_data.get("author")), + author=None if row_data.get("author") in (None, "") else str(row_data.get("author")), # ty: ignore[invalid-argument-type] isbn=None if row_data.get("isbn") in (None, "") else str(row_data.get("isbn")), cover_url=cover_url, publisher=None if row_data.get("publisher") in (None, "") else str(row_data.get("publisher")), published_year=_parse_year(row_data.get("published_year"), "published_year"), - page_count=page_count, + page_count=page_count, # ty: ignore[invalid-argument-type] language=language, notes=None if row_data.get("notes") in (None, "") else str(row_data.get("notes")), blurb=None if row_data.get("blurb") in (None, "") else str(row_data.get("blurb")), rating=rating, reading_status=reading_status, + acquisition_status=acquisition_status, date_started=date_started, date_finished=date_finished, user_id=user.id, ) session.add(book) session.flush() + assert book.id is not None if create_progress_for_read and reading_status == ReadingStatus.read and page_count is not None and date_finished is not None: log_date = date_finished diff --git a/backend/app/services/tags.py b/backend/app/services/tags.py index ee936025..68c60a9c 100644 --- a/backend/app/services/tags.py +++ b/backend/app/services/tags.py @@ -2,7 +2,7 @@ from typing import Optional -from sqlmodel import Session, select +from sqlmodel import Session, col, select from app.models import Book, BookTag, Tag from app.schemas import BookRead @@ -57,7 +57,7 @@ def sync_book_tags(session: Session, user_id: int, book_id: int, raw_tags: str | return existing_tags = list( - session.exec(select(Tag).where(Tag.user_id == user_id, Tag.name.in_(parsed))).all() + session.exec(select(Tag).where(Tag.user_id == user_id, col(Tag.name).in_(parsed))).all() ) name_to_tag = {tag.name: tag for tag in existing_tags} @@ -69,7 +69,11 @@ def sync_book_tags(session: Session, user_id: int, book_id: int, raw_tags: str | session.flush() name_to_tag[name] = tag - target_tag_ids = {name_to_tag[name].id for name in parsed if name_to_tag[name].id is not None} + target_tag_ids: set[int] = set() + for name in parsed: + tag_id = name_to_tag[name].id + if tag_id is not None: + target_tag_ids.add(tag_id) for tag_id in target_tag_ids - existing_tag_ids: session.add(BookTag(book_id=book_id, tag_id=tag_id)) @@ -93,9 +97,9 @@ def tags_text_for_book(session: Session, book_id: int) -> str | None: names = list( session.exec( select(Tag.name) - .join(BookTag, BookTag.tag_id == Tag.id) + .join(BookTag, col(BookTag.tag_id) == col(Tag.id)) .where(BookTag.book_id == book_id) - .order_by(Tag.name.asc()) + .order_by(col(Tag.name).asc()) ).all() ) if not names: @@ -112,9 +116,9 @@ def load_tags_batch(session: Session, book_ids: list[int]) -> dict[int, str | No return {} rows = session.exec( select(BookTag.book_id, Tag.name) - .join(Tag, Tag.id == BookTag.tag_id) - .where(BookTag.book_id.in_(book_ids)) - .order_by(BookTag.book_id, Tag.name.asc()) + .join(Tag, col(Tag.id) == col(BookTag.tag_id)) + .where(col(BookTag.book_id).in_(book_ids)) + .order_by(col(BookTag.book_id), col(Tag.name).asc()) ).all() result: dict[int, list[str]] = {} for book_id, tag_name in rows: diff --git a/backend/app/services/user_deletion.py b/backend/app/services/user_deletion.py index 4e478116..0634ac48 100644 --- a/backend/app/services/user_deletion.py +++ b/backend/app/services/user_deletion.py @@ -4,7 +4,7 @@ from typing import Optional from fastapi import HTTPException, status -from sqlmodel import Session, func, select +from sqlmodel import Session, col, func, select from app.models import ApiKey, Book, BookTag, OidcLink, ReadingProgress, Tag, User, UserRole, UserSettings from app.time_utils import utcnow @@ -59,7 +59,7 @@ def delete_user_reading_data(session: Session, user_id: int, covers_dir: str) -> if not shared: delete_cover_file(filename, covers_dir) - for link in session.exec(select(BookTag).where(BookTag.book_id.in_(book_ids))).all(): + for link in session.exec(select(BookTag).where(col(BookTag.book_id).in_(book_ids))).all(): session.delete(link) for entry in session.exec(select(ReadingProgress).where(ReadingProgress.user_id == user_id)).all(): @@ -83,6 +83,7 @@ def delete_user_account_data(session: Session, user: User, covers_dir: str) -> R Revokes API keys, unlinks OIDC, removes settings, then deletes the user. """ + assert user.id is not None deletion_counts = delete_user_reading_data(session, user.id, covers_dir) for key in session.exec(select(ApiKey).where(ApiKey.user_id == user.id)).all(): diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 6c2ae101..470e8406 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,4 +1,5 @@ #!/bin/sh set -e -uv run alembic upgrade head -exec uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 +export ALEMBIC_CONFIG=/app/backend/alembic.ini +uv run --no-project alembic upgrade head +exec uv run --no-project uvicorn app.main:app --host 0.0.0.0 --port 8000 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index cb66ec5a..56c51f9a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,25 +4,26 @@ version = "v0.0.0-dev" description = "LibrisLog book tracking API" requires-python = ">=3.14" dependencies = [ - "alembic>=1.18.4", + "alembic>=1.19.1", "authlib>=1.6.5", - "cachetools>=5.3.3", - "cryptography>=46.0.3", - "curl-cffi>=0.15.0", - "fastapi-mail>=1.4.2", - "fastapi>=0.136.1", + "cachetools>=7.1.7", + "cryptography>=50.0.0", + "curl-cffi>=0.16.1", + "fastapi-mail>=1.6.8", + "fastapi>=0.141.1", "httpx>=0.28.1", "itsdangerous>=2.2.0", - "playwright>=1.55.0", + "playwright>=1.62.0", "passlib[bcrypt]>=1.7.4", - "pydantic-settings>=2.14.1", + "pydantic-settings>=2.15.0", "pycountry>=24.6.1", - "python-multipart>=0.0.28", - "scrapling>=0.4.8", - "sqlmodel>=0.0.38", - "uvicorn[standard]>=0.46.0", + "python-multipart>=0.0.32", + "scrapling>=0.4.14", + "sqlmodel>=0.0.39", + "uvicorn[standard]>=0.52.4", "browserforge>=1.2.4", - "restrictedpython>=8.1", + "restrictedpython>=8.5", + "pytest>=9.1.1", ] [tool.uv] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b8440aa6..ecc0c7c7 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -47,6 +47,7 @@ def client_fixture(session: Session) -> Generator[TestClient, None, None]: session.add(user) session.commit() session.refresh(user) + assert user.id is not None session.add(UserSettings(user_id=user.id, language="en")) session.add( @@ -92,6 +93,7 @@ def _create( session.add(user) session.commit() session.refresh(user) + assert user.id is not None session.add(UserSettings(user_id=user.id, language="en")) session.add( diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index fbed0eb4..6ef2d0d4 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -55,6 +55,7 @@ def admin_client_with_file_db(tmp_path: Path, monkeypatch: MonkeyPatch) -> Gener session.add(user) session.commit() session.refresh(user) + assert user.id is not None session.add(UserSettings(user_id=user.id, language="en")) @@ -207,7 +208,7 @@ def test_admin_restore_success(admin_client_with_file_db: tuple[TestClient, str] # 2. Modify the database (add a new book) conn = sqlite3.connect(db_path) - conn.execute("INSERT INTO book (title, author, page_count, user_id, reading_status) VALUES ('New Book', '', 0, 1, 'read')") + conn.execute("INSERT INTO book (title, author, page_count, user_id, reading_status, acquisition_status) VALUES ('New Book', '', 0, 1, 'read', 'owned')") conn.commit() row = conn.execute("SELECT COUNT(*) FROM book").fetchone() assert row[0] == 2 diff --git a/backend/tests/test_auth_profile_users.py b/backend/tests/test_auth_profile_users.py index c70edda3..4fb8244e 100644 --- a/backend/tests/test_auth_profile_users.py +++ b/backend/tests/test_auth_profile_users.py @@ -3,6 +3,7 @@ import pytest from fastapi import HTTPException from fastapi.testclient import TestClient +from pytest import MonkeyPatch from sqlmodel import Session, select from app.auth import ( @@ -505,7 +506,9 @@ def test_users_delete_user_not_found(client: TestClient) -> None: assert resp.json()["detail"] == "User not found" -def test_oidc_config_disabled_by_default(client: TestClient) -> None: +def test_oidc_config_disabled_by_default(client: TestClient, monkeypatch: MonkeyPatch) -> None: + from app import config + monkeypatch.setattr(config.settings, "oidc_enabled", False) resp = client.get("/api/oidc/config") assert resp.status_code == 200 assert resp.json()["enabled"] is False @@ -551,6 +554,7 @@ def test_profile_delete_account_deletes_regular_user_data( session: Session, ) -> None: user, key = create_user_with_key(email="danger@example.com", role=UserRole.user) + assert user.id is not None with TestClient(client.app) as c2: c2.headers.update({"X-API-Key": key}) @@ -575,3 +579,73 @@ def test_profile_delete_account_deletes_regular_user_data( keys = session.exec(select(ApiKey).where(ApiKey.user_id == user.id)).all() assert keys assert all(k.revoked_at is not None for k in keys) + + +def test_forgot_password_with_mail_server(client: TestClient, monkeypatch: MonkeyPatch, session: Session) -> None: + from app import config + monkeypatch.setattr(config.settings, "mail_server", "smtp.example.com") + + import app.routers.auth as auth_module + + sent: list[tuple[str, str, str]] = [] + + async def fake_send(email: str, url: str, locale: str = "en") -> None: + sent.append((email, url, locale)) + + monkeypatch.setattr(auth_module, "send_password_reset_email", fake_send) + + resp = client.post("/api/auth/forgot-password", json={"email": "test@example.com", "locale": "de"}) + assert resp.status_code == 200 + assert "reset link" in resp.json()["message"] + assert len(sent) == 1 + assert sent[0][0] == "test@example.com" + assert sent[0][2] == "de" + assert "/reset-password?token=" in sent[0][1] + + +def test_forgot_password_without_mail_server(client: TestClient, monkeypatch: MonkeyPatch) -> None: + from app import config + monkeypatch.setattr(config.settings, "mail_server", None) + + import app.routers.auth as auth_module + + called: list[object] = [] + monkeypatch.setattr(auth_module, "send_password_reset_email", lambda *args, **kwargs: called.append(args)) + + resp = client.post("/api/auth/forgot-password", json={"email": "test@example.com"}) + assert resp.status_code == 200 + assert "reset link" in resp.json()["message"] + assert not called + + +def test_reset_password_valid_token(client: TestClient) -> None: + from app.auth import generate_password_reset_token + + token = generate_password_reset_token("test@example.com", 0) + resp = client.post("/api/auth/reset-password", json={"token": token, "password": "Newpass1!"}) + assert resp.status_code == 200 + assert resp.json()["message"] == "Password has been reset successfully" + + login = client.post("/api/auth/login", json={"email": "test@example.com", "password": "Newpass1!"}) + assert login.status_code == 200 + + +def test_reset_password_invalid_token(client: TestClient) -> None: + resp = client.post("/api/auth/reset-password", json={"token": "not-a-valid-token", "password": "Newpass1!"}) + assert resp.status_code == 400 + assert resp.json()["detail"] == "Invalid or expired reset token" + + +def test_reset_password_mismatched_credentials_version(client: TestClient, session: Session) -> None: + from app.auth import generate_password_reset_token + + user = session.exec(select(User).where(User.email == "test@example.com")).first() + assert user is not None + user.credentials_version = 5 + session.add(user) + session.commit() + + token = generate_password_reset_token("test@example.com", 0) + resp = client.post("/api/auth/reset-password", json={"token": token, "password": "Newpass1!"}) + assert resp.status_code == 400 + assert resp.json()["detail"] == "Invalid or expired reset token" diff --git a/backend/tests/test_auth_unit.py b/backend/tests/test_auth_unit.py new file mode 100644 index 00000000..ab18b809 --- /dev/null +++ b/backend/tests/test_auth_unit.py @@ -0,0 +1,75 @@ +"""Unit tests for app.auth password reset tokens and session credential checks.""" + +import pytest +from fastapi import HTTPException, Request +from sqlmodel import Session + +from app.auth import ( + generate_password_reset_token, + require_user, + verify_password_reset_token, +) +from app.models import User, UserRole + + +def test_password_reset_token_round_trip() -> None: + """A freshly generated token should verify and return the payload.""" + token = generate_password_reset_token("user@example.com", credentials_version=3) + payload = verify_password_reset_token(token) + assert payload == {"email": "user@example.com", "credentials_version": 3} + + +def test_password_reset_token_expired() -> None: + """A token verified with max_age=-1 should be rejected as expired.""" + token = generate_password_reset_token("user@example.com") + assert verify_password_reset_token(token, max_age=-1) is None + + +def test_password_reset_token_tampered() -> None: + """A tampered token should fail verification.""" + token = generate_password_reset_token("user@example.com") + assert verify_password_reset_token(token + "x") is None + + +def test_password_reset_token_wrong_shape(monkeypatch) -> None: + """A token whose payload lacks required keys should be rejected.""" + from app.auth import _password_reset_serializer + + # Manually sign a payload with the wrong shape. + token = _password_reset_serializer.dumps("just-a-string") + assert verify_password_reset_token(token) is None + + +def test_require_user_session_credentials_version_mismatch(session: Session) -> None: + """A session whose credentials_version does not match the user should be cleared and rejected.""" + user = User( + firstname="A", + lastname="B", + email="session@example.com", + role=UserRole.user, + hashed_password="hashed", + credentials_version=2, + ) + session.add(user) + session.commit() + session.refresh(user) + + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [], + "session": { + "user_id": user.id, + "credentials_version": 1, + "csrf_token": "csrf", + }, + } + request = Request(scope) + + with pytest.raises(HTTPException) as exc_info: + require_user(request=request, x_api_key=None, x_csrf_token=None, session=session) + + assert exc_info.value.status_code == 401 + assert "session expired" in exc_info.value.detail.lower() + assert request.session == {} diff --git a/backend/tests/test_backup_restore.py b/backend/tests/test_backup_restore.py index 4587ec44..63c14efc 100644 --- a/backend/tests/test_backup_restore.py +++ b/backend/tests/test_backup_restore.py @@ -749,3 +749,53 @@ def _fake_getinfo(self: zipfile.ZipFile, name: str) -> zipfile.ZipInfo: covers_dir=covers_dir, import_temp_dir=import_temp_dir, ) + + +# ── _remove_wal_files ───────────────────────────────────────────────────────── + +def test_remove_wal_files_deletes_existing_files(tmp_path: Path) -> None: + db_path = str(tmp_path / "test.db") + wal_path = f"{db_path}-wal" + shm_path = f"{db_path}-shm" + Path(wal_path).write_bytes(b"wal") + Path(shm_path).write_bytes(b"shm") + br._remove_wal_files(db_path) + assert not Path(wal_path).exists() + assert not Path(shm_path).exists() + + +def test_remove_wal_files_ignores_missing_files(tmp_path: Path) -> None: + db_path = str(tmp_path / "test.db") + br._remove_wal_files(db_path) # should not raise + + +# ── _stamp_alembic_head_if_fresh ────────────────────────────────────────────── + +def test_stamp_alembic_head_if_fresh_skips_when_version_table_has_rows( + tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + db_path = str(tmp_path / "test.db") + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE alembic_version (version_num TEXT PRIMARY KEY)") + conn.execute("INSERT INTO alembic_version (version_num) VALUES ('abc123')") + conn.commit() + conn.close() + + monkeypatch.setattr(br.settings, "database_url", f"sqlite:///{db_path}") + + from alembic import command as alembic_command + from alembic.script import ScriptDirectory + + stamp_called = False + + def _fake_stamp(*args: Any, **kwargs: Any) -> None: + nonlocal stamp_called + stamp_called = True + + mock_script = MagicMock() + mock_script.get_current_head.return_value = "head123" + monkeypatch.setattr(ScriptDirectory, "from_config", lambda cfg: mock_script) + monkeypatch.setattr(alembic_command, "stamp", _fake_stamp) + + br._stamp_alembic_head_if_fresh() + assert stamp_called is False diff --git a/backend/tests/test_book_import.py b/backend/tests/test_book_import.py index 8c288ced..023fde88 100644 --- a/backend/tests/test_book_import.py +++ b/backend/tests/test_book_import.py @@ -29,7 +29,7 @@ def test_source_backend_error_without_status() -> None: def test_truncate_api_key_empty() -> None: assert bi._truncate_api_key("") == "" - assert bi._truncate_api_key(None) == "" + assert bi._truncate_api_key(None) == "" # ty: ignore[invalid-argument-type] def test_truncate_api_key_short() -> None: @@ -936,6 +936,7 @@ def test_map_hardcover_full() -> None: "contributions": [{"author": {"name": "Author"}}], } c = bi.map_hardcover(edition) + assert c is not None assert c.title == "Book" assert c.subtitle == "Subtitle" assert c.author == "Author" @@ -960,6 +961,7 @@ def test_map_hardcover_invalid_release_date() -> None: "release_date": "not-a-date", } c = bi.map_hardcover(edition) + assert c is not None assert c.published_year is None @@ -968,6 +970,7 @@ def test_map_hardcover_no_release_date() -> None: "title": "Book", } c = bi.map_hardcover(edition) + assert c is not None assert c.published_year is None @@ -978,6 +981,7 @@ def test_map_hardcover_unsafe_cover_url(monkeypatch: pytest.MonkeyPatch) -> None } monkeypatch.setattr(bi, "is_safe_cover_import_url", lambda url: False) c = bi.map_hardcover(edition) + assert c is not None assert c.cover_url is None @@ -987,6 +991,7 @@ def test_map_hardcover_no_author() -> None: "contributions": [{"author": {}}], } c = bi.map_hardcover(edition) + assert c is not None assert c.author is None diff --git a/backend/tests/test_books.py b/backend/tests/test_books.py index 06c1f478..436599b6 100644 --- a/backend/tests/test_books.py +++ b/backend/tests/test_books.py @@ -7,7 +7,7 @@ from fastapi.testclient import TestClient from pytest import MonkeyPatch from sqlalchemy.exc import IntegrityError as SQLAIntegrityError -from sqlmodel import Session +from sqlmodel import Session, col from app.config import settings from app.models import Book, User @@ -48,6 +48,7 @@ def test_create_book_with_all_fields(client: TestClient) -> None: "notes": "A classic", "rating": 5, "reading_status": "read", + "acquisition_status": "borrowed", "date_started": "2024-01-01", "date_finished": "2024-01-15", } @@ -59,6 +60,15 @@ def test_create_book_with_all_fields(client: TestClient) -> None: assert data["language"] == "EN" assert data["rating"] == 5 assert data["reading_status"] == "read" + assert data["acquisition_status"] == "borrowed" + + +def test_create_book_invalid_acquisition_status_returns_422(client: TestClient) -> None: + resp = client.post( + "/api/books", + json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "acquisition_status": "unknown"}, + ) + assert resp.status_code == 422 def test_create_book_missing_title_returns_422(client: TestClient) -> None: @@ -102,6 +112,16 @@ def test_list_books_filter_by_status(client: TestClient) -> None: assert body["books"][0]["title"] == "Reading" +def test_list_books_filter_by_acquisition_status(client: TestClient) -> None: + _create_book(client, title="Owned", acquisition_status="owned") + _create_book(client, title="To Acquire", acquisition_status="to_acquire") + + resp = client.get("/api/books?acquisition_status=to_acquire") + + assert resp.status_code == 200 + assert [book["title"] for book in resp.json()["books"]] == ["To Acquire"] + + def test_list_books_search_by_title(client: TestClient) -> None: _create_book(client, title="Dune") _create_book(client, title="Foundation") @@ -265,7 +285,7 @@ def test_list_books_filter_has_cover_excludes_empty_string(client: TestClient, s # Bypass the model validator by setting cover_url to "" via raw SQL from sqlalchemy import update as sa_update - session.exec(sa_update(Book).where(Book.id == book["id"]).values(cover_url="")) + session.exec(sa_update(Book).where(col(Book.id) == book["id"]).values(cover_url="")) session.commit() _create_book(client, title="Real Cover", cover_url="http://example.com/real.jpg") diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 4825f39a..41b3ac86 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -1,8 +1,9 @@ -"""Tests for app configuration validation.""" +"""Tests for app configuration validation and config endpoint.""" from typing import Any import pytest +from fastapi.testclient import TestClient @pytest.mark.parametrize( @@ -19,4 +20,20 @@ def test_api_key_encryption_key_validation(invalid_settings_kwargs: tuple[dict[s from app.config import Settings with pytest.raises(ValueError, match=expected_error): - Settings(**kwargs) + Settings(**kwargs) # ty: ignore[invalid-argument-type] + + +def test_get_config_returns_feature_flags(client: TestClient, monkeypatch) -> None: + """GET /api/config should return current feature flag values.""" + monkeypatch.setattr("app.config.settings.embed_enabled", True) + monkeypatch.setattr("app.config.settings.dashboard_quote_enabled", False) + monkeypatch.setattr("app.config.settings.thalia_cover_search_enabled", True) + + resp = client.get("/api/config") + assert resp.status_code == 200 + data = resp.json() + assert data == { + "embed_enabled": True, + "dashboard_quote_enabled": False, + "thalia_cover_search_enabled": True, + } diff --git a/backend/tests/test_cover_candidates.py b/backend/tests/test_cover_candidates.py index 11d8e6c1..17e98be7 100644 --- a/backend/tests/test_cover_candidates.py +++ b/backend/tests/test_cover_candidates.py @@ -14,6 +14,7 @@ def test_cover_candidates_search_requires_valid_isbn(client: TestClient) -> None def test_cover_candidates_search_returns_candidates(client: TestClient, monkeypatch) -> None: from app import config monkeypatch.setattr(config.settings, "thalia_cover_search_enabled", False) + monkeypatch.setattr(config.settings, "hardcover_app_api_token", "") requested_urls: list[str] = [] @@ -1072,7 +1073,7 @@ def fake_fetch(*args: object, **kwargs: object) -> str: monkeypatch.setattr("app.routers.cover_candidates.is_safe_cover_import_url", lambda url: False) async def run() -> None: - candidate = await _probe_thalia_candidate("9783426440087", None, 1000, 10) + candidate = await _probe_thalia_candidate("9783426440087", None, 1000, 10) # ty: ignore[invalid-argument-type] assert candidate.available is False assert candidate.url == "" @@ -1089,6 +1090,22 @@ def test_probe_source_candidates_empty_urls() -> None: async def run() -> None: with pytest.raises(IndexError): - await _probe_source_candidates("abebooks", [], None, 1000) + await _probe_source_candidates("abebooks", [], None, 1000) # ty: ignore[invalid-argument-type] asyncio.run(run()) + + +def test_fetch_thalia_page_sync_returns_none_on_unrewritable_url(monkeypatch) -> None: + """_fetch_thalia_page_sync returns None when _rewrite_thalia_image_url fails.""" + from app.routers.cover_candidates import _fetch_thalia_page_sync + + mock_page = _make_mock_page(suchtreffer="1", src="https://images.thalia.media/03") + + class _FakeFetcher: + @classmethod + def get(cls, url: str, **kwargs: object) -> object: + return mock_page + + monkeypatch.setattr("app.routers.cover_candidates._THALIA_FETCHER_CLASS", _FakeFetcher) + result = _fetch_thalia_page_sync("9783426440087", 10) + assert result is None diff --git a/backend/tests/test_cover_storage.py b/backend/tests/test_cover_storage.py index 3e3eef25..02b66c6e 100644 --- a/backend/tests/test_cover_storage.py +++ b/backend/tests/test_cover_storage.py @@ -49,8 +49,8 @@ def raise_for_status(self) -> None: if not self.is_success: raise httpx.HTTPStatusError( "error", - request=None, # type: ignore[arg-type] - response=self, # type: ignore[arg-type] + request=None, # ty: ignore[invalid-argument-type] + response=self, # ty: ignore[invalid-argument-type] ) @@ -73,7 +73,7 @@ async def test_download_cover_success(tmp_path: Path) -> None: client = _FakeCoverClient( {_IMAGE_URL: _FakeCoverResponse(200, _IMAGE_HEADERS, _VALID_BODY)} ) - filename = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # type: ignore[arg-type] + filename = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # ty: ignore[invalid-argument-type] assert filename is not None assert filename.endswith(".jpg") @@ -90,7 +90,7 @@ async def test_download_cover_dedup(tmp_path: Path) -> None: pre_existing.write_bytes(b"cached") client = _FakeCoverClient({}) - filename = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # type: ignore[arg-type] + filename = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # ty: ignore[invalid-argument-type] assert filename == pre_existing.name @@ -101,7 +101,7 @@ async def test_download_cover_too_small(tmp_path: Path) -> None: client = _FakeCoverClient( {_IMAGE_URL: _FakeCoverResponse(200, _IMAGE_HEADERS, _SMALL_BODY)} ) - result = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # type: ignore[arg-type] + result = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # ty: ignore[invalid-argument-type] assert result is None assert list(tmp_path.iterdir()) == [] @@ -113,7 +113,7 @@ async def test_download_cover_non_image_content_type(tmp_path: Path) -> None: client = _FakeCoverClient( {_IMAGE_URL: _FakeCoverResponse(200, {"content-type": "text/html"}, _VALID_BODY)} ) - result = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # type: ignore[arg-type] + result = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # ty: ignore[invalid-argument-type] assert result is None assert list(tmp_path.iterdir()) == [] @@ -125,7 +125,7 @@ async def test_download_cover_http_error(tmp_path: Path) -> None: client = _FakeCoverClient( {_IMAGE_URL: _FakeCoverResponse(404, {}, b"")} ) - result = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # type: ignore[arg-type] + result = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # ty: ignore[invalid-argument-type] assert result is None @@ -137,7 +137,7 @@ class _ErrorClient: async def get(self, url: str, **_kwargs: Any) -> None: raise httpx.ConnectError("connection refused") - result = await download_cover(_IMAGE_URL, tmp_path, _ErrorClient(), _USER_ID) # type: ignore[arg-type] + result = await download_cover(_IMAGE_URL, tmp_path, _ErrorClient(), _USER_ID) # ty: ignore[invalid-argument-type] assert result is None @@ -148,7 +148,8 @@ async def test_download_cover_atomic_write(tmp_path: Path) -> None: client = _FakeCoverClient( {_IMAGE_URL: _FakeCoverResponse(200, _IMAGE_HEADERS, _VALID_BODY)} ) - filename = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # type: ignore[arg-type] + filename = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # ty: ignore[invalid-argument-type] + assert filename is not None tmp_files = list(tmp_path.glob("*.tmp")) assert tmp_files == [], "Stale .tmp file found after successful download" @@ -161,7 +162,7 @@ async def test_download_cover_correct_extension_jpeg(tmp_path: Path) -> None: client = _FakeCoverClient( {_IMAGE_URL: _FakeCoverResponse(200, {"content-type": "image/jpeg"}, _VALID_BODY)} ) - filename = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # type: ignore[arg-type] + filename = await download_cover(_IMAGE_URL, tmp_path, client, _USER_ID) # ty: ignore[invalid-argument-type] assert filename is not None assert filename.endswith(".jpg") @@ -174,7 +175,7 @@ async def test_download_cover_correct_extension_png(tmp_path: Path) -> None: client = _FakeCoverClient( {png_url: _FakeCoverResponse(200, {"content-type": "image/png"}, _VALID_BODY)} ) - filename = await download_cover(png_url, tmp_path, client, _USER_ID) # type: ignore[arg-type] + filename = await download_cover(png_url, tmp_path, client, _USER_ID) # ty: ignore[invalid-argument-type] assert filename is not None assert filename.endswith(".png") @@ -260,7 +261,7 @@ def test_resolve_cover_path_none() -> None: def test_delete_cover_file_invalid_filename() -> None: """Invalid filename should return False without touching filesystem.""" assert delete_cover_file("", "/tmp/covers") is False - assert delete_cover_file(None, "/tmp/covers") is False # type: ignore[arg-type] + assert delete_cover_file(None, "/tmp/covers") is False # ty: ignore[invalid-argument-type] def test_delete_cover_file_unlink_error(monkeypatch) -> None: @@ -294,7 +295,7 @@ def _raise(*args: object, **kwargs: object) -> None: monkeypatch.setattr("app.services.cover_storage.Path.mkdir", _raise) result = await download_cover( - "https://example.com/img.jpg", tmp_path, _FakeClient(), 1 + "https://example.com/img.jpg", tmp_path, _FakeClient(), 1 # ty: ignore[invalid-argument-type] ) assert result is None @@ -383,3 +384,37 @@ def test_cleanup_orphan_covers_nonexistent_dir(session: Session, monkeypatch: py monkeypatch.setattr(cover_storage.settings, "covers_dir", "/nonexistent/path") assert cleanup_orphan_covers(session) == 0 + + +def test_cleanup_orphan_covers_logs_warning_on_unlink_error( + session: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """OSError during orphan cover deletion should be logged and counted as not deleted.""" + import time + + from app.services import cover_storage + + orphan = tmp_path / "1__orphan.jpg" + orphan.write_bytes(b"orphan") + old_time = time.time() - 7200 + os.utime(orphan, (old_time, old_time)) + + monkeypatch.setattr(cover_storage.settings, "covers_dir", str(tmp_path)) + + def _raise_unlink(self: Path, missing_ok: bool = False) -> Any: + raise OSError("permission denied") + + monkeypatch.setattr(Path, "unlink", _raise_unlink) + + warned = False + + def _capture_warning(msg: str, *args: Any, **kwargs: Any) -> None: + nonlocal warned + if "Failed to delete orphaned cover" in msg: + warned = True + + monkeypatch.setattr(cover_storage.logger, "warning", _capture_warning) + + deleted = cleanup_orphan_covers(session) + assert deleted == 0 + assert warned diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index 687ebb11..209da80c 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -210,7 +210,7 @@ def test_data_import_mapping_crud(client: TestClient) -> None: def test_data_import_validate_and_execute_continue_on_error(client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path / "import_temp")) - csv_payload = "Title,Author\nDune,Frank Herbert\n,No Title\n" + csv_payload = "Title,Author,Availability\nDune,Frank Herbert,owned\n,No Title,owned\n" parse_resp = client.post( "/api/data/import/parse", files={"file": ("books.csv", csv_payload, "text/csv")}, @@ -219,14 +219,14 @@ def test_data_import_validate_and_execute_continue_on_error(client: TestClient, validate_resp = client.post( "/api/data/import/validate", - json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}}}, + json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}, "acquisition_status": {"source": "Availability", "transform": None}}}, ) assert validate_resp.status_code == 200 assert validate_resp.json()["valid"] is False preview_resp = client.post( "/api/data/import/preview", - json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}}}, + json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}, "acquisition_status": {"source": "Availability", "transform": None}}}, ) assert preview_resp.status_code == 200 preview = preview_resp.json() @@ -238,7 +238,7 @@ def test_data_import_validate_and_execute_continue_on_error(client: TestClient, "/api/data/import/execute", json={ "file_id": file_id, - "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}}, + "mapping": {"title": {"source": "Title", "transform": None}, "author": {"source": "Author", "transform": None}, "acquisition_status": {"source": "Availability", "transform": None}}, "import_mode": "continue_on_error", }, ) @@ -298,7 +298,7 @@ def test_data_import_execute_rejects_invalid_target_mapping(client: TestClient, def test_data_import_validate_rejects_invalid_reading_status_enum(client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path / "import_temp")) - csv_payload = "Title,Status\nDune,uxnread\n" + csv_payload = "Title,Status,Availability\nDune,uxnread,owned\n" parse_resp = client.post( "/api/data/import/parse", files={"file": ("books.csv", csv_payload, "text/csv")}, @@ -307,7 +307,7 @@ def test_data_import_validate_rejects_invalid_reading_status_enum(client: TestCl validate_resp = client.post( "/api/data/import/validate", - json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "reading_status": {"source": "Status", "transform": None}}}, + json={"file_id": file_id, "mapping": {"title": {"source": "Title", "transform": None}, "reading_status": {"source": "Status", "transform": None}, "acquisition_status": {"source": "Availability", "transform": None}}}, ) assert validate_resp.status_code == 200 payload = validate_resp.json() @@ -320,7 +320,7 @@ def test_data_import_execute_progress_uses_date_finished_for_read_books( client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path ) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path / "import_temp")) - csv_payload = "Title,Status,Pages,Date Finished\nDune,read,412,2024-01-15T10:30:00Z\n" + csv_payload = "Title,Status,Pages,Date Finished,Availability\nDune,read,412,2024-01-15T10:30:00Z,owned\n" parse_resp = client.post( "/api/data/import/parse", files={"file": ("books.csv", csv_payload, "text/csv")}, @@ -336,6 +336,7 @@ def test_data_import_execute_progress_uses_date_finished_for_read_books( "reading_status": {"source": "Status", "transform": None}, "page_count": {"source": "Pages", "transform": None}, "date_finished": {"source": "Date Finished", "transform": None}, + "acquisition_status": {"source": "Availability", "transform": None}, }, "import_mode": "continue_on_error", "create_progress_for_read": True, @@ -362,7 +363,7 @@ def test_data_import_execute_read_book_without_date_finished_skips_progress( client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path ) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path / "import_temp")) - csv_payload = "Title,Status,Pages\nDune,read,412\n" + csv_payload = "Title,Status,Pages,Availability\nDune,read,412,owned\n" parse_resp = client.post( "/api/data/import/parse", files={"file": ("books.csv", csv_payload, "text/csv")}, @@ -377,6 +378,7 @@ def test_data_import_execute_read_book_without_date_finished_skips_progress( "title": {"source": "Title", "transform": None}, "reading_status": {"source": "Status", "transform": None}, "page_count": {"source": "Pages", "transform": None}, + "acquisition_status": {"source": "Availability", "transform": None}, }, "import_mode": "continue_on_error", "create_progress_for_read": True, @@ -571,3 +573,40 @@ async def mock_execute(*args: object, **kwargs: object) -> AsyncGenerator[dict[s ) events = _parse_sse(resp.text) assert any(event.get("message") == "error.importExecutionFailed" for event in events) + + +def test_data_import_mapping_get_predefined(client: TestClient) -> None: + resp = client.get("/api/data/import/mappings/-1") + assert resp.status_code == 200 + data = resp.json() + assert data["is_predefined"] is True + assert data["id"] == -1 + assert data["name"] == "Goodreads Export" + + +def test_data_import_mapping_get_predefined_missing(client: TestClient) -> None: + resp = client.get("/api/data/import/mappings/-999") + assert resp.status_code == 404 + assert resp.json()["detail"] == "Predefined mapping not found." + + +def test_data_import_mapping_delete_predefined_forbidden(client: TestClient) -> None: + resp = client.delete("/api/data/import/mappings/-1") + assert resp.status_code == 403 + assert resp.json()["detail"] == "Predefined mappings cannot be deleted." + + +def test_data_import_preview_file_not_found(client: TestClient, monkeypatch: MonkeyPatch) -> None: + from app.routers import data as data_module + + def fake_preview(*args: object, **kwargs: object) -> None: + raise FileNotFoundError("Import file not found.") + + monkeypatch.setattr(data_module, "preview_import", fake_preview) + + resp = client.post( + "/api/data/import/preview", + json={"file_id": "missing", "mapping": {}}, + ) + assert resp.status_code == 404 + assert resp.json()["detail"] == "Import file not found." diff --git a/backend/tests/test_data_import.py b/backend/tests/test_data_import.py index 5694c196..c8529e1a 100644 --- a/backend/tests/test_data_import.py +++ b/backend/tests/test_data_import.py @@ -267,11 +267,26 @@ def test_validate_mapping_transform_invalid() -> None: def test_validate_mapping_transform_valid() -> None: - mapping = {"title": ImportFieldConfig(source="A", transform="value.upper()")} - warnings, errors = di._validate_mapping(mapping, {"A"}) + mapping = { + "title": ImportFieldConfig(source="A", transform="value.upper()"), + "acquisition_status": ImportFieldConfig(source="B"), + } + warnings, errors = di._validate_mapping(mapping, {"A", "B"}) assert len(errors) == 0 +def test_validate_mapping_requires_acquisition_status() -> None: + _warnings, errors = di._validate_mapping( + {"title": ImportFieldConfig(source="A")}, {"A"}, require_acquisition_status=True + ) + assert "Mapping missing required field: acquisition_status" in errors + + +def test_parse_acquisition_status_rejects_invalid_value() -> None: + with pytest.raises(ValueError, match="acquisition_status"): + di._parse_acquisition_status("wishlist") + + # ── preview_import ──────────────────────────────────────────────────────────── def test_preview_import_basic(session: Session, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: @@ -282,7 +297,7 @@ def test_preview_import_basic(session: Session, tmp_path: Path, monkeypatch: Mon "source_fields": ["title", "author"], } file_id = "test_preview" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -302,7 +317,7 @@ def test_preview_import_with_transform(session: Session, tmp_path: Path, monkeyp "source_fields": ["title", "author"], } file_id = "test_preview_transform" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -325,7 +340,7 @@ def test_preview_import_mapping_errors(session: Session, tmp_path: Path, monkeyp "source_fields": ["title"], } file_id = "test_preview_errors" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -349,6 +364,7 @@ def _create_test_user(session: Session) -> User: session.add(user) session.commit() session.refresh(user) + assert user.id is not None return user @@ -360,7 +376,7 @@ def test_validate_import_rating_out_of_range(session: Session, tmp_path: Path, m "source_fields": ["title", "rating"], } file_id = "test_rating" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -376,7 +392,7 @@ def test_validate_import_date_started_after_finished(session: Session, tmp_path: "source_fields": ["title", "started", "finished"], } file_id = "test_dates" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -392,7 +408,7 @@ def test_validate_import_progress_warning_no_pages(session: Session, tmp_path: P "source_fields": ["title", "status"], } file_id = "test_progress" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -415,7 +431,7 @@ def test_validate_import_isbn_already_exists(session: Session, tmp_path: Path, m "source_fields": ["title", "isbn"], } file_id = "test_isbn" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -432,7 +448,7 @@ def test_validate_import_no_isbns(session: Session, tmp_path: Path, monkeypatch: "source_fields": ["title"], } file_id = "test_no_isbn" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -448,7 +464,7 @@ def test_validate_import_missing_title(session: Session, tmp_path: Path, monkeyp "source_fields": ["title"], } file_id = "test_missing_title" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -464,7 +480,7 @@ def test_validate_import_value_error_caught(session: Session, tmp_path: Path, mo "source_fields": ["title", "pages"], } file_id = "test_value_error" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -480,7 +496,7 @@ def test_validate_import_cover_url_warns_on_non_url(session: Session, tmp_path: "source_fields": ["title", "cover"], } file_id = "test_cover_nonurl" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -498,7 +514,7 @@ def test_validate_import_cover_url_accepts_valid_url(session: Session, tmp_path: "source_fields": ["title", "cover"], } file_id = "test_cover_valid" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -519,7 +535,7 @@ async def test_execute_import_mapping_errors(session: Session, tmp_path: Path, m "source_fields": ["title"], } file_id = "test_exec_map" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -540,7 +556,7 @@ async def test_execute_import_rating_out_of_range_set_to_none(session: Session, "source_fields": ["title", "rating"], } file_id = "test_exec_rating" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -562,7 +578,7 @@ async def test_execute_import_date_started_after_finished(session: Session, tmp_ "source_fields": ["title", "started", "finished"], } file_id = "test_exec_dates" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -585,7 +601,7 @@ async def test_execute_import_cover_download(session: Session, tmp_path: Path, m "source_fields": ["title", "cover"], } file_id = "test_exec_cover" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -613,7 +629,7 @@ async def test_execute_import_progress_date_naive_tz_fix(session: Session, tmp_p "source_fields": ["title", "status", "pages", "finished"], } file_id = "test_exec_tz" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -639,7 +655,7 @@ async def test_execute_import_rollback_all_commit(session: Session, tmp_path: Pa "source_fields": ["title"], } file_id = "test_exec_rollback" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -661,7 +677,7 @@ async def test_execute_import_missing_title_row(session: Session, tmp_path: Path "source_fields": ["title"], } file_id = "test_exec_missing_title" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -683,7 +699,7 @@ async def test_execute_import_rollback_all_error(session: Session, tmp_path: Pat "source_fields": ["title"], } file_id = "test_exec_rollback_err" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -715,7 +731,7 @@ async def test_execute_import_progress_naive_date_finished(session: Session, tmp "source_fields": ["title", "status", "pages", "finished"], } file_id = "test_exec_naive_dt" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, default=str)) @@ -742,7 +758,7 @@ async def test_execute_import_progress_naive_utcnow_fallback(session: Session, t "source_fields": ["title", "status", "pages", "finished"], } file_id = "test_exec_naive_utc" - path = di._temp_file_path(user.id, file_id) + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload)) @@ -823,3 +839,493 @@ def _raise_unlink(self: Path, missing_ok: bool = False) -> Any: monkeypatch.setattr(Path, "unlink", _raise_unlink) # Should not raise di.cleanup_temp_files() + + +# ── _parse_acquisition_status ───────────────────────────────────────────────── + +def test_parse_acquisition_status_missing_value() -> None: + with pytest.raises(ValueError, match="Missing required field 'acquisition_status'"): + di._parse_acquisition_status(None) + with pytest.raises(ValueError, match="Missing required field 'acquisition_status'"): + di._parse_acquisition_status(" ") + + +# ── _mapped_row ─────────────────────────────────────────────────────────────── + +def test_mapped_row_transform_execution_error() -> None: + mapping = {"title": ImportFieldConfig(source="title", transform="return int(value)")} + transform_cache = di._build_transform_cache(mapping) + errors: list[str] = [] + result = di._mapped_row( + {"title": "not-a-number"}, + mapping, + transform_cache, + {}, + errors, + ) + assert "title" not in result + assert any("title" in e for e in errors) + + +# ── _validate_mapping ───────────────────────────────────────────────────────── + +def test_validate_mapping_invalid_target_with_empty_source() -> None: + mapping = { + "title": ImportFieldConfig(source="A"), + "invalid_target": ImportFieldConfig(source=""), + } + warnings, errors = di._validate_mapping(mapping, {"A"}) + assert any("Invalid mapping target: invalid_target" in e for e in errors) + + +# ── validate_import ─────────────────────────────────────────────────────────── + +def test_validate_import_invalid_mapping_returns_early( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book"}], + "source_fields": ["title"], + } + file_id = "test_validate_early" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.validate_import( + file_id, user, {"invalid_target": ImportFieldConfig(source="title")}, session + ) + assert result["valid"] is False + assert any("Invalid mapping target" in e for e in result["errors"]) + + +def test_validate_import_require_acquisition_status_invalid( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "acq": "wishlist"}], + "source_fields": ["title", "acq"], + } + file_id = "test_validate_acq" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.validate_import( + file_id, + user, + {"title": ImportFieldConfig(source="title"), "acquisition_status": ImportFieldConfig(source="acq")}, + session, + require_acquisition_status=True, + ) + assert any("acquisition_status" in e for e in result["errors"]) + + +def test_validate_import_invalid_date_started( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "started": "not-a-date"}], + "source_fields": ["title", "started"], + } + file_id = "test_validate_bad_started" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.validate_import( + file_id, + user, + {"title": ImportFieldConfig(source="title"), "date_started": ImportFieldConfig(source="started")}, + session, + ) + assert any("date_started" in e for e in result["errors"]) + + +def test_validate_import_invalid_date_finished( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "finished": "not-a-date"}], + "source_fields": ["title", "finished"], + } + file_id = "test_validate_bad_finished" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.validate_import( + file_id, + user, + {"title": ImportFieldConfig(source="title"), "date_finished": ImportFieldConfig(source="finished")}, + session, + ) + assert any("date_finished" in e for e in result["errors"]) + + +# ── preview_import ──────────────────────────────────────────────────────────── + +def test_preview_import_missing_title( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": ""}], + "source_fields": ["title"], + } + file_id = "test_preview_missing_title" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.preview_import( + file_id, user, {"title": ImportFieldConfig(source="title")} + ) + assert any("Missing required field 'title'" in e for e in result["preview_rows"][0]["errors"]) + + +def test_preview_import_rating_out_of_range( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "rating": "99"}], + "source_fields": ["title", "rating"], + } + file_id = "test_preview_rating" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.preview_import( + file_id, user, {"title": ImportFieldConfig(source="title"), "rating": ImportFieldConfig(source="rating")} + ) + assert any("Rating out of range" in e for e in result["preview_rows"][0]["errors"]) + + +def test_preview_import_invalid_page_count( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "pages": "abc"}], + "source_fields": ["title", "pages"], + } + file_id = "test_preview_pages" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.preview_import( + file_id, user, {"title": ImportFieldConfig(source="title"), "page_count": ImportFieldConfig(source="pages")} + ) + assert any("page_count" in e for e in result["preview_rows"][0]["errors"]) + + +def test_preview_import_invalid_date_started( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "started": "bad-date"}], + "source_fields": ["title", "started"], + } + file_id = "test_preview_bad_started" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.preview_import( + file_id, user, {"title": ImportFieldConfig(source="title"), "date_started": ImportFieldConfig(source="started")} + ) + assert any("date_started" in e for e in result["preview_rows"][0]["errors"]) + + +def test_preview_import_invalid_date_finished( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "finished": "bad-date"}], + "source_fields": ["title", "finished"], + } + file_id = "test_preview_bad_finished" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.preview_import( + file_id, user, {"title": ImportFieldConfig(source="title"), "date_finished": ImportFieldConfig(source="finished")} + ) + assert any("date_finished" in e for e in result["preview_rows"][0]["errors"]) + + +def test_preview_import_date_order( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "started": "2024-02-01", "finished": "2024-01-01"}], + "source_fields": ["title", "started", "finished"], + } + file_id = "test_preview_order" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.preview_import( + file_id, + user, + { + "title": ImportFieldConfig(source="title"), + "date_started": ImportFieldConfig(source="started"), + "date_finished": ImportFieldConfig(source="finished"), + }, + ) + assert any("date_started is after date_finished" in e for e in result["preview_rows"][0]["errors"]) + + +def test_preview_import_read_without_finished_date( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "status": "read"}], + "source_fields": ["title", "status"], + } + file_id = "test_preview_read_nofinish" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.preview_import( + file_id, user, {"title": ImportFieldConfig(source="title"), "reading_status": ImportFieldConfig(source="status")} + ) + assert any("no finished date" in e for e in result["preview_rows"][0]["errors"]) + + +def test_preview_import_require_acquisition_status_invalid( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "acq": "wishlist"}], + "source_fields": ["title", "acq"], + } + file_id = "test_preview_acq" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.preview_import( + file_id, + user, + {"title": ImportFieldConfig(source="title"), "acquisition_status": ImportFieldConfig(source="acq")}, + require_acquisition_status=True, + ) + assert any("acquisition_status" in e for e in result["preview_rows"][0]["errors"]) + + +def test_preview_import_invalid_isbn( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "isbn": "not-valid"}], + "source_fields": ["title", "isbn"], + } + file_id = "test_preview_isbn" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + result = di.preview_import( + file_id, user, {"title": ImportFieldConfig(source="title"), "isbn": ImportFieldConfig(source="isbn")} + ) + assert any("isbn" in e.lower() for e in result["preview_rows"][0]["errors"]) + + +# ── execute_import ──────────────────────────────────────────────────────────── + +@pytest.mark.anyio +async def test_execute_import_transform_error( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "num": "abc"}], + "source_fields": ["title", "num"], + } + file_id = "test_exec_transform" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + events = [] + async for event in di.execute_import( + file_id, + user, + {"title": ImportFieldConfig(source="title"), "rating": ImportFieldConfig(source="num", transform="return int(value)")}, + session, + "continue_on_error", + ): + events.append(event) + complete = [e for e in events if e["event"] == "complete"][0] + assert complete["failed"] == 1 + + +@pytest.mark.anyio +async def test_execute_import_invalid_date_started( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "started": "bad-date"}], + "source_fields": ["title", "started"], + } + file_id = "test_exec_bad_started" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + events = [] + async for event in di.execute_import( + file_id, + user, + {"title": ImportFieldConfig(source="title"), "date_started": ImportFieldConfig(source="started")}, + session, + "continue_on_error", + ): + events.append(event) + complete = [e for e in events if e["event"] == "complete"][0] + assert complete["failed"] == 1 + + +@pytest.mark.anyio +async def test_execute_import_invalid_date_finished( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "finished": "bad-date"}], + "source_fields": ["title", "finished"], + } + file_id = "test_exec_bad_finished" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + events = [] + async for event in di.execute_import( + file_id, + user, + {"title": ImportFieldConfig(source="title"), "date_finished": ImportFieldConfig(source="finished")}, + session, + "continue_on_error", + ): + events.append(event) + complete = [e for e in events if e["event"] == "complete"][0] + assert complete["failed"] == 1 + + +@pytest.mark.anyio +async def test_execute_import_read_without_finished_date( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "status": "read"}], + "source_fields": ["title", "status"], + } + file_id = "test_exec_read_nofinish" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + events = [] + async for event in di.execute_import( + file_id, + user, + {"title": ImportFieldConfig(source="title"), "reading_status": ImportFieldConfig(source="status")}, + session, + "continue_on_error", + ): + events.append(event) + complete = [e for e in events if e["event"] == "complete"][0] + assert complete["failed"] == 1 + + +@pytest.mark.anyio +async def test_execute_import_naive_log_date_gets_utc_tz( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [{"title": "Book", "status": "read", "pages": "100", "finished": "2024-01-15"}], + "source_fields": ["title", "status", "pages", "finished"], + } + file_id = "test_exec_naive_logdate" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + original_parse_datetime = di._parse_datetime + + def _naive_finished_parse(value: object, field: str): + if field == "date_finished": + return datetime(2024, 1, 15, 10, 30, 0) # naive + return original_parse_datetime(value, field) + + monkeypatch.setattr(di, "_parse_datetime", _naive_finished_parse) + + events = [] + async for event in di.execute_import( + file_id, + user, + { + "title": ImportFieldConfig(source="title"), + "reading_status": ImportFieldConfig(source="status"), + "page_count": ImportFieldConfig(source="pages"), + "date_finished": ImportFieldConfig(source="finished"), + }, + session, + "continue_on_error", + create_progress_for_read=True, + ): + events.append(event) + complete = [e for e in events if e["event"] == "complete"][0] + assert complete["imported"] == 1 + + +# ── get_predefined_mapping ──────────────────────────────────────────────────── + +def test_get_predefined_mapping_known_id() -> None: + result = di.get_predefined_mapping(-1) + assert result is not None + assert result["name"] == "Goodreads Export" + + +def test_get_predefined_mapping_unknown_id() -> None: + assert di.get_predefined_mapping(-999) is None diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index a0b70354..9381da23 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -1,8 +1,9 @@ """Tests for app.database module.""" from collections.abc import Generator +from unittest.mock import MagicMock -from app.database import create_db_and_tables, get_session, _dispose_engine +from app.database import create_db_and_tables, get_session, _dispose_engine, _set_sqlite_pragmas from sqlmodel import Session @@ -25,3 +26,9 @@ def test_get_session_yields_session() -> None: def test_dispose_engine() -> None: """_dispose_engine should run without error.""" _dispose_engine() + + +def test_set_sqlite_pragmas_skips_non_sqlite_connection() -> None: + """The pragma callback should return early for non-sqlite connections.""" + result = _set_sqlite_pragmas(MagicMock(), None) + assert result is None diff --git a/backend/tests/test_email.py b/backend/tests/test_email.py new file mode 100644 index 00000000..741b7a1d --- /dev/null +++ b/backend/tests/test_email.py @@ -0,0 +1,59 @@ +"""Tests for app.email module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.email import send_password_reset_email + + +@pytest.mark.anyio +async def test_send_password_reset_email_success(monkeypatch) -> None: + """A successful send should call FastMail.send_message with the expected message.""" + monkeypatch.setattr("app.config.settings.password_reset_token_max_age", 3600) + monkeypatch.setattr("app.config.settings.mail_username", "user") + monkeypatch.setattr("app.config.settings.mail_password", "pass") + monkeypatch.setattr("app.config.settings.mail_from", "noreply@example.com") + monkeypatch.setattr("app.config.settings.mail_server", "smtp.example.com") + monkeypatch.setattr("app.config.settings.mail_port", 587) + + mock_fastmail_cls = MagicMock() + mock_fm = MagicMock() + mock_fm.send_message = AsyncMock() + mock_fastmail_cls.return_value = mock_fm + + with patch("app.email.FastMail", mock_fastmail_cls): + await send_password_reset_email("user@example.com", "https://reset.url", locale="en") + + mock_fastmail_cls.assert_called_once() + mock_fm.send_message.assert_awaited_once() + message = mock_fm.send_message.call_args[0][0] + assert len(message.recipients) == 1 + assert message.recipients[0].email == "user@example.com" + assert "Password Reset" in message.subject + assert "https://reset.url" in message.body + assert "60 minutes" in message.body + + +@pytest.mark.anyio +async def test_send_password_reset_email_exception_logs_error(monkeypatch) -> None: + """An exception during send should be logged and swallowed.""" + monkeypatch.setattr("app.config.settings.password_reset_token_max_age", 1800) + monkeypatch.setattr("app.config.settings.mail_username", "user") + monkeypatch.setattr("app.config.settings.mail_password", "pass") + monkeypatch.setattr("app.config.settings.mail_from", "noreply@example.com") + monkeypatch.setattr("app.config.settings.mail_server", "smtp.example.com") + monkeypatch.setattr("app.config.settings.mail_port", 587) + + mock_fastmail_cls = MagicMock() + mock_fm = MagicMock() + mock_fm.send_message = AsyncMock(side_effect=RuntimeError("SMTP failed")) + mock_fastmail_cls.return_value = mock_fm + + with patch("app.email.logger") as mock_logger: + with patch("app.email.FastMail", mock_fastmail_cls): + await send_password_reset_email("user@example.com", "https://reset.url") + + mock_fm.send_message.assert_awaited_once() + mock_logger.exception.assert_called_once() + assert "user@example.com" in str(mock_logger.exception.call_args) diff --git a/backend/tests/test_embed.py b/backend/tests/test_embed.py index 2b0e45b5..b0a374b0 100644 --- a/backend/tests/test_embed.py +++ b/backend/tests/test_embed.py @@ -1,12 +1,16 @@ """Tests for embed token lifecycle and the embed HTML widget endpoint.""" +import json import re from collections.abc import Callable from datetime import datetime, timedelta, timezone +from pathlib import Path from typing import Any import pytest +from fastapi import HTTPException, Request from fastapi.testclient import TestClient +from pytest import MonkeyPatch from sqlmodel import Session, select from app.auth import generate_embed_token, hash_embed_token @@ -252,6 +256,7 @@ def test_user_isolation(self, client: TestClient, session: Session) -> None: session.add(user2) session.commit() session.refresh(user2) + assert user2.id is not None session.add(UserSettings(user_id=user2.id, language="en")) key2 = generate_api_key() session.add(ApiKey(user_id=user2.id, key_prefix=get_api_key_prefix(key2), @@ -357,3 +362,193 @@ def test_security_headers(self, client: TestClient, session: Session) -> None: assert resp.headers.get("x-content-type-options") == "nosniff" assert resp.headers.get("referrer-policy") == "no-referrer" assert resp.headers.get("content-security-policy") == "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors *" + + +# ── Direct unit tests for uncovered embed branches ─────────────────────── + + +def _make_fake_path_class(tmp_path: Path): + """Return a minimal Path stand-in that redirects embed i18n lookups to tmp_path.""" + + class FakePath: + def __init__(self, *parts: str): + self._parts = parts + if not parts or parts == ("i18n",): + self._path = tmp_path + else: + self._path = tmp_path.joinpath(*parts) + + def resolve(self): + return self + + @property + def parent(self): + return FakePath() + + def __truediv__(self, other: str): + if other == "i18n": + return FakePath() + return FakePath(other) + + def glob(self, pattern: str): + return [FakePath(p.name) for p in sorted(tmp_path.glob(pattern))] + + @property + def stem(self): + return self._path.stem + + def open(self, *args, **kwargs): + return self._path.open(*args, **kwargs) + + def __str__(self): + return str(self._path) + + @property + def name(self): + return self._path.name + + return FakePath + + +def test_load_stat_labels_skips_invalid_stats(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + from app.routers import embed as embed_module + + (tmp_path / "de.json").write_text(json.dumps({"embed": {"stats": "not-a-dict"}})) + (tmp_path / "en.json").write_text( + json.dumps( + { + "embed": { + "stats": { + "books": "Books", + "reading": "Reading", + "read": "Read", + "to_read": "To Read", + "pages": "Pages", + "avg_pages": "Avg/Book", + } + } + } + ) + ) + monkeypatch.setattr(embed_module, "Path", _make_fake_path_class(tmp_path)) + labels = embed_module._load_stat_labels() + assert "en" in labels + assert "de" not in labels + + +def test_load_stat_labels_missing_required_key(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + from app.routers import embed as embed_module + + (tmp_path / "en.json").write_text(json.dumps({"embed": {"stats": {"books": "Books"}}})) + monkeypatch.setattr(embed_module, "Path", _make_fake_path_class(tmp_path)) + with pytest.raises(RuntimeError, match="missing embed.stats keys"): + embed_module._load_stat_labels() + + +def test_load_stat_labels_missing_english(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + from app.routers import embed as embed_module + + (tmp_path / "de.json").write_text( + json.dumps( + { + "embed": { + "stats": { + "books": "B\u00fccher", + "reading": "Lesen", + "read": "Gelesen", + "to_read": "Zu lesen", + "pages": "Seiten", + "avg_pages": "\u00d8/Buch", + } + } + } + ) + ) + monkeypatch.setattr(embed_module, "Path", _make_fake_path_class(tmp_path)) + with pytest.raises(RuntimeError, match="expected at least en.json"): + embed_module._load_stat_labels() + + +def test_verify_embed_token_missing_scope(client: TestClient, session: Session) -> None: + from app.routers.embed import _verify_embed_token + + plain = generate_embed_token() + token = EmbedToken( + user_id=1, + name="No Scope", + token_prefix=plain[:12], + token_hash=hash_embed_token(plain), + scopes="other:scope", + ) + session.add(token) + session.commit() + + request = Request({"type": "http", "headers": []}) + with pytest.raises(HTTPException) as exc_info: + _verify_embed_token(plain, session, request) + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Token lacks required scope" + + +def test_verify_embed_token_empty_origin_allowed(client: TestClient, session: Session) -> None: + from app.routers.embed import _verify_embed_token + + plain = _create_token(session, user_id=1, allowed_origins="https://example.com") + request = Request({"type": "http", "headers": []}) + user = _verify_embed_token(plain, session, request) + assert user.email == "test@example.com" + + +def test_verify_embed_token_user_not_found(session: Session) -> None: + from app.auth import get_password_hash + from app.models import User + from app.routers.embed import _verify_embed_token + + user = User( + firstname="Orphan", + lastname="Token", + email="orphan@example.com", + role=UserRole.user, + hashed_password=get_password_hash("secret"), + ) + session.add(user) + session.commit() + session.refresh(user) + assert user.id is not None + + plain = generate_embed_token() + token = EmbedToken( + user_id=user.id, + name="Orphan", + token_prefix=plain[:12], + token_hash=hash_embed_token(plain), + ) + session.add(token) + session.commit() + + session.delete(user) + session.commit() + + request = Request({"type": "http", "headers": []}) + with pytest.raises(HTTPException) as exc_info: + _verify_embed_token(plain, session, request) + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Token user not found" + + +def test_render_stats_html_zero_items_grid() -> None: + from app.routers.embed import _render_stats_html + + html = _render_stats_html( + {}, + theme="light", + accent="#000000", + radius="md", + density="normal", + hide_labels=False, + lang="en", + font_scale=1.0, + layout="grid", + show={"not_a_stat"}, + ) + assert "grid-template-columns:repeat(1,1fr)" in html diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 76f390c2..7fd965c3 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -39,6 +39,19 @@ def _raise(*args: object, **kwargs: object) -> None: assert checks["database_schema"]["status"] == "unhealthy" +def test_health_database_schema_inspector_none(client: TestClient, monkeypatch) -> None: + """When inspect(bind) returns None, schema check should report unhealthy.""" + def _inspect_none(bind): + return None + + monkeypatch.setattr("app.routers.health.inspect", _inspect_none) + resp = client.get("/api/health") + assert resp.status_code == 200 + checks = resp.json()["checks"] + assert checks["database_schema"]["status"] == "unhealthy" + assert "no inspector" in checks["database_schema"]["detail"].lower() + + def test_health_not_sqlite(client: TestClient, monkeypatch) -> None: """Non-SQLite DB should skip data_dir_writable with skipped message.""" monkeypatch.setattr( diff --git a/backend/tests/test_hygiene.py b/backend/tests/test_hygiene.py index bbf16475..ef065d63 100644 --- a/backend/tests/test_hygiene.py +++ b/backend/tests/test_hygiene.py @@ -2,6 +2,7 @@ import pytest from fastapi.testclient import TestClient +from pytest import MonkeyPatch from sqlmodel import Session from app.models import Book, ReadingStatus, User @@ -353,3 +354,81 @@ async def _fake_download(url: str, covers_dir: str, http_client: object, user_id assert data["updated"] == 0 assert data["skipped"] == 1 assert b1.id in data["skipped_ids"] + + def test_batch_update_author_empty(self, client: TestClient, session: Session) -> None: + """Setting author to whitespace should be rejected.""" + b1 = _create_book(session, 1, title="B1", author="Old") + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "author", + "value": " ", + }) + assert resp.status_code == 422 + assert "author must not be empty" in resp.json()["detail"] + + def test_batch_update_published_year_success(self, client: TestClient, session: Session) -> None: + """published_year up to 2099 can be set.""" + b1 = _create_book(session, 1, title="B1", published_year=2020) + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "published_year", + "value": 2099, + }) + assert resp.status_code == 200 + session.refresh(b1) + assert b1.published_year == 2099 + + def test_batch_update_published_year_too_large(self, client: TestClient, session: Session) -> None: + """published_year greater than 2099 should be rejected.""" + b1 = _create_book(session, 1, title="B1", published_year=2020) + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "published_year", + "value": 2100, + }) + assert resp.status_code == 422 + assert "no greater than 2099" in resp.json()["detail"] + + def test_batch_update_database_error(self, client: TestClient, session: Session, monkeypatch: MonkeyPatch) -> None: + """A database error during the update should return 500.""" + from sqlalchemy.sql.dml import Update + + b1 = _create_book(session, 1, title="B1", author="Old") + original_exec = session.exec + + def fake_exec(statement, *args, **kwargs): + if isinstance(statement, Update): + raise Exception("database error") + return original_exec(statement, *args, **kwargs) + + monkeypatch.setattr(session, "exec", fake_exec) + + resp = client.post("/api/hygiene/batch-update", json={ + "book_ids": [b1.id], + "field": "author", + "value": "New", + }) + assert resp.status_code == 500 + assert resp.json()["detail"] == "Batch update failed due to a database error" + + +class TestListMissingEdgeCases: + def test_missing_empty_attribute_part_skipped(self, client: TestClient, session: Session) -> None: + """Empty parts in the attributes list are ignored.""" + _create_book(session, 1, title="Missing ISBN", isbn=None, author="Author") + resp = client.get("/api/hygiene/missing?attributes=isbn,,&match=all") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + + def test_missing_unknown_attribute_returns_422(self, client: TestClient) -> None: + """Unknown attribute names return a 422 error.""" + resp = client.get("/api/hygiene/missing?attributes=unknown") + assert resp.status_code == 422 + assert "Unknown attribute" in resp.json()["detail"] + + def test_missing_only_empty_attributes_returns_422(self, client: TestClient) -> None: + """A list containing only empty attribute names is rejected.""" + resp = client.get("/api/hygiene/missing?attributes=,,&match=all") + assert resp.status_code == 422 + assert "At least one attribute" in resp.json()["detail"] diff --git a/backend/tests/test_i18n.py b/backend/tests/test_i18n.py new file mode 100644 index 00000000..f5d454ca --- /dev/null +++ b/backend/tests/test_i18n.py @@ -0,0 +1,37 @@ +"""Tests for app.i18n.translate.""" + +from app.i18n import translate + + + +def test_translate_existing_key() -> None: + """An existing key returns the translated string.""" + assert translate("email.passwordResetSubject") == "Password Reset – LibrisLog" + + +def test_translate_missing_key_returns_empty() -> None: + """A missing key returns an empty string.""" + assert translate("does.not.exist") == "" + + +def test_translate_fallback_locale() -> None: + """An unsupported locale falls back to English.""" + assert translate("email.passwordResetSubject", locale="xx") == "Password Reset – LibrisLog" + + +def test_translate_interpolation() -> None: + """Placeholders are interpolated into the translated value.""" + body = translate("email.passwordResetBody", duration_minutes="30", reset_url="https://example.com/reset") + assert "30 minutes" in body + assert "https://example.com/reset" in body + + +def test_translate_non_dict_path_returns_empty() -> None: + """Traversing into a non-dict value returns an empty string.""" + assert translate("email.passwordResetSubject.extra") == "" + + +def test_translate_invalid_value_returns_empty(monkeypatch) -> None: + """A non-string leaf value is coerced to an empty string.""" + monkeypatch.setattr("app.i18n._load_translations", lambda locale: {"key": 123}) + assert translate("key") == "" diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 3f8e35ea..f5d97559 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -66,6 +66,7 @@ def test_map_open_library_fields() -> None: assert result.page_count == 412 assert result.language == "EN" assert result.publisher == "Ace Books" + assert result.tags is not None assert "Science Fiction" in result.tags assert result.cover_url == "https://covers.openlibrary.org/b/id/11481354-L.jpg" assert result.source == "open_library" @@ -851,7 +852,7 @@ def __init__(self, status_code: int = 200, headers: dict[str, str] | None = None def raise_for_status(self) -> None: if not self.is_success: - raise httpx.HTTPStatusError("error", request=None, response=self) # type: ignore[arg-type] + raise httpx.HTTPStatusError("error", request=None, response=self) # ty: ignore[invalid-argument-type] def json(self) -> dict[str, Any]: return self._body @@ -904,7 +905,7 @@ async def test_best_cover_prefers_large_over_thumbnail() -> None: _LARGE_URL: _FakeResponse(200, headers=_IMAGE_HEADERS), }, ) - result = await book_import._best_google_books_cover(_VOLUME_ID, _THUMB_URL, fake_client) # type: ignore[arg-type] + result = await book_import._best_google_books_cover(_VOLUME_ID, _THUMB_URL, fake_client) # ty: ignore[invalid-argument-type] assert result == _LARGE_URL @@ -926,7 +927,7 @@ async def test_best_cover_falls_back_when_large_too_small() -> None: _MEDIUM_URL: _FakeResponse(200, headers=_IMAGE_HEADERS), }, ) - result = await book_import._best_google_books_cover(_VOLUME_ID, _THUMB_URL, fake_client) # type: ignore[arg-type] + result = await book_import._best_google_books_cover(_VOLUME_ID, _THUMB_URL, fake_client) # ty: ignore[invalid-argument-type] assert result == _MEDIUM_URL @@ -948,7 +949,7 @@ async def test_best_cover_falls_back_when_large_not_image() -> None: _THUMB_URL: _FakeResponse(200, headers=_IMAGE_HEADERS), }, ) - result = await book_import._best_google_books_cover(_VOLUME_ID, _THUMB_URL, fake_client) # type: ignore[arg-type] + result = await book_import._best_google_books_cover(_VOLUME_ID, _THUMB_URL, fake_client) # ty: ignore[invalid-argument-type] assert result == _THUMB_URL @@ -961,7 +962,7 @@ async def test_best_cover_uses_fallback_when_volume_fetch_fails() -> None: _THUMB_URL: _FakeResponse(200, headers=_IMAGE_HEADERS), }, ) - result = await book_import._best_google_books_cover(_VOLUME_ID, _THUMB_URL, fake_client) # type: ignore[arg-type] + result = await book_import._best_google_books_cover(_VOLUME_ID, _THUMB_URL, fake_client) # ty: ignore[invalid-argument-type] assert result == _THUMB_URL @@ -976,7 +977,7 @@ async def test_best_cover_upgrades_http_to_https() -> None: _THUMB_URL: _FakeResponse(200, headers=_IMAGE_HEADERS), # https version }, ) - result = await book_import._best_google_books_cover(_VOLUME_ID, http_thumb, fake_client) # type: ignore[arg-type] + result = await book_import._best_google_books_cover(_VOLUME_ID, http_thumb, fake_client) # ty: ignore[invalid-argument-type] assert result is not None assert result.startswith("https://") @@ -985,7 +986,7 @@ async def test_best_cover_upgrades_http_to_https() -> None: async def test_best_cover_returns_none_when_no_candidates() -> None: """Returns None when no fallback is provided and volume fetch fails.""" fake_client = _FakeClient() - result = await book_import._best_google_books_cover(None, None, fake_client) # type: ignore[arg-type] + result = await book_import._best_google_books_cover(None, None, fake_client) # ty: ignore[invalid-argument-type] assert result is None diff --git a/backend/tests/test_logging_config.py b/backend/tests/test_logging_config.py index 7c8914fa..27329b3e 100644 --- a/backend/tests/test_logging_config.py +++ b/backend/tests/test_logging_config.py @@ -1,6 +1,7 @@ """Tests for app.logging_config module.""" import logging +from collections.abc import Generator import pytest diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 3d97a65e..d96e9ff9 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -2,7 +2,7 @@ import asyncio import importlib -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import FastAPI, Request @@ -139,7 +139,7 @@ async def test_proxy_headers_middleware_sets_scheme_from_forwarded_proto() -> No async def scheme(request: Request): return {"scheme": request.url.scheme} - app.add_middleware(type(None), middleware=proxy_headers_middleware) # noqa + app.add_middleware(type(None), middleware=proxy_headers_middleware) # ty: ignore[invalid-argument-type] # We need to add the middleware as a pure "http" middleware, which isn't # directly doable via add_middleware. Instead, we test the logic directly. @@ -191,3 +191,74 @@ async def call_next(request: Request) -> Response: await proxy_headers_middleware(request, call_next) assert received is not None assert received["scheme"] == "http" + + +@pytest.mark.anyio +async def test_lifespan_logs_warning_when_mail_not_configured(monkeypatch) -> None: + """Lifespan should warn when MAIL_SERVER or MAIL_FROM are not configured.""" + from app.config import settings + import app.main as main_module + + original_server = settings.mail_server + original_from = settings.mail_from + try: + settings.mail_server = " " + settings.mail_from = " " + importlib.reload(main_module) + with patch("app.main.logger") as mock_logger: + with patch("app.main._periodic_maintenance", new=AsyncMock()): + async with main_module.lifespan(main_module.app): + pass + warning_messages = [str(call) for call in mock_logger.warning.call_args_list] + assert any("MAIL_SERVER or MAIL_FROM" in msg for msg in warning_messages) + finally: + settings.mail_server = original_server + settings.mail_from = original_from + importlib.reload(main_module) + + +def test_display_version_includes_git_sha_when_not_embedded() -> None: + """Version display should include the git sha when it is not part of the version string.""" + from app import _build_info + import app.main as main_module + + original_sha = _build_info.__git_sha__ + original_version = _build_info.__version__ + try: + _build_info.__git_sha__ = "abcdef1234567890" + _build_info.__version__ = "1.0.0" + importlib.reload(main_module) + assert "abcdef1" in main_module._display_version + finally: + _build_info.__git_sha__ = original_sha + _build_info.__version__ = original_version + importlib.reload(main_module) +@pytest.mark.anyio +async def test_proxy_headers_middleware_skips_untrusted_proxy(monkeypatch) -> None: + """When request is not from a trusted proxy, X-Forwarded-Proto is ignored.""" + from app.main import proxy_headers_middleware + + monkeypatch.setattr("app.main._TRUSTED_PROXY_IPS", {"10.0.0.5"}) + + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [ + (b"host", b"example.com"), + (b"x-forwarded-proto", b"https"), + ], + "scheme": "http", + "client": ("192.168.1.1", 54321), + } + received: dict | None = None + + async def call_next(request: Request) -> Response: + nonlocal received + received = {"scheme": request.url.scheme} + return JSONResponse(received) + + request = Request(scope) + await proxy_headers_middleware(request, call_next) + assert received is not None + assert received["scheme"] == "http" diff --git a/backend/tests/test_oidc.py b/backend/tests/test_oidc.py index 32eced21..c40b17b9 100644 --- a/backend/tests/test_oidc.py +++ b/backend/tests/test_oidc.py @@ -277,6 +277,7 @@ def test_oidc_link_callback_rejects_sub_already_linked_to_another_user( _set_oidc_enabled(monkeypatch) other_user, _ = create_user_with_key(email="other-oidc@example.com") + assert other_user.id is not None session.add( OidcLink( user_id=other_user.id, diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 33b918db..5f0f383f 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -5,7 +5,9 @@ from fastapi.testclient import TestClient from sqlmodel import Session -from app.models import UserRole +from app.auth import generate_embed_token, get_embed_token_prefix, hash_embed_token +from app.models import EmbedToken, UserRole +from app.time_utils import utcnow def test_get_profile_returns_current_user(client: TestClient) -> None: @@ -26,6 +28,7 @@ def test_get_settings_creates_default_when_missing(client: TestClient, session: session.add(user) session.commit() session.refresh(user) + assert user.id is not None key_plain = generate_api_key() session.add(ApiKey(user_id=user.id, key_prefix=get_api_key_prefix(key_plain), @@ -51,6 +54,7 @@ def test_update_settings_creates_default_when_missing(client: TestClient, sessio session.add(user) session.commit() session.refresh(user) + assert user.id is not None key_plain = generate_api_key() session.add(ApiKey(user_id=user.id, key_prefix=get_api_key_prefix(key_plain), @@ -102,3 +106,30 @@ def test_delete_api_key_not_found(client: TestClient) -> None: resp = client.delete("/api/profile/api-keys/99999") assert resp.status_code == 404 assert resp.json()["detail"] == "API key not found" + + +def test_rotate_embed_token_not_found(client: TestClient) -> None: + """Rotating a non-existent embed token should return 404.""" + resp = client.post("/api/profile/embed-tokens/99999/rotate") + assert resp.status_code == 404 + assert resp.json()["detail"] == "Embed token not found" + + +def test_rotate_embed_token_revoked(client: TestClient, session: Session) -> None: + """Rotating a revoked embed token should return 404.""" + plain = generate_embed_token() + token = EmbedToken( + user_id=1, + name="Revoked", + token_prefix=get_embed_token_prefix(plain), + token_hash=hash_embed_token(plain), + revoked_at=utcnow(), + ) + session.add(token) + session.commit() + session.refresh(token) + assert token.id is not None + + resp = client.post(f"/api/profile/embed-tokens/{token.id}/rotate") + assert resp.status_code == 404 + assert resp.json()["detail"] == "Embed token not found" diff --git a/backend/tests/test_progress.py b/backend/tests/test_progress.py index fe60127e..66caed6d 100644 --- a/backend/tests/test_progress.py +++ b/backend/tests/test_progress.py @@ -31,7 +31,7 @@ def test_create_progress_page_exceeds_page_count(client: TestClient) -> None: def test_create_progress_wrong_user_returns_404(client: TestClient, create_user_with_key: Callable[..., Any]) -> None: book = _create_book(client) _user2, key2 = create_user_with_key(email="other@example.com") - with TestClient(client.app) as c2: # type: ignore[arg-type] + with TestClient(client.app) as c2: c2.headers.update({"X-API-Key": key2}) resp = c2.post(f"/api/books/{book['id']}/progress", json={"page": 10}) assert resp.status_code == 404 @@ -73,7 +73,7 @@ def test_delete_progress_entry_wrong_user_returns_404(client: TestClient, create book = _create_book(client) entry = client.post(f"/api/books/{book['id']}/progress", json={"page": 10}).json() _user2, key2 = create_user_with_key(email="other@example.com") - with TestClient(client.app) as c2: # type: ignore[arg-type] + with TestClient(client.app) as c2: c2.headers.update({"X-API-Key": key2}) resp = c2.delete(f"/api/books/{book['id']}/progress/{entry['id']}") assert resp.status_code == 404 @@ -149,7 +149,7 @@ def test_update_progress_entry_wrong_user_returns_404(client: TestClient, create book = _create_book(client) entry = client.post(f"/api/books/{book['id']}/progress", json={"page": 10}).json() _user2, key2 = create_user_with_key(email="other@example.com") - with TestClient(client.app) as c2: # type: ignore[arg-type] + with TestClient(client.app) as c2: c2.headers.update({"X-API-Key": key2}) resp = c2.patch( f"/api/books/{book['id']}/progress/{entry['id']}", diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py new file mode 100644 index 00000000..5fa774d1 --- /dev/null +++ b/backend/tests/test_schemas.py @@ -0,0 +1,44 @@ +"""Tests for Pydantic/SQLModel schemas.""" + +import pytest + +from app.models import Book +from app.schemas import UserSettingsUpdate + + +def test_book_normalizes_empty_cover_url_to_none() -> None: + """The Book model validator should turn an empty cover_url string into None.""" + book = Book.model_validate({"title": "Test", "cover_url": ""}) + assert book.cover_url is None + + +def test_user_settings_update_invalid_theme_raises() -> None: + """An invalid theme value should raise a validation error.""" + with pytest.raises(ValueError, match="theme must be one of"): + UserSettingsUpdate(theme="neon") + + +def test_user_settings_update_blank_custom_theme_returns_none() -> None: + """A blank custom_theme should be normalized to None.""" + update = UserSettingsUpdate(custom_theme=" ") + assert update.custom_theme is None + + +def test_user_settings_update_non_blank_custom_theme_preserved() -> None: + """A non-blank custom_theme should be preserved.""" + update = UserSettingsUpdate(custom_theme="my-theme") + assert update.custom_theme == "my-theme" + + +def test_user_settings_update_valid_theme_accepted() -> None: + """Valid theme values should be accepted.""" + for theme in ("light", "dark", "custom"): + update = UserSettingsUpdate(theme=theme) + assert update.theme == theme + + +def test_user_settings_update_none_values_accepted() -> None: + """None values for optional fields should be accepted.""" + update = UserSettingsUpdate() + assert update.theme is None + assert update.custom_theme is None diff --git a/backend/tests/test_statistics.py b/backend/tests/test_statistics.py index 936c9259..9da70e36 100644 --- a/backend/tests/test_statistics.py +++ b/backend/tests/test_statistics.py @@ -1,9 +1,12 @@ from collections import Counter from collections.abc import Callable -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock +from zoneinfo import ZoneInfo +from pytest import MonkeyPatch from sqlmodel import Session, select from app.models import Book, ReadingProgress, ReadingStatus, UserSettings @@ -38,6 +41,12 @@ def test_statistics_empty_library(client: Any) -> None: "read": 0, "did_not_finish": 0, } + assert data["acquisition_status_distribution"] == { + "owned": 0, + "borrowed": 0, + "digital_access": 0, + "to_acquire": 0, + } assert data["page_buckets"] == {"pages_to_read": 0, "pages_read": 0, "pages_wasted": 0} assert data["pages_read_per_month"] == [] assert data["books_finished_per_month"] == [] @@ -115,6 +124,40 @@ def test_statistics_core_metrics_and_distributions(client: Any) -> None: assert data["top_authors"][1]["book_count"] == 2 +def test_statistics_acquisition_status_distribution(client: Any) -> None: + _create_book(client, title="Owned 1", acquisition_status="owned") + _create_book(client, title="Owned 2", acquisition_status="owned") + _create_book(client, title="Borrowed 1", acquisition_status="borrowed") + _create_book(client, title="Digital 1", acquisition_status="digital_access") + _create_book(client, title="To Acquire 1", acquisition_status="to_acquire") + + resp = client.get("/api/statistics") + assert resp.status_code == 200 + data = resp.json() + assert data["acquisition_status_distribution"] == { + "owned": 2, + "borrowed": 1, + "digital_access": 1, + "to_acquire": 1, + } + + +def test_statistics_acquisition_status_defaults_to_owned(client: Any) -> None: + _create_book(client, title="Default 1") + _create_book(client, title="Default 2") + _create_book(client, title="Borrowed 1", acquisition_status="borrowed") + + resp = client.get("/api/statistics") + assert resp.status_code == 200 + data = resp.json() + assert data["acquisition_status_distribution"] == { + "owned": 2, + "borrowed": 1, + "digital_access": 0, + "to_acquire": 0, + } + + def test_statistics_top_authors_limit_and_tiebreaker(client: Any) -> None: _create_book(client, title="A1", author="Author Z", reading_status="read") _create_book(client, title="A2", author="Author Z", reading_status="read") @@ -157,6 +200,7 @@ def test_statistics_top_authors_no_covers(client: Any) -> None: def test_statistics_timezone_month_bucketing(client: Any, session: Session) -> None: settings = session.exec(select(UserSettings)).first() + assert settings is not None settings.timezone = "America/New_York" session.add(settings) session.commit() @@ -197,6 +241,7 @@ def test_statistics_pages_wasted_ignores_non_dnf(client: Any) -> None: def test_statistics_invalid_timezone_falls_back_to_utc(client: Any, session: Session) -> None: settings = session.exec(select(UserSettings)).first() + assert settings is not None settings.timezone = "Mars/OlympusMons" session.add(settings) session.commit() @@ -301,6 +346,8 @@ def test_pages_per_day_counts_single_log_when_started_and_finished_same_day(clie book = session.get(Book, created["id"]) assert book is not None + assert book.id is not None + assert book.user_id is not None session.add(ReadingProgress( book_id=book.id, user_id=book.user_id, page=250, created_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc), @@ -322,9 +369,9 @@ def test_extract_book_level_skips_books_with_missing_fields() -> None: """Books without date_started, date_finished or page_count are skipped.""" book = Book( title="Incomplete", reading_status=ReadingStatus.read, - date_started=None, date_finished=None, page_count=None, user_id=1, + date_started=None, date_finished=None, page_count=None, user_id=1, # ty: ignore[invalid-argument-type] ) - result = _extract_book_level_daily_pages([book], timezone.utc) + result = _extract_book_level_daily_pages([book], ZoneInfo("UTC")) assert result == Counter() @@ -344,5 +391,274 @@ def __sub__(self, other: object) -> MagicMock: book.date_finished = FakeDateTime() book.page_count = 100 - result = _extract_book_level_daily_pages([book], timezone.utc) + result = _extract_book_level_daily_pages([book], ZoneInfo("UTC")) assert result == Counter() + + +# ── Window clamping tests ──────────────────────────────────────────────── + + +def test_clamp_window_entirely_before() -> None: + from app.routers.statistics import _clamp_window + + start = datetime(2025, 1, 1, tzinfo=timezone.utc) + end = datetime(2025, 1, 5, tzinfo=timezone.utc) + window_start = datetime(2025, 1, 10, tzinfo=timezone.utc) + window_end = datetime(2025, 1, 20, tzinfo=timezone.utc) + assert _clamp_window(start, end, window_start, window_end) == (None, None) + + +def test_clamp_window_start_before_window() -> None: + from app.routers.statistics import _clamp_window + + start = datetime(2025, 1, 5, tzinfo=timezone.utc) + end = datetime(2025, 1, 15, tzinfo=timezone.utc) + window_start = datetime(2025, 1, 10, tzinfo=timezone.utc) + window_end = datetime(2025, 1, 20, tzinfo=timezone.utc) + result = _clamp_window(start, end, window_start, window_end) + assert result[0] == window_start + assert result[1] == end + + +def test_clamp_window_entirely_after() -> None: + from app.routers.statistics import _clamp_window + + start = datetime(2025, 1, 25, tzinfo=timezone.utc) + end = datetime(2025, 1, 30, tzinfo=timezone.utc) + window_start = datetime(2025, 1, 10, tzinfo=timezone.utc) + window_end = datetime(2025, 1, 20, tzinfo=timezone.utc) + assert _clamp_window(start, end, window_start, window_end) == (None, None) + + +def test_clamp_window_end_after_window() -> None: + from app.routers.statistics import _clamp_window + + start = datetime(2025, 1, 15, tzinfo=timezone.utc) + end = datetime(2025, 1, 25, tzinfo=timezone.utc) + window_start = datetime(2025, 1, 10, tzinfo=timezone.utc) + window_end = datetime(2025, 1, 20, tzinfo=timezone.utc) + result = _clamp_window(start, end, window_start, window_end) + assert result[0] == start + assert result[1] == window_end + + +# ── Virtual entry skip for read books without date_finished ────────────── + + +def test_pages_per_day_skips_virtual_entry_for_read_without_date_finished(client: Any, session: Session) -> None: + book = Book( + title="Read No Finish", + reading_status=ReadingStatus.read, + page_count=100, + date_started=datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc), + date_finished=None, + user_id=1, + ) + session.add(book) + session.commit() + session.refresh(book) + assert book.id is not None + + session.add( + ReadingProgress( + book_id=book.id, + user_id=1, + page=100, + created_at=datetime(2026, 1, 5, 10, 0, tzinfo=timezone.utc), + ) + ) + session.commit() + + resp = client.get("/api/statistics/pages-per-day?days=730") + assert resp.status_code == 200 + dates = {row["date"]: row["pages"] for row in resp.json()["data"]} + assert dates.get("2026-01-01") is None + assert dates.get("2026-01-05") is None + + +def test_statistics_skips_virtual_entry_for_read_without_date_finished(client: Any, session: Session) -> None: + book = Book( + title="Read No Finish", + reading_status=ReadingStatus.read, + page_count=100, + date_started=datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc), + date_finished=None, + user_id=1, + ) + session.add(book) + session.commit() + session.refresh(book) + assert book.id is not None + + session.add( + ReadingProgress( + book_id=book.id, + user_id=1, + page=100, + created_at=datetime(2026, 1, 5, 10, 0, tzinfo=timezone.utc), + ) + ) + session.commit() + + resp = client.get("/api/statistics") + assert resp.status_code == 200 + assert all(m["pages"] == 0 for m in resp.json()["pages_read_per_month"]) + + +def test_statistics_includes_virtual_entry_for_non_read_book_with_progress(client: Any, session: Session) -> None: + book = Book( + title="Currently Reading", + reading_status=ReadingStatus.currently_reading, + page_count=100, + date_started=datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc), + date_finished=None, + user_id=1, + ) + session.add(book) + session.commit() + session.refresh(book) + assert book.id is not None + + session.add( + ReadingProgress( + book_id=book.id, + user_id=1, + page=50, + created_at=datetime(2026, 1, 5, 10, 0, tzinfo=timezone.utc), + ) + ) + session.commit() + + resp = client.get("/api/statistics") + assert resp.status_code == 200 + pages_by_month = {m["month"]: m["pages"] for m in resp.json()["pages_read_per_month"]} + assert pages_by_month.get("2026-01") == 50 + + +# ── _compute_pages_per_month edge cases ────────────────────────────────── + + +def test_compute_pages_per_month_skips_non_positive_delta() -> None: + from app.routers.statistics import _compute_pages_per_month_from_progress + + entries = [ + SimpleNamespace(book_id=1, page=100, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc)), + SimpleNamespace(book_id=1, page=50, created_at=datetime(2026, 1, 2, tzinfo=timezone.utc)), + ] + result = _compute_pages_per_month_from_progress(entries, ZoneInfo("UTC")) + assert result == {} + + +def test_compute_pages_per_month_skips_non_positive_day_diff(monkeypatch: MonkeyPatch) -> None: + import builtins + + from app.routers.statistics import _compute_pages_per_month_from_progress + + # Bypass internal sorting so we can feed prev/curr in the order needed. + monkeypatch.setattr(builtins, "sorted", lambda iterable, **kwargs: list(iterable)) + + entries = [ + SimpleNamespace(book_id=1, page=10, created_at=datetime(2026, 1, 2, 10, 0, tzinfo=timezone.utc)), + SimpleNamespace(book_id=1, page=20, created_at=datetime(2026, 1, 2, 9, 0, tzinfo=timezone.utc)), + ] + result = _compute_pages_per_month_from_progress(entries, ZoneInfo("UTC")) + assert result == {} + + +def test_compute_pages_per_month_from_books_skips_invalid() -> None: + from app.routers.statistics import _compute_pages_per_month_from_books + + books = [ + Book(id=1, title="No dates", reading_status=ReadingStatus.read, user_id=1), + Book( + id=2, + title="Inverted", + reading_status=ReadingStatus.read, + user_id=1, + date_started=datetime(2026, 1, 5, tzinfo=timezone.utc), + date_finished=datetime(2026, 1, 1, tzinfo=timezone.utc), + page_count=100, + ), + ] + result = _compute_pages_per_month_from_books(books, ZoneInfo("UTC")) + assert result == {} + + +def test_compute_pages_per_month_from_books_skips_non_positive_total_days() -> None: + """total_days <= 0 should be skipped even when date_finished is not < date_started.""" + from app.routers.statistics import _compute_pages_per_month_from_books + + class FakeDateTime: + def __lt__(self, other: object) -> bool: + return False + + def __sub__(self, other: object) -> MagicMock: + mock_delta = MagicMock() + mock_delta.days = -1 + return mock_delta + + book = MagicMock() + book.date_started = FakeDateTime() + book.date_finished = FakeDateTime() + book.page_count = 100 + book.reading_status = ReadingStatus.read + + result = _compute_pages_per_month_from_books([book], ZoneInfo("UTC")) + assert result == {} + + +# ── Window exclusion continue branches ─────────────────────────────────── + + +def test_extract_progress_daily_pages_skips_outside_window() -> None: + from app.routers.statistics import _extract_progress_daily_pages + + entries = [ + SimpleNamespace(book_id=1, page=0, created_at=datetime(2025, 1, 1, tzinfo=timezone.utc)), + SimpleNamespace(book_id=1, page=100, created_at=datetime(2025, 1, 5, tzinfo=timezone.utc)), + ] + result = _extract_progress_daily_pages( + entries, + ZoneInfo("UTC"), + window_start=datetime(2026, 1, 1, tzinfo=timezone.utc), + window_end=datetime(2026, 1, 10, tzinfo=timezone.utc), + ) + assert result == {} + + +def test_extract_book_level_daily_pages_skips_outside_window() -> None: + from app.routers.statistics import _extract_book_level_daily_pages + + book = Book( + title="Old", + reading_status=ReadingStatus.read, + user_id=1, + page_count=100, + date_started=datetime(2025, 1, 1, tzinfo=timezone.utc), + date_finished=datetime(2025, 1, 5, tzinfo=timezone.utc), + ) + result = _extract_book_level_daily_pages( + [book], + ZoneInfo("UTC"), + window_start=datetime(2026, 1, 1, tzinfo=timezone.utc), + window_end=datetime(2026, 1, 10, tzinfo=timezone.utc), + ) + assert result == {} + + +# ── Rating stats ───────────────────────────────────────────────────────── + + +def test_statistics_top_and_worst_rated_books(client: Any) -> None: + _create_book(client, title="Best", author="A", reading_status="read", rating=5) + _create_book(client, title="Good", author="A", reading_status="read", rating=4) + _create_book(client, title="Okay", author="A", reading_status="read", rating=3) + _create_book(client, title="Bad", author="A", reading_status="read", rating=2) + + resp = client.get("/api/statistics") + assert resp.status_code == 200 + data = resp.json() + assert data["books_with_rating"] == 4 + assert data["average_rating"] == 3.5 + assert [b["title"] for b in data["top_rated_books"]] == ["Bad", "Okay", "Good", "Best"] + assert [b["title"] for b in data["worst_rated_books"]] == ["Best", "Good", "Okay", "Bad"] diff --git a/backend/tests/test_tags.py b/backend/tests/test_tags.py index b29c33f5..9056d0b9 100644 --- a/backend/tests/test_tags.py +++ b/backend/tests/test_tags.py @@ -5,6 +5,7 @@ from app.models import Book, BookTag, Tag from app.services.tags import ( cleanup_orphan_tags, + load_tags_batch, parse_tags, sync_book_tags, tags_text_for_book, @@ -49,6 +50,7 @@ def test_sync_book_tags_adds_new_tags(session: Session) -> None: book = Book(title="Test", user_id=user_id) session.add(book) session.flush() + assert book.id is not None sync_book_tags(session, user_id, book.id, "fantasy, sci-fi") @@ -67,6 +69,7 @@ def test_sync_book_tags_removes_removed_tags(session: Session) -> None: book = Book(title="Test", user_id=user_id) session.add(book) session.flush() + assert book.id is not None sync_book_tags(session, user_id, book.id, "fantasy, sci-fi") sync_book_tags(session, user_id, book.id, "fantasy") @@ -86,6 +89,7 @@ def test_sync_book_tags_clears_all_when_empty(session: Session) -> None: book = Book(title="Test", user_id=user_id) session.add(book) session.flush() + assert book.id is not None sync_book_tags(session, user_id, book.id, "fantasy, sci-fi") sync_book_tags(session, user_id, book.id, None) @@ -104,6 +108,7 @@ def test_sync_book_tags_reuses_existing_tags(session: Session) -> None: book = Book(title="Test", user_id=user_id) session.add(book) session.flush() + assert book.id is not None sync_book_tags(session, user_id, book.id, "fantasy") @@ -133,6 +138,8 @@ def test_cleanup_orphan_tags_keeps_linked(session: Session) -> None: book = Book(title="Test", user_id=user_id) session.add(book) session.flush() + assert book.id is not None + assert tag.id is not None session.add(BookTag(book_id=book.id, tag_id=tag.id)) session.flush() @@ -148,6 +155,7 @@ def test_tags_text_for_book_returns_none_for_no_tags(session: Session) -> None: book = Book(title="Test", user_id=user_id) session.add(book) session.flush() + assert book.id is not None assert tags_text_for_book(session, book.id) is None @@ -158,12 +166,50 @@ def test_tags_text_for_book_returns_comma_separated(session: Session) -> None: book = Book(title="Test", user_id=user_id) session.add(book) session.flush() + assert book.id is not None for name in ("fantasy", "sci-fi"): tag = Tag(user_id=user_id, name=name) session.add(tag) session.flush() + assert tag.id is not None session.add(BookTag(book_id=book.id, tag_id=tag.id)) session.flush() result = tags_text_for_book(session, book.id) assert result == "fantasy, sci-fi" + + +# ── load_tags_batch ─────────────────────────────────────────────────────────── + +def test_load_tags_batch_empty_book_ids(session: Session) -> None: + assert load_tags_batch(session, []) == {} + + +def test_load_tags_batch_multiple_books(session: Session) -> None: + user_id = 1 + book1 = Book(title="Book 1", user_id=user_id) + book2 = Book(title="Book 2", user_id=user_id) + session.add(book1) + session.add(book2) + session.flush() + assert book1.id is not None + assert book2.id is not None + + tag1 = Tag(user_id=user_id, name="fantasy") + tag2 = Tag(user_id=user_id, name="sci-fi") + tag3 = Tag(user_id=user_id, name="history") + session.add(tag1) + session.add(tag2) + session.add(tag3) + session.flush() + assert tag1.id is not None + assert tag2.id is not None + assert tag3.id is not None + + session.add(BookTag(book_id=book1.id, tag_id=tag1.id)) + session.add(BookTag(book_id=book1.id, tag_id=tag2.id)) + session.add(BookTag(book_id=book2.id, tag_id=tag3.id)) + + result = load_tags_batch(session, [book1.id, book2.id]) + assert result[book1.id] == "fantasy, sci-fi" + assert result[book2.id] == "history" diff --git a/backend/tests/test_transform_engine.py b/backend/tests/test_transform_engine.py index 582c523b..a83280fc 100644 --- a/backend/tests/test_transform_engine.py +++ b/backend/tests/test_transform_engine.py @@ -175,3 +175,55 @@ def test_cannot_access_dunder(self) -> None: def test_cannot_use_yield(self) -> None: with pytest.raises(ValueError): te.compile_transform("yield value") + + +class TestGuardedImport: + def test_guarded_import_disallowed(self) -> None: + with pytest.raises(ImportError, match="not allowed"): + te._guarded_import("os") + + +class TestImportFrom: + def test_forbidden_import_from_in_validate(self) -> None: + errors = te.validate_transform("from os import path") + assert any("Forbidden import" in e for e in errors) + + def test_forbidden_import_from_in_compile(self) -> None: + with pytest.raises(ValueError): + te.compile_transform("from os import path") + + +class TestRestrictedPythonRejection: + def test_compile_rejects_when_restricted_python_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(te, "compile_restricted", lambda *args, **kwargs: None) + with pytest.raises(ValueError, match="RestrictedPython rejected the code"): + te.compile_transform("return value") + + def test_validate_reports_when_restricted_python_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(te, "compile_restricted", lambda *args, **kwargs: None) + errors = te.validate_transform("return value") + assert any("RestrictedPython rejected the code" in e for e in errors) + + def test_validate_reports_compilation_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + def _raise(*args: object, **kwargs: object) -> None: + raise ValueError("boom") + + monkeypatch.setattr(te, "compile_restricted", _raise) + errors = te.validate_transform("return value") + assert any("Compilation error" in e for e in errors) + + +class TestFailedFunctionDefinition: + def test_raises_when_transform_function_not_defined(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + te, "compile_restricted", lambda source, filename, mode: compile("", filename, mode) + ) + with pytest.raises(ValueError, match="Failed to define transform function"): + te.compile_transform("return value") + + +class TestExecuteTransformErrors: + def test_runtime_exception_becomes_transform_execution_error(self) -> None: + fn = te.compile_transform("return int(value)") + with pytest.raises(te.TransformExecutionError): + te.execute_transform(fn, "not-a-number", {}, {}) diff --git a/backend/ty.toml b/backend/ty.toml new file mode 100644 index 00000000..d27d16aa --- /dev/null +++ b/backend/ty.toml @@ -0,0 +1,9 @@ +[environment] +python = "../.venv" +python-version = "3.14" + +[[overrides]] +include = ["alembic/versions"] + +[overrides.rules] +possibly-missing-submodule = "ignore" \ No newline at end of file diff --git a/backend/uv.lock b/backend/uv.lock deleted file mode 100644 index 9cdb5efe..00000000 --- a/backend/uv.lock +++ /dev/null @@ -1,1165 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.14" - -[[package]] -name = "alembic" -version = "1.18.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mako" }, - { name = "sqlalchemy" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, -] - -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, -] - -[[package]] -name = "apify-fingerprint-datapoints" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/f1/b74f95767581372ab849c8b13e384b62f60d034584892c60c4a3442d9312/apify_fingerprint_datapoints-0.13.0.tar.gz", hash = "sha256:263141c19e9bc90a821e6b4e2b845925f17e0b8fbd53a897fc71546bd50df7f1", size = 934827, upload-time = "2026-05-04T09:08:45.036Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/58/8402442bf6af5a3a8068fe5431c42ea4f73c1eb18f621f9bf7c5de80caf5/apify_fingerprint_datapoints-0.13.0-py3-none-any.whl", hash = "sha256:0213d42297be19e8035202b41fb2e840a1e5d79874c99c882a5027a7d0b1a0eb", size = 761652, upload-time = "2026-05-04T09:08:43.347Z" }, -] - -[[package]] -name = "authlib" -version = "1.7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "joserfc" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, -] - -[[package]] -name = "bcrypt" -version = "5.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, - { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, - { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, - { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, - { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, - { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, - { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, - { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, - { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, - { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, - { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, - { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, - { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, - { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, - { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, - { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, - { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, - { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, - { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, - { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, - { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, - { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, - { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, - { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, - { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, - { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, - { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, -] - -[[package]] -name = "browserforge" -version = "1.2.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "apify-fingerprint-datapoints" }, - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/6f/8975af88d203efd70cc69477ebac702babef38201d04621c9583f2508f25/browserforge-1.2.4.tar.gz", hash = "sha256:05686473793769856ebd3528c69071f5be0e511260993e8b2ba839863711a0c4", size = 36700, upload-time = "2026-02-03T02:52:09.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/35/ce962f738ae28ffce6293e7607b129075633e6bb185a5ab87e49246eedc2/browserforge-1.2.4-py3-none-any.whl", hash = "sha256:fb1c14e62ac09de221dcfc73074200269f697596c642cb200ceaab1127a17542", size = 37890, upload-time = "2026-02-03T02:52:08.745Z" }, -] - -[[package]] -name = "cachetools" -version = "7.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/e2/85f227594656000ff4d8adadae91a21f536d4a84c6c716a86bd6685874be/cachetools-7.1.1.tar.gz", hash = "sha256:27bdf856d68fd3c71c26c01b5edc312124ed427524d1ddb31aa2b7746fe20d4b", size = 40202, upload-time = "2026-05-03T20:00:29.391Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/0f/f897abe4ea0a8c408ae65c8c83bffab4936ad65d6032d4fb4cd35bbdc3ee/cachetools-7.1.1-py3-none-any.whl", hash = "sha256:0335cd7a0952d2b22327441fb0628139e234c565559eeb91a8a4ac7551c5353d", size = 16775, upload-time = "2026-05-03T20:00:27.857Z" }, -] - -[[package]] -name = "certifi" -version = "2026.4.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "click" -version = "8.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "coverage" -version = "7.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, - { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, - { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, - { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, - { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, - { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, - { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, - { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, - { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, - { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, - { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, - { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, - { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, - { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, - { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, - { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, - { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, - { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, - { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, -] - -[[package]] -name = "cryptography" -version = "48.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, - { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, - { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, - { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, - { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, - { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, - { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, - { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, - { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, - { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, - { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, - { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, - { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, - { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, - { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, - { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, - { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, -] - -[[package]] -name = "cssselect" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/2e/cdfd8b01c37cbf4f9482eefd455853a3cf9c995029a46acd31dfaa9c1dd6/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92", size = 40589, upload-time = "2026-01-29T07:00:26.701Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/0c/7bb51e3acfafd16c48875bf3db03607674df16f5b6ef8d056586af7e2b8b/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8", size = 18540, upload-time = "2026-01-29T07:00:24.994Z" }, -] - -[[package]] -name = "curl-cffi" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "cffi" }, - { name = "rich" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, - { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, - { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, - { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, - { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, - { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723, upload-time = "2026-04-03T11:12:13.668Z" }, - { url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739, upload-time = "2026-04-03T11:12:15.08Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046, upload-time = "2026-04-03T11:12:17.034Z" }, - { url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115, upload-time = "2026-04-03T11:12:19.694Z" }, - { url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346, upload-time = "2026-04-03T11:12:22.151Z" }, - { url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834, upload-time = "2026-04-03T11:12:24.986Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771, upload-time = "2026-04-03T11:12:28.201Z" }, - { url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" }, -] - -[[package]] -name = "fastapi" -version = "0.136.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, -] - -[[package]] -name = "greenlet" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, - { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, - { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, - { url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" }, - { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, - { url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" }, - { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, - { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, - { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, - { url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" }, - { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, - { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, - { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httptools" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "itsdangerous" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, -] - -[[package]] -name = "joserfc" -version = "1.6.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, -] - -[[package]] -name = "librislog-backend" -version = "0.0.0.dev0" -source = { editable = "." } -dependencies = [ - { name = "alembic" }, - { name = "authlib" }, - { name = "browserforge" }, - { name = "cachetools" }, - { name = "cryptography" }, - { name = "curl-cffi" }, - { name = "fastapi" }, - { name = "httpx" }, - { name = "itsdangerous" }, - { name = "passlib", extra = ["bcrypt"] }, - { name = "playwright" }, - { name = "pycountry" }, - { name = "pydantic-settings" }, - { name = "python-multipart" }, - { name = "scrapling" }, - { name = "sqlmodel" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[package.dev-dependencies] -dev = [ - { name = "httpx" }, - { name = "pytest" }, - { name = "pytest-anyio" }, - { name = "pytest-cov" }, - { name = "rich" }, - { name = "typer" }, -] - -[package.metadata] -requires-dist = [ - { name = "alembic", specifier = ">=1.18.4" }, - { name = "authlib", specifier = ">=1.6.5" }, - { name = "browserforge", specifier = ">=1.2.4" }, - { name = "cachetools", specifier = ">=5.3.3" }, - { name = "cryptography", specifier = ">=46.0.3" }, - { name = "curl-cffi", specifier = ">=0.15.0" }, - { name = "fastapi", specifier = ">=0.136.1" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "itsdangerous", specifier = ">=2.2.0" }, - { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, - { name = "playwright", specifier = ">=1.55.0" }, - { name = "pycountry", specifier = ">=24.6.1" }, - { name = "pydantic-settings", specifier = ">=2.14.1" }, - { name = "python-multipart", specifier = ">=0.0.28" }, - { name = "scrapling", specifier = ">=0.4.8" }, - { name = "sqlmodel", specifier = ">=0.0.38" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.46.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "httpx", specifier = ">=0.28.1" }, - { name = "pytest", specifier = ">=9.0.3" }, - { name = "pytest-anyio", specifier = ">=0.0.0" }, - { name = "pytest-cov", specifier = ">=7.1.0" }, - { name = "rich", specifier = ">=13.9.4" }, - { name = "typer", specifier = ">=0.15.2" }, -] - -[[package]] -name = "lxml" -version = "6.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, - { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, - { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, - { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, - { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, - { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, - { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, - { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, - { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, - { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, - { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, - { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, - { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, - { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, - { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, - { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, - { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, - { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, - { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, - { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, - { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, - { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, - { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, - { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, - { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, - { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, -] - -[[package]] -name = "mako" -version = "1.3.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "orjson" -version = "3.11.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, - { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, - { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, - { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, - { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, - { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, - { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, - { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, -] - -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "passlib" -version = "1.7.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, -] - -[package.optional-dependencies] -bcrypt = [ - { name = "bcrypt" }, -] - -[[package]] -name = "playwright" -version = "1.60.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet" }, - { name = "pyee" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" }, - { url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" }, - { url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" }, - { url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" }, - { url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" }, - { url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" }, - { url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" }, - { url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pycountry" -version = "26.2.16" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/1d/061b9e7a48b85cfd69f33c33d2ef784a531c359399ad764243399673c8f5/pycountry-26.2.16.tar.gz", hash = "sha256:5b6027d453fcd6060112b951dd010f01f168b51b4bf8a1f1fc8c95c8d94a0801", size = 7711342, upload-time = "2026-02-17T03:42:52.367Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/42/7703bd45b62fecd44cd7d3495423097e2f7d28bc2e99e7c1af68892ab157/pycountry-26.2.16-py3-none-any.whl", hash = "sha256:115c4baf7cceaa30f59a4694d79483c9167dbce7a9de4d3d571c5f3ea77c305a", size = 8044600, upload-time = "2026-02-17T03:42:49.777Z" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - -[[package]] -name = "pydantic" -version = "2.13.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.46.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, -] - -[[package]] -name = "pydantic-settings" -version = "2.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, -] - -[[package]] -name = "pyee" -version = "13.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, -] - -[[package]] -name = "pytest-anyio" -version = "0.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/44/a02e5877a671b0940f21a7a0d9704c22097b123ed5cdbcca9cab39f17acc/pytest-anyio-0.0.0.tar.gz", hash = "sha256:b41234e9e9ad7ea1dbfefcc1d6891b23d5ef7c9f07ccf804c13a9cc338571fd3", size = 1560, upload-time = "2021-06-29T22:57:30.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/25/bd6493ae85d0a281b6a0f248d0fdb1d9aa2b31f18bcd4a8800cf397d8209/pytest_anyio-0.0.0-py2.py3-none-any.whl", hash = "sha256:dc8b5c4741cb16ff90be37fddd585ca943ed12bbeb563de7ace6cd94441d8746", size = 1999, upload-time = "2021-06-29T22:57:29.158Z" }, -] - -[[package]] -name = "pytest-cov" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coverage" }, - { name = "pluggy" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - -[[package]] -name = "python-multipart" -version = "0.0.28" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "rich" -version = "15.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, -] - -[[package]] -name = "scrapling" -version = "0.4.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cssselect" }, - { name = "lxml" }, - { name = "orjson" }, - { name = "tld" }, - { name = "typing-extensions" }, - { name = "w3lib" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/03/91b75381298493758eac3eb326621e5b04c8510cc96a3b7ad0c86a405db3/scrapling-0.4.8.tar.gz", hash = "sha256:04fc55fffcfb10e099b7d9be385876ae796c23c756e28be4dd79971873bd8e72", size = 157004, upload-time = "2026-05-11T02:00:48.571Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/56/97c0d4e05e9e0c7d712642ddbaf176d723bf5590b29a3b571cf1038cd06b/scrapling-0.4.8-py3-none-any.whl", hash = "sha256:ea6e5f13760740489544cf0f72e69014260e1658d19cf2bc337b82ac91d45782", size = 158559, upload-time = "2026-05-11T02:00:46.704Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "sqlalchemy" -version = "2.0.49" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, - { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, - { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, - { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, - { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, - { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, - { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, - { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, -] - -[[package]] -name = "sqlmodel" -version = "0.0.38" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "sqlalchemy" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/0d/26ec1329960ea9430131fe63f63a95ea4cb8971d49c891ff7e1f3255421c/sqlmodel-0.0.38.tar.gz", hash = "sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b", size = 86710, upload-time = "2026-04-02T21:03:55.571Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/c7/10c60af0607ab6fa136264f7f39d205932218516226d38585324ffda705d/sqlmodel-0.0.38-py3-none-any.whl", hash = "sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649", size = 27294, upload-time = "2026-04-02T21:03:56.406Z" }, -] - -[[package]] -name = "starlette" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, -] - -[[package]] -name = "tld" -version = "0.13.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5d/76b4383ac4e5b5e254e50c09807b3e13820bed6d6c11cd540264988d6802/tld-0.13.2.tar.gz", hash = "sha256:d983fa92b9d717400742fca844e29d5e18271079c7bcfabf66d01b39b4a14345", size = 467175, upload-time = "2026-03-06T23:50:34.498Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/90/39a85a4b63c84213e78b3c17d22e1bf45328acf8ebb33ef93be30d0a3911/tld-0.13.2-py2.py3-none-any.whl", hash = "sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c", size = 296743, upload-time = "2026-03-06T23:50:32.465Z" }, -] - -[[package]] -name = "typer" -version = "0.25.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.46.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - -[[package]] -name = "uvloop" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, -] - -[[package]] -name = "w3lib" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/91/b2eb59c2cf243de5de1e91c963655df78c015509f51297685a8c86a27b8c/w3lib-2.4.1.tar.gz", hash = "sha256:8dd69ee39ff6398d708c793abc779c334a69bac7cee1cdf71736c669ed6be864", size = 48494, upload-time = "2026-03-20T09:50:27.477Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/c3/f8b216cbd742e5b84c40f045204c764ccb7524d2aeab021054ec69446b0a/w3lib-2.4.1-py3-none-any.whl", hash = "sha256:40930132907e68de906a5b89331ab8c8ff4f01bd35b5539ef7896017d814138d", size = 21695, upload-time = "2026-03-20T09:50:26.187Z" }, -] - -[[package]] -name = "watchfiles" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, -] - -[[package]] -name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, -] diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index df5ffb83..6170cfe0 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -3,7 +3,8 @@ name: librislog-e2e services: backend: build: - context: ./backend + context: . + dockerfile: ./backend/Dockerfile args: APP_VERSION: ${APP_VERSION:-v0.0.0-dev} GIT_SHA: ${GIT_SHA:-unknown} diff --git a/docs/guide/cli.md b/docs/guide/cli.md index b931f054..3b5c269d 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -7,8 +7,8 @@ LibrisLog includes a command-line tool for common development tasks. It automate From the repository root: ```bash -cd cli uv sync +cd cli ``` Then run commands with: diff --git a/docs/guide/developer-setup.md b/docs/guide/developer-setup.md index 9f76e08d..fb96b6b3 100644 --- a/docs/guide/developer-setup.md +++ b/docs/guide/developer-setup.md @@ -61,8 +61,8 @@ Requirements: Steps: ```bash -cd backend uv sync +cd backend uv run alembic upgrade head uv run uvicorn app.main:app --reload --port 8000 ``` diff --git a/docs/guide/using-librislog/import-export.md b/docs/guide/using-librislog/import-export.md index 4531abc3..dd5fbe75 100644 --- a/docs/guide/using-librislog/import-export.md +++ b/docs/guide/using-librislog/import-export.md @@ -15,6 +15,7 @@ The most common way to add books is by searching external sources: - **Google Books** (if `GOOGLE_BOOKS_API_KEY` is set — see [API Keys](/guide/api-keys)) - **Hardcover.app** (if `HARDCOVER_APP_API_TOKEN` is set — see [API Keys](/guide/api-keys)) 4. Select a result to import with full metadata and cover +5. Choose an availability value (owned, borrowed, digital access, or to acquire) before saving ### ISBN Barcode Scan @@ -22,10 +23,11 @@ On mobile devices: 1. Tap the scan button in the import dialog 2. Point the camera at an ISBN barcode 3. The app detects the barcode and searches automatically +4. Pick the search result and select an availability value before saving ### Manual Entry -If no search results are found, enter book details manually. All fields are optional except title. +If no search results are found, enter book details manually. Title, author, page count, and availability are required; all other fields are optional. ## Data Export @@ -72,6 +74,8 @@ When importing CSV, map source columns to LibrisLog fields: - Target field shows available LibrisLog properties - Optional transform expressions (Python) for data conversion +`acquisition_status` is required for imports. Map it to one of `owned`, `borrowed`, `digital_access`, or `to_acquire`; use a transform when the source file uses different names. + ### Transform DSL Per-field Python expressions allow data transformation: @@ -108,4 +112,4 @@ Backup and restore are admin-only features. See [Administration](./administratio ## API Access -For programmatic import/export, use the REST API. See the [API documentation](../../api/) for details. \ No newline at end of file +For programmatic import/export, use the REST API. See the [API documentation](../../api/) for details. diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md index 2715c54d..f8a0de29 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -15,6 +15,10 @@ Books are categorized into four statuses: Each status has its own tab in the library view, making it easy to browse your collection by reading state. +## Availability + +Availability is separate from reading status. Choose whether a book is owned, borrowed, available digitally, or still needs to be acquired. In the Want to Read view, books that still need to be acquired show a shopping-cart indicator. Use the availability filter to narrow the list without changing its newest-first order. + ![Library](/screenshots/library-read.png) ## Navigation @@ -101,4 +105,4 @@ Downloaded covers are cached locally in the `COVERS_DIR` directory to avoid repe ## View Modes -Switch between grid view (cover-focused) and list view (compact) using the view toggle. \ No newline at end of file +Switch between grid view (cover-focused) and list view (compact) using the view toggle. diff --git a/docs/guide/using-librislog/statistics.md b/docs/guide/using-librislog/statistics.md index a29d5040..bc934127 100644 --- a/docs/guide/using-librislog/statistics.md +++ b/docs/guide/using-librislog/statistics.md @@ -29,6 +29,14 @@ A stacked bar showing how your library is divided among the four reading statuse - Read (green) - Did Not Finish (red) +### Acquisition Status Distribution + +A stacked bar showing how your library is divided among the four acquisition (ownership) statuses: +- Owned +- Borrowed +- Digital Access +- Needs to be acquired + ### Page Buckets A stacked bar showing: diff --git a/docs/package-lock.json b/docs/package-lock.json index 9fb6e9b5..c046c8ce 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "librislog-docs", "dependencies": { - "viewerjs": "^1.11.7", + "viewerjs": "^1.12.0", "vitepress-plugin-image-viewer": "^1.1.6", "vitepress-plugin-mermaid": "^2.0.17" }, @@ -377,9 +377,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -389,13 +389,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -405,13 +405,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -421,13 +421,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -437,13 +437,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -453,13 +453,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -469,13 +469,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -485,13 +485,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -501,13 +501,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -517,13 +517,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -533,13 +533,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -549,13 +549,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -565,13 +565,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -581,13 +581,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -597,13 +597,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -613,13 +613,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -629,13 +629,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -645,13 +645,29 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -661,13 +677,29 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -677,13 +709,29 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -693,13 +741,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -709,13 +757,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -725,13 +773,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -741,7 +789,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@iconify-json/simple-icons": { @@ -801,13 +849,13 @@ "optional": true }, "node_modules/@mermaid-js/parser": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", - "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", "license": "MIT", "peer": true, "dependencies": { - "@chevrotain/types": "~11.1.1" + "@chevrotain/types": "~11.1.2" } }, "node_modules/@rollup/rollup-android-arm-eabi": { @@ -2532,9 +2580,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", - "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", "license": "(MPL-2.0 OR Apache-2.0)", "peer": true, "optionalDependencies": { @@ -2571,41 +2619,44 @@ ] }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "hasInstallScript": true, "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "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" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/estree-walker": { @@ -2614,6 +2665,16 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/fastdom": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", + "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", + "license": "MIT", + "peer": true, + "dependencies": { + "strictdom": "^1.0.1" + } + }, "node_modules/focus-trap": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", @@ -2835,27 +2896,28 @@ } }, "node_modules/mermaid": { - "version": "11.15.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", - "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", + "version": "11.17.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.0.tgz", + "integrity": "sha512-Jo9N377Wb4MSnHFPTbLi2SxFpsQl4eVHoxnW5U1Md9EazvgMp3s+4ohDxr81YNTgbn5Kj7HJ3yslrSJ52kwpbA==", "license": "MIT", "peer": true, "dependencies": { - "@braintree/sanitize-url": "^7.1.1", + "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.1.1", + "@mermaid-js/parser": "^1.2.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", - "cytoscape": "^3.33.1", + "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", - "dayjs": "^1.11.19", - "dompurify": "^3.3.1", + "dayjs": "^1.11.21", + "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", - "katex": "^0.16.25", + "fastdom": "1.0.12", + "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", @@ -2966,9 +3028,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -3046,9 +3108,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -3065,7 +3127,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3249,6 +3311,13 @@ "node": ">=0.10.0" } }, + "node_modules/strictdom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", + "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", + "license": "MIT", + "peer": true + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -3429,9 +3498,9 @@ } }, "node_modules/viewerjs": { - "version": "1.11.7", - "resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.11.7.tgz", - "integrity": "sha512-0JuVqOmL5v1jmEAlG5EBDR3XquxY8DWFQbFMprOXgaBB0F7Q/X9xWdEaQc59D8xzwkdUgXEMSSknTpriq95igg==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.12.0.tgz", + "integrity": "sha512-eQrV7FvqXj2Ysavd9LINI6bFAvXtw5YbdlMtdIo6Bmrv25hEOBeXoOX9E7cJwYuFAOPFzmppFvDn02QNHB120w==", "license": "MIT" }, "node_modules/vite": { diff --git a/docs/package.json b/docs/package.json index 7cb7a797..5af4294b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -12,8 +12,14 @@ "vitepress": "^1.6.4" }, "dependencies": { - "viewerjs": "^1.11.7", + "viewerjs": "^1.12.0", "vitepress-plugin-image-viewer": "^1.1.6", "vitepress-plugin-mermaid": "^2.0.17" + }, + "overrides": { + "esbuild": "^0.25.0" + }, + "allowScripts": { + "esbuild@0.25.12": true } } diff --git a/frontend/Dockerfile.e2e b/frontend/Dockerfile.e2e index 6cca985b..f5cd63f1 100644 --- a/frontend/Dockerfile.e2e +++ b/frontend/Dockerfile.e2e @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/playwright:v1.60.0-noble +FROM mcr.microsoft.com/playwright:v1.62.1-noble WORKDIR /app/frontend COPY package.json package-lock.json ./ diff --git a/frontend/e2e/fixtures/seed-data.ts b/frontend/e2e/fixtures/seed-data.ts index f536505d..bf5f28a9 100644 --- a/frontend/e2e/fixtures/seed-data.ts +++ b/frontend/e2e/fixtures/seed-data.ts @@ -15,6 +15,7 @@ export interface SeedBook { tags?: string; date_started?: string; date_finished?: string; + acquisition_status?: 'owned' | 'borrowed' | 'digital_access' | 'to_acquire'; } export const SEED_BOOKS: SeedBook[] = [ diff --git a/frontend/e2e/fixtures/seed.api.ts b/frontend/e2e/fixtures/seed.api.ts index 92621910..cfb81987 100644 --- a/frontend/e2e/fixtures/seed.api.ts +++ b/frontend/e2e/fixtures/seed.api.ts @@ -31,7 +31,7 @@ export async function seedBooks(page: Page, books: SeedBook[]): Promise { export async function deleteAllBooks(page: Page): Promise { const resp = await page.request.get(bookApiPath() + '?limit=200'); const body = await resp.json(); - const books: { id: number }[] = body.books; + const books: { id: number }[] = Array.isArray(body?.books) ? body.books : []; for (const book of books) { const csrf = await getCsrfToken(page); await page.request.delete(`${bookApiPath()}/${book.id}`, { diff --git a/frontend/e2e/specs/03-library-browsing.spec.ts b/frontend/e2e/specs/03-library-browsing.spec.ts index 6c68b5aa..d4f4be17 100644 --- a/frontend/e2e/specs/03-library-browsing.spec.ts +++ b/frontend/e2e/specs/03-library-browsing.spec.ts @@ -4,6 +4,20 @@ import { seedBooks, deleteAllBooks } from '../fixtures/seed.api'; import { SEED_USER, SEED_BOOKS } from '../fixtures/seed-data'; import { LibraryPage } from '../fixtures/pages/library.page'; +async function createWantToReadBook( + page: import('@playwright/test').Page, + title: string, + acquisition_status: 'owned' | 'borrowed' | 'digital_access' | 'to_acquire' +) { + const csrf = await page.request.get('/api/auth/csrf'); + const { csrf_token } = await csrf.json(); + const response = await page.request.post('/api/books', { + data: { title, author: 'E2E Author', page_count: 200, reading_status: 'want_to_read', acquisition_status }, + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf_token }, + }); + expect(response.ok()).toBeTruthy(); +} + test.describe('Library Browsing', () => { test.beforeEach(async ({ page }) => { await loginViaUi(page, SEED_USER.email, SEED_USER.password); @@ -52,4 +66,41 @@ test.describe('Library Browsing', () => { const body = page.locator('body'); await expect(body).toContainText(/no books|empty/i); }); + + test('3.5 manual creation requires availability and persists the selected value', async ({ page }) => { + await deleteAllBooks(page); + const library = new LibraryPage(page); + await library.goto(); + + await page.getByRole('button', { name: '+ Add Book' }).click(); + const modal = page.locator('.modal-box'); + const availability = modal.getByRole('combobox', { name: /Availability/ }); + await expect(availability).toHaveValue(''); + + await modal.getByLabel('Title *').fill('Digital E2E Book'); + await modal.getByRole('searchbox', { name: /Author/ }).fill('E2E Author'); + await modal.getByLabel(/Pages/).fill('200'); + await availability.selectOption('digital_access'); + await modal.getByRole('button', { name: 'Add Book' }).click(); + + await expect(page.getByText('Digital E2E Book')).toBeVisible(); + const response = await page.request.get('/api/books?q=Digital%20E2E%20Book'); + expect((await response.json()).books[0].acquisition_status).toBe('digital_access'); + }); + + test('3.6 filters Want to Read books by availability and marks books to acquire', async ({ page }) => { + await deleteAllBooks(page); + await createWantToReadBook(page, 'Owned E2E Book', 'owned'); + await createWantToReadBook(page, 'Acquire E2E Book', 'to_acquire'); + const library = new LibraryPage(page); + await library.goto(); + + await expect(page.getByText('Owned E2E Book')).toBeVisible(); + await expect(page.getByText('Acquire E2E Book')).toBeVisible(); + await expect(page.locator('span[aria-label="Needs to be acquired"]')).toBeVisible(); + + await page.locator('select[name="acquisition_filter"]').selectOption('to_acquire'); + await expect(page.getByText('Acquire E2E Book')).toBeVisible(); + await expect(page.getByText('Owned E2E Book')).not.toBeVisible(); + }); }); diff --git a/frontend/e2e/specs/09-data-import.spec.ts b/frontend/e2e/specs/09-data-import.spec.ts index 62d12d76..6364d8f5 100644 --- a/frontend/e2e/specs/09-data-import.spec.ts +++ b/frontend/e2e/specs/09-data-import.spec.ts @@ -1,11 +1,16 @@ import { test, expect } from '@playwright/test'; import { loginViaUi } from '../fixtures/auth.fixture'; +import { deleteAllBooks } from '../fixtures/seed.api'; import { SEED_USER } from '../fixtures/seed-data'; test.describe('Data Import', () => { - const CSV = `title,author,isbn,pages,status -"The Imported Book","Import Author","1234567890",300,want_to_read -"Second Imported","Another Author","9876543210",250,want_to_read`; + const CSV = `title,author,isbn,pages,status,availability +"The Imported Book","Import Author","1234567890",300,want_to_read,owned +"Second Imported","Another Author","9876543210",250,want_to_read,to_acquire`; + + const CSV_ACQUISITION = `title,author,isbn,pages,status,availability +"Acquisition Test A","Author A","1111111111",300,want_to_read,owned +"Acquisition Test B","Author B","2222222222",250,want_to_read,to_acquire`; test.beforeEach(async ({ page }) => { await loginViaUi(page, SEED_USER.email, SEED_USER.password); @@ -38,6 +43,7 @@ test.describe('Data Import', () => { await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); + await page.locator('select[name="mapping-target-acquisition_status"]').selectOption('availability'); await page.locator('textarea[name="mapping-transform-title"]').fill('value.strip().upper()'); @@ -73,7 +79,7 @@ test.describe('Data Import', () => { } }); - test('9.3 transform syntax error shown in preview', async ({ page }) => { + test('9.3 transform syntax error shown in preview', async ({ page }) => { await page.goto('/data?tab=import'); await page.waitForTimeout(1000); @@ -93,6 +99,7 @@ test.describe('Data Import', () => { await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); + await page.locator('select[name="mapping-target-acquisition_status"]').selectOption('availability'); await page.locator('textarea[name="mapping-transform-title"]').fill('value.upper('); @@ -102,4 +109,75 @@ test.describe('Data Import', () => { const body = page.locator('body'); await expect(body).toContainText(/error|invalid|syntax/i, { timeout: 10000 }); }); + + test('9.4 import validation rejects missing acquisition_status mapping', async ({ page }) => { + await page.goto('/data?tab=import'); + await page.waitForTimeout(1000); + + await page.locator('input[type="file"]').setInputFiles({ + name: 'test-books.csv', + mimeType: 'text/csv', + buffer: Buffer.from(CSV), + }); + + await page.locator('button').filter({ hasText: 'Parse file' }).click(); + await page.waitForTimeout(2000); + + await page.locator('select[name="mapping-target-title"]').selectOption('title'); + await page.locator('select[name="mapping-target-author"]').selectOption('author'); + await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); + await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); + await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); + // intentionally omit acquisition_status mapping + + await page.locator('button').filter({ hasText: 'Simulate' }).click(); + await page.waitForTimeout(2000); + + const body = page.locator('body'); + await expect(body).toContainText(/acquisition_status/i, { timeout: 10000 }); + await expect(body).toContainText(/required/i, { timeout: 10000 }); + }); + + test('9.5 imported books retain their acquisition status', async ({ page }) => { + await deleteAllBooks(page); + await page.goto('/data?tab=import'); + await page.waitForTimeout(1000); + + await page.locator('input[type="file"]').setInputFiles({ + name: 'test-books.csv', + mimeType: 'text/csv', + buffer: Buffer.from(CSV_ACQUISITION), + }); + + await page.locator('button').filter({ hasText: 'Parse file' }).click(); + await page.waitForTimeout(2000); + + await page.locator('select[name="mapping-target-title"]').selectOption('title'); + await page.locator('select[name="mapping-target-author"]').selectOption('author'); + await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); + await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); + await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); + await page.locator('select[name="mapping-target-acquisition_status"]').selectOption('availability'); + + await page.locator('button').filter({ hasText: 'Generate' }).click(); + await page.waitForTimeout(2000); + + await page.locator('button').filter({ hasText: 'Simulate' }).click(); + await page.waitForTimeout(2000); + + const body = page.locator('body'); + await expect(body).toContainText('Validation passed.', { timeout: 10000 }); + + await page.locator('button.btn-secondary.btn-sm').filter({ hasText: 'Import now' }).click(); + await page.locator('dialog.modal-open .btn-secondary').filter({ hasText: 'Import now' }).waitFor({ state: 'visible', timeout: 5000 }); + await page.locator('dialog.modal-open .btn-secondary').filter({ hasText: 'Import now' }).click(); + await page.waitForTimeout(2000); + + await expect(body).toContainText(/Import complete/i, { timeout: 10000 }); + + const response = await page.request.get('/api/books?q=Acquisition%20Test%20B'); + const books = (await response.json()).books; + expect(books).toHaveLength(1); + expect(books[0].acquisition_status).toBe('to_acquire'); + }); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5b111906..1ee2502a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,10 +29,10 @@ "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", - "@testing-library/jest-dom": "^6.9.1", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/svelte": "^5.3.1", "@types/hammerjs": "^2.0.46", - "@types/node": "^25.7.0", + "@types/node": "^26.2.0", "@vitest/coverage-v8": "^4.1.7", "happy-dom": "^20.9.0", "svelte": "^5.55.2", @@ -43,20 +43,20 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", "dev": true, "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -65,9 +65,9 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -75,9 +75,9 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -85,13 +85,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -101,9 +101,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "dev": true, "license": "MIT", "engines": { @@ -111,14 +111,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -134,41 +134,10 @@ "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@fontsource/inter": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", - "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -277,55 +246,37 @@ "license": "MIT" }, "node_modules/@lucide/svelte": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.16.0.tgz", - "integrity": "sha512-AvvPJnaWxeiNkAljI5MsSEc84yHPLMaWQIAJOcbX7k9au/f9ITS7cxTTQiautDiOFKVOXiYdZ+d6mtl88J+Kbg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.33.0.tgz", + "integrity": "sha512-b+osTYG2V4dge5Lr7tnaTaahhy/4vH7Xb8zcqVjmctjwgkpXS0NNOJPkGzHHZoxtw/OO+2YAU28omeMfYCaawg==", "license": "ISC", "peerDependencies": { "svelte": "^5" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.128.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", - "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@playwright/test": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", - "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.60.0" + "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@polka/url": { @@ -335,10 +286,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", - "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", "cpu": [ "arm64" ], @@ -352,9 +319,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", - "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", "cpu": [ "arm64" ], @@ -368,9 +335,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", - "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", "cpu": [ "x64" ], @@ -384,9 +351,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", - "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", "cpu": [ "x64" ], @@ -400,9 +367,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", - "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", "cpu": [ "arm" ], @@ -416,9 +383,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", - "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", "cpu": [ "arm64" ], @@ -435,9 +402,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", - "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", "cpu": [ "arm64" ], @@ -454,9 +421,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", - "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", "cpu": [ "ppc64" ], @@ -473,9 +440,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", - "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", "cpu": [ "s390x" ], @@ -492,9 +459,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", - "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", "cpu": [ "x64" ], @@ -511,9 +478,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", - "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", "cpu": [ "x64" ], @@ -530,9 +497,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", - "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", "cpu": [ "arm64" ], @@ -545,28 +512,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", - "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", - "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", "cpu": [ "arm64" ], @@ -580,9 +529,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", - "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", "cpu": [ "x64" ], @@ -596,9 +545,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", - "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, "node_modules/@standard-schema/spec": { @@ -609,9 +558,9 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", - "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -638,18 +587,18 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.59.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz", - "integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==", + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", - "acorn": "^8.14.1", + "acorn": "^8.16.0", "cookie": "^0.6.0", - "devalue": "^5.6.4", + "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", @@ -679,15 +628,25 @@ } } }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", - "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz", + "integrity": "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==", "dev": true, "license": "MIT", "dependencies": { "deepmerge": "^4.3.1", - "magic-string": "^0.30.21", + "magic-string": "^1.0.0", "obug": "^2.1.0", "vitefu": "^1.1.2" }, @@ -699,48 +658,58 @@ "vite": "^8.0.0-beta.7 || ^8.0.0" } }, + "node_modules/@sveltejs/vite-plugin-svelte/node_modules/magic-string": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.2.tgz", + "integrity": "sha512-veT/+7iXrXzT39XnEN4lOxtNl72dMgJ8Lp+5Bd6YcMSWpb0n0MjBM8Uuooi6jgJr8dhUW2swQgBmoZVMni5SVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -754,9 +723,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -770,9 +739,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -786,9 +755,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -802,9 +771,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -818,9 +787,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -837,9 +806,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -856,9 +825,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -875,9 +844,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -894,9 +863,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -911,11 +880,11 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { @@ -923,9 +892,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -939,9 +908,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -955,14 +924,14 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", - "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "tailwindcss": "4.3.0" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -1006,9 +975,9 @@ "license": "MIT" }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1020,20 +989,29 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } } }, "node_modules/@testing-library/svelte": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", - "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.4.2.tgz", + "integrity": "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==", "dev": true, "license": "MIT", "dependencies": { "@testing-library/dom": "9.x.x || 10.x.x", - "@testing-library/svelte-core": "1.0.0" + "@testing-library/svelte-core": "1.1.3" }, "engines": { "node": ">= 10" @@ -1053,9 +1031,9 @@ } }, "node_modules/@testing-library/svelte-core": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", - "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", + "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", "dev": true, "license": "MIT", "engines": { @@ -1065,16 +1043,6 @@ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1120,13 +1088,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", - "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.21.0" + "undici-types": "~8.3.0" } }, "node_modules/@types/trusted-types": { @@ -1153,14 +1121,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", - "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1174,8 +1142,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.7", - "vitest": "4.1.7" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1184,16 +1152,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1202,13 +1170,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1228,20 +1196,10 @@ } } }, - "node_modules/@vitest/mocker/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/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1252,13 +1210,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1266,14 +1224,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1282,9 +1240,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -1292,13 +1250,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1307,9 +1265,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1348,9 +1306,10 @@ } }, "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1367,9 +1326,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", "dev": true, "license": "MIT", "dependencies": { @@ -1378,16 +1337,6 @@ "js-tokens": "^10.0.0" } }, - "node_modules/ast-v8-to-istanbul/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/ast-v8-to-istanbul/node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -1404,6 +1353,19 @@ "node": ">= 0.4" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1427,9 +1389,9 @@ } }, "node_modules/chartjs-chart-matrix": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/chartjs-chart-matrix/-/chartjs-chart-matrix-3.0.4.tgz", - "integrity": "sha512-thkswkjZEtmZph+JUU65GjSxfAIKkLedVAhKz6umIs8zO+y+gHIuzovEtS1FqRXzubMXCX2RcglbQjHsL8g0Xw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/chartjs-chart-matrix/-/chartjs-chart-matrix-3.0.5.tgz", + "integrity": "sha512-hVqXBEtLoYJk+iA9NajA/ArG7HlPZl3XXNsRDY3c5sCuqSrM6uTythSJh1jVR7UNVApdY9PStb84xSEjEceKaw==", "license": "MIT", "peerDependencies": { "chart.js": ">=3.0.0" @@ -1497,9 +1459,9 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true, "license": "MIT", "engines": { @@ -1527,18 +1489,18 @@ } }, "node_modules/daisyui": { - "version": "5.5.19", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", - "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", + "version": "5.7.20", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.20.tgz", + "integrity": "sha512-qoL9qXXo/K/MzcteD1SvZOSeBaL8F9qBJvwX3KEpiVHQLzIEtGkNl/ZznSI7J0d+qnQJa2dAAFzTFDAx9df1rw==", "license": "MIT", "funding": { "url": "https://github.com/saadeghi/daisyui?sponsor=1" } }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "license": "MIT" }, "node_modules/decimal.js": { @@ -1576,9 +1538,9 @@ } }, "node_modules/devalue": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz", - "integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==", + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.1.tgz", + "integrity": "sha512-+17vil3EVQRzvtDJSFuTWEb8XJRvXqAiV3qZyQWD398QeXUa6CxsUyMdD1fxzEhUrd4FojitFz7lhIHBTlV4fw==", "license": "MIT" }, "node_modules/dom-accessibility-api": { @@ -1589,9 +1551,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.21.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", - "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -1615,9 +1577,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -1695,9 +1657,9 @@ } }, "node_modules/esrap": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.6.tgz", - "integrity": "sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow==", + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.6.tgz", + "integrity": "sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -1712,10 +1674,14 @@ } }, "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" + "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/event-emitter": { "version": "0.3.5", @@ -1728,9 +1694,9 @@ } }, "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==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1764,9 +1730,10 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1805,18 +1772,19 @@ } }, "node_modules/happy-dom": { - "version": "20.9.0", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz", - "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", + "version": "20.11.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.6.tgz", + "integrity": "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==", "dev": true, "license": "MIT", "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", - "ws": "^8.18.3" + "ws": "^8.21.0" }, "engines": { "node": ">=20.0.0" @@ -2243,14 +2211,14 @@ } }, "node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, @@ -2319,9 +2287,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -2343,15 +2311,18 @@ "license": "ISC" }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/pathe": { "version": "2.0.3", @@ -2367,9 +2338,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -2379,56 +2350,41 @@ } }, "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=20" } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -2445,7 +2401,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2504,13 +2460,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", - "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.128.0", - "@rolldown/pluginutils": "1.0.0-rc.18" + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -2519,21 +2475,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.18", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", - "@rolldown/binding-darwin-x64": "1.0.0-rc.18", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" } }, "node_modules/sade": { @@ -2549,9 +2505,9 @@ } }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -2562,9 +2518,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", - "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", "dev": true, "license": "MIT" }, @@ -2607,9 +2563,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -2640,23 +2596,23 @@ } }, "node_modules/svelte": { - "version": "5.55.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", - "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==", + "version": "5.56.10", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.10.tgz", + "integrity": "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.4", + "devalue": "^5.8.1", "esm-env": "^1.2.1", - "esrap": "^2.2.4", + "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -2677,13 +2633,14 @@ } }, "node_modules/svelte-check": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz", - "integrity": "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==", + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", @@ -2697,7 +2654,7 @@ }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": ">=5.0.0" + "typescript": "^5.0.0 || ^6.0.0" } }, "node_modules/svelte-i18n": { @@ -2725,9 +2682,9 @@ } }, "node_modules/svelte-i18n/node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -2737,13 +2694,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -2753,13 +2710,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -2769,13 +2726,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -2785,13 +2742,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -2801,13 +2758,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -2817,13 +2774,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -2833,13 +2790,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -2849,13 +2806,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -2865,13 +2822,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -2881,13 +2838,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -2897,13 +2854,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -2913,13 +2870,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -2929,13 +2886,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -2945,13 +2902,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -2961,13 +2918,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -2977,13 +2934,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -2993,13 +2950,29 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -3009,13 +2982,29 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -3025,13 +3014,29 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/svelte-i18n/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -3041,13 +3046,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -3057,13 +3062,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -3073,13 +3078,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -3089,51 +3094,69 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/svelte-i18n/node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "hasInstallScript": true, "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/svelte-i18n/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/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" } }, "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "license": "MIT" }, "node_modules/tapable": { @@ -3180,9 +3203,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { @@ -3190,9 +3213,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3206,9 +3229,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -3252,23 +3275,23 @@ } }, "node_modules/undici-types": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", - "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "devOptional": true, "license": "MIT" }, "node_modules/vite": { - "version": "8.0.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", - "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.0-rc.18", - "tinyglobby": "^0.2.16" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -3284,7 +3307,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -3335,6 +3358,281 @@ } } }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/vitefu": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", @@ -3356,19 +3654,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -3396,12 +3694,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -3473,9 +3771,9 @@ } }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/package.json b/frontend/package.json index dbf609d7..16116bc9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,10 +21,10 @@ "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", - "@testing-library/jest-dom": "^6.9.1", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/svelte": "^5.3.1", "@types/hammerjs": "^2.0.46", - "@types/node": "^25.7.0", + "@types/node": "^26.2.0", "@vitest/coverage-v8": "^4.1.7", "happy-dom": "^20.9.0", "svelte": "^5.55.2", @@ -48,5 +48,14 @@ "svelte-chartjs": "^4.0.1", "svelte-i18n": "^4.0.1", "tailwindcss": "^4.3.0" + }, + "overrides": { + "cookie": "^0.7.2", + "svelte-i18n": { + "esbuild": "^0.25.0" + } + }, + "allowScripts": { + "esbuild@0.25.12": true } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 53c89443..b6d25b04 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -31,6 +31,7 @@ import type { StatusTransitionResponse, ImportSearchMode, ReadingStatus, + AcquisitionStatus, SearchStage, TagCloudEntry, SortField, @@ -329,6 +330,7 @@ export const api = { list(params?: { status?: ReadingStatus; + acquisition_status?: AcquisitionStatus; q?: string; has_cover?: boolean; sort?: SortField; @@ -339,6 +341,7 @@ export const api = { }): Promise { const qs = new URLSearchParams(); if (params?.status) qs.set('status', params.status); + if (params?.acquisition_status) qs.set('acquisition_status', params.acquisition_status); if (params?.q) qs.set('q', params.q); if (params?.has_cover !== undefined) qs.set('has_cover', String(params.has_cover)); if (params?.sort) qs.set('sort', params.sort); @@ -465,10 +468,10 @@ export const api = { ); }, - importBook(candidate: BookImportCandidate, status: ReadingStatus = 'want_to_read'): Promise { + importBook(candidate: BookImportCandidate, status: ReadingStatus, acquisitionStatus: AcquisitionStatus): Promise { return request('/import', { method: 'POST', - body: JSON.stringify({ candidate, reading_status: status }) + body: JSON.stringify({ candidate, reading_status: status, acquisition_status: acquisitionStatus }) }); }, diff --git a/frontend/src/lib/chartjs/register.ts b/frontend/src/lib/chartjs/register.ts index c28b11e0..997e11bc 100644 --- a/frontend/src/lib/chartjs/register.ts +++ b/frontend/src/lib/chartjs/register.ts @@ -31,13 +31,16 @@ Chart.register( import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import advancedFormat from 'dayjs/plugin/advancedFormat'; import localizedFormat from 'dayjs/plugin/localizedFormat'; import quarterOfYear from 'dayjs/plugin/quarterOfYear'; import weekday from 'dayjs/plugin/weekday'; +import { getTimezone } from '$lib/stores/timezone'; dayjs.extend(utc); +dayjs.extend(timezone); dayjs.extend(customParseFormat); dayjs.extend(advancedFormat); dayjs.extend(localizedFormat); @@ -69,16 +72,17 @@ _adapters._date.override({ const d = dayjs.utc(value as string | number | Date); return d.isValid() ? d.valueOf() : null; }, - format: (time: unknown, format: string) => dayjs.utc(time as number).format(format), - add: (time: unknown, amount: number, unit: string) => dayjs.utc(time as number).add(amount, unit as dayjs.ManipulateType).valueOf(), - diff: (max: unknown, min: unknown, unit: string) => dayjs.utc(max as number).diff(dayjs.utc(min as number), unit as dayjs.OpUnitType), + format: (time: unknown, format: string) => dayjs.utc(time as number).tz(getTimezone()).format(format), + add: (time: unknown, amount: number, unit: string) => dayjs.utc(time as number).tz(getTimezone()).add(amount, unit as dayjs.ManipulateType).valueOf(), + diff: (max: unknown, min: unknown, unit: string) => dayjs.utc(max as number).tz(getTimezone()).diff(dayjs.utc(min as number).tz(getTimezone()), unit as dayjs.OpUnitType), startOf: (time: unknown, unit: string, weekday?: number) => { + const date = dayjs.utc(time as number).tz(getTimezone()); if (unit === 'isoWeek') { - return (dayjs.utc(time as number) as unknown as { weekday: (w: number) => { valueOf: () => number } }).weekday(weekday ?? 1).valueOf(); + return (date as unknown as { weekday: (w: number) => { valueOf: () => number } }).weekday(weekday ?? 1).valueOf(); } - return dayjs.utc(time as number).startOf(unit as dayjs.OpUnitType).valueOf(); + return date.startOf(unit as dayjs.OpUnitType).valueOf(); }, - endOf: (time: unknown, unit: string) => dayjs.utc(time as number).endOf(unit as dayjs.OpUnitType).valueOf(), + endOf: (time: unknown, unit: string) => dayjs.utc(time as number).tz(getTimezone()).endOf(unit as dayjs.OpUnitType).valueOf(), } as unknown as Parameters[0]); export { Chart as ChartJS }; diff --git a/frontend/src/lib/components/AddBookModal.svelte b/frontend/src/lib/components/AddBookModal.svelte index 3fd7c446..e2a67784 100644 --- a/frontend/src/lib/components/AddBookModal.svelte +++ b/frontend/src/lib/components/AddBookModal.svelte @@ -1,5 +1,5 @@ {#if open} @@ -201,6 +211,15 @@ {/each} +