diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e41af7fb..083f2e9b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -16,22 +16,11 @@ env: REGISTRY: ghcr.io jobs: - build-and-push: + prepare-tags-and-names: runs-on: ubuntu-latest - strategy: - matrix: - service: - - name: frontend - image: librislog - dockerfile: ./frontend/Dockerfile - context: ./frontend - - name: backend - image: librislog-api - dockerfile: ./backend/Dockerfile - context: ./backend + permissions: contents: read - packages: write steps: - name: Checkout repository @@ -59,6 +48,54 @@ jobs: SANITIZED="$(echo "$SANITIZED" | sed 's/^[^a-zA-Z0-9_]\+//')" echo "sanitized_tag=${SANITIZED:0:128}" >> "$GITHUB_OUTPUT" + - name: Normalize repository name + id: repo + uses: actions/github-script@v7 + with: + result-encoding: string + script: return `${context.repo.owner}/${context.repo.repo}`.toLowerCase() + + outputs: + version: ${{ steps.version.outputs.version }} + sha_short: ${{ steps.version.outputs.sha_short }} + sanitized_tag: ${{ steps.sanitize.outputs.sanitized_tag }} + repository: ${{ steps.repo.outputs.result }} + + build-and-push: + runs-on: ${{ matrix.runner }} + needs: prepare-tags-and-names + strategy: + fail-fast: false + matrix: + service: + - name: frontend + image: librislog + dockerfile: ./frontend/Dockerfile + context: ./frontend + - name: backend + image: librislog-api + dockerfile: ./backend/Dockerfile + context: ./backend + + arch: [amd64, arm64] + + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.branch || github.ref }} + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -72,14 +109,14 @@ jobs: - name: Generate image tags id: tags run: | - IMAGE="${{ env.REGISTRY }}/${{ github.repository }}/${{ matrix.service.image }}" - SHA_SHORT="${{ steps.version.outputs.sha_short }}" + IMAGE="${{ env.REGISTRY }}/${{ needs.prepare-tags-and-names.outputs.repository }}/${{ matrix.service.image }}" + SHA_SHORT="${{ needs.prepare-tags-and-names.outputs.sha_short }}" if [ "${{ github.event_name }}" = "release" ]; then - SANITIZED="${{ steps.sanitize.outputs.sanitized_tag }}" - TAGS="${IMAGE}:${SANITIZED},${IMAGE}:latest" + SANITIZED="${{ needs.prepare-tags-and-names.outputs.sanitized_tag }}" + TAGS="${IMAGE}:${SANITIZED}-${{ matrix.arch }},${IMAGE}:latest-${{ matrix.arch }}" else - TAGS="${IMAGE}:develop,${IMAGE}:${SHA_SHORT}" + TAGS="${IMAGE}:develop-${{ matrix.arch }},${IMAGE}:${SHA_SHORT}-${{ matrix.arch }}" fi echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" @@ -90,11 +127,51 @@ jobs: context: ${{ matrix.service.context }} file: ${{ matrix.service.dockerfile }} push: true - platforms: linux/amd64 + platforms: linux/${{ matrix.arch }} tags: ${{ steps.tags.outputs.tags }} build-args: | - APP_VERSION=${{ steps.version.outputs.version }} + APP_VERSION=${{ needs.prepare-tags-and-names.outputs.version }} GIT_SHA=${{ github.sha }} ${{ matrix.service.name == 'frontend' && 'PUBLIC_DEFAULT_LOCALE=en' || '' }} cache-from: type=gha cache-to: type=gha,mode=max + + create-manifest: + needs: [prepare-tags-and-names, build-and-push] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + service: + - name: frontend + image: librislog + - name: backend + image: librislog-api + + permissions: + packages: write + + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create multi-arch manifest + run: | + IMAGE="${{ env.REGISTRY }}/${{ needs.prepare-tags-and-names.outputs.repository }}/${{ matrix.service.image }}" + SHA_SHORT="${{ needs.prepare-tags-and-names.outputs.sha_short }}" + + if [ "${{ github.event_name }}" = "release" ]; then + SANITIZED="${{ needs.prepare-tags-and-names.outputs.sanitized_tag }}" + docker buildx imagetools create -t "${IMAGE}:${SANITIZED}" "${IMAGE}:${SANITIZED}-amd64" "${IMAGE}:${SANITIZED}-arm64" + docker buildx imagetools create -t "${IMAGE}:latest" "${IMAGE}:latest-amd64" "${IMAGE}:latest-arm64" + else + docker buildx imagetools create -t "${IMAGE}:develop" "${IMAGE}:develop-amd64" "${IMAGE}:develop-arm64" + docker buildx imagetools create -t "${IMAGE}:${SHA_SHORT}" "${IMAGE}:${SHA_SHORT}-amd64" "${IMAGE}:${SHA_SHORT}-arm64" + fi diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2b9ae567..df927594 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,6 +12,7 @@ concurrency: env: NODE_VERSION: "22" + PYTHON_VERSION: "3.14" jobs: deploy: @@ -66,6 +67,16 @@ jobs: release/docs/package-lock.json develop/docs/package-lock.json + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Generate DB layout docs (release) + if: steps.check-release-docs.outputs.has_docs == 'true' + working-directory: release/docs + run: npm run docs:gen-db 2>/dev/null || echo "Skipped — docs:gen-db not in this release" + - name: Build release docs if: steps.check-release-docs.outputs.has_docs == 'true' working-directory: release/docs @@ -74,6 +85,10 @@ jobs: npx vitepress build mv .vitepress/dist /tmp/dist-release + - name: Generate DB layout docs (nightly) + working-directory: develop/docs + run: npm run docs:gen-db + - name: Build nightly docs working-directory: develop/docs run: | diff --git a/backend/alembic/versions/1a2b3c4d5e6f_add_embed_token_table.py b/backend/alembic/versions/1a2b3c4d5e6f_add_embed_token_table.py new file mode 100644 index 00000000..32ac7fed --- /dev/null +++ b/backend/alembic/versions/1a2b3c4d5e6f_add_embed_token_table.py @@ -0,0 +1,52 @@ +"""add embed_token table for scoped embed tokens + +Revision ID: 1a2b3c4d5e6f +Revises: f7e9d1c3b5a2 +Create Date: 2026-06-13 23:25:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine.reflection import Inspector + + +revision: str = "1a2b3c4d5e6f" +down_revision: Union[str, Sequence[str], None] = "f7e9d1c3b5a2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + tables = inspector.get_table_names() + if "embed_token" not in tables: + op.create_table( + "embed_token", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("token_prefix", sa.String(), nullable=False), + sa.Column("token_hash", sa.String(), nullable=False), + sa.Column("scopes", sa.String(), nullable=False, server_default="embed:stats:read"), + sa.Column("allowed_origins", sa.String(), nullable=True), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("last_used_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], name="fk_embed_token_user_id"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token_hash", name="uq_embed_token_token_hash"), + ) + op.create_index(op.f("ix_embed_token_token_prefix"), "embed_token", ["token_prefix"]) + op.create_index(op.f("ix_embed_token_user_id"), "embed_token", ["user_id"]) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + tables = inspector.get_table_names() + if "embed_token" in tables: + op.drop_table("embed_token") diff --git a/backend/app/auth.py b/backend/app/auth.py index 45905055..2a5a39a3 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -115,6 +115,31 @@ def get_api_key_prefix(api_key: str) -> str: return api_key[:12] +# --- Embed token utilities --- + +EMBED_TOKEN_PREFIX = "le_" +EMBED_TOKEN_SCOPE_STATS_READ = "embed:stats:read" + + +def generate_embed_token() -> str: + """Generate a new random embed token prefixed with 'le_'.""" + return f"{EMBED_TOKEN_PREFIX}{secrets.token_urlsafe(32)}" + + +def hash_embed_token(value: str) -> str: + """Return a HMAC-SHA256 hex digest of the embed token.""" + return hmac.new( + settings.api_key_encryption_key.encode("utf-8"), + value.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +def get_embed_token_prefix(token: str) -> str: + """Return the first 12 characters of the embed token (visible prefix).""" + return token[:12] + + api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) diff --git a/backend/app/config.py b/backend/app/config.py index ce51d515..708d4900 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -41,6 +41,7 @@ class Settings(BaseSettings): cover_import_timeout_seconds: int = 15 hardcover_app_api_token: str = "" thalia_cover_search_enabled: bool = False + embed_enabled: bool = True forwarded_allow_ips: str = "*" @field_validator("api_key_encryption_key") diff --git a/backend/app/i18n/de.json b/backend/app/i18n/de.json new file mode 100644 index 00000000..8bf72d56 --- /dev/null +++ b/backend/app/i18n/de.json @@ -0,0 +1,12 @@ +{ + "embed": { + "stats": { + "books": "Bücher", + "reading": "Lese ich gerade", + "read": "Gelesen", + "to_read": "Möchte ich lesen", + "pages": "Seiten", + "avg_pages": "Seiten/Buch" + } + } +} diff --git a/backend/app/i18n/en.json b/backend/app/i18n/en.json new file mode 100644 index 00000000..12651e10 --- /dev/null +++ b/backend/app/i18n/en.json @@ -0,0 +1,12 @@ +{ + "embed": { + "stats": { + "books": "Books", + "reading": "Reading", + "read": "Read", + "to_read": "To Read", + "pages": "Pages", + "avg_pages": "Avg/Book" + } + } +} diff --git a/backend/app/i18n/es.json b/backend/app/i18n/es.json new file mode 100644 index 00000000..87048454 --- /dev/null +++ b/backend/app/i18n/es.json @@ -0,0 +1,12 @@ +{ + "embed": { + "stats": { + "books": "Libros", + "reading": "Leyendo", + "read": "Leído", + "to_read": "Quiero leer", + "pages": "Páginas", + "avg_pages": "Páginas/Libro" + } + } +} diff --git a/backend/app/i18n/fr.json b/backend/app/i18n/fr.json new file mode 100644 index 00000000..21bb30b2 --- /dev/null +++ b/backend/app/i18n/fr.json @@ -0,0 +1,12 @@ +{ + "embed": { + "stats": { + "books": "Livres", + "reading": "En cours", + "read": "Lu", + "to_read": "À lire", + "pages": "Pages", + "avg_pages": "Pages/Livre" + } + } +} diff --git a/backend/app/i18n/zh.json b/backend/app/i18n/zh.json new file mode 100644 index 00000000..c757466a --- /dev/null +++ b/backend/app/i18n/zh.json @@ -0,0 +1,12 @@ +{ + "embed": { + "stats": { + "books": "图书", + "reading": "正在阅读", + "read": "已读", + "to_read": "待读", + "pages": "页数", + "avg_pages": "每本页数" + } + } +} diff --git a/backend/app/main.py b/backend/app/main.py index 01c2fb1e..5674e95a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from app._build_info import __git_sha__, __version__ from app.config import settings from app.logging_config import configure_logging -from app.routers import admin, auth, books, cover_candidates, covers, data, docs, health, hygiene, import_, oidc, profile, progress, statistics, users +from app.routers import admin, auth, books, config, cover_candidates, covers, data, docs, embed, health, hygiene, import_, oidc, profile, progress, statistics, users from app.services.cover_storage import cleanup_orphan_covers from app.services.data_import import cleanup_temp_files @@ -164,4 +164,7 @@ async def proxy_headers_middleware(request: Request, call_next) -> Response: app.include_router(hygiene.router) app.include_router(statistics.router) app.include_router(data.router) +app.include_router(config.router) +if settings.embed_enabled: + app.include_router(embed.router) app.include_router(admin.router) diff --git a/backend/app/models.py b/backend/app/models.py index 3ea21987..6668665f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -205,6 +205,36 @@ class OidcLink(SQLModel, table=True): ) +class EmbedToken(SQLModel, table=True): + """A scoped embed token for iframe/dashboard integrations.""" + + __tablename__ = "embed_token" + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id", index=True) + name: str = Field(max_length=255) + token_prefix: str = Field(index=True) + token_hash: str = Field(index=True, unique=True) + scopes: str = Field(default="embed:stats:read") # comma-separated list + allowed_origins: Optional[str] = None # comma-separated; null = wildcard + expires_at: Optional[datetime] = Field( + default=None, + sa_column=Column(UtcDateTime, default=None) + ) + last_used_at: Optional[datetime] = Field( + default=None, + sa_column=Column(UtcDateTime, default=None) + ) + created_at: datetime = Field( + default_factory=utcnow, + sa_column=Column(UtcDateTime, default=utcnow) + ) + revoked_at: Optional[datetime] = Field( + default=None, + sa_column=Column(UtcDateTime, default=None) + ) + + class ImportMapping(SQLModel, table=True): """A saved column-mapping configuration for data import.""" diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py new file mode 100644 index 00000000..ca6aae93 --- /dev/null +++ b/backend/app/routers/config.py @@ -0,0 +1,18 @@ +"""Application-level config endpoint for frontend feature flags.""" + +from fastapi import APIRouter + +from app.config import settings +from app.schemas import AppConfigRead + +router = APIRouter(prefix="/api", tags=["config"]) + + +@router.get("/config", response_model=AppConfigRead) +def app_config() -> AppConfigRead: + """Return application-level feature flags.""" + return AppConfigRead( + embed_enabled=settings.embed_enabled, + dashboard_quote_enabled=settings.dashboard_quote_enabled, + thalia_cover_search_enabled=settings.thalia_cover_search_enabled, + ) diff --git a/backend/app/routers/docs.py b/backend/app/routers/docs.py index 5c505c48..86bb2b85 100644 --- a/backend/app/routers/docs.py +++ b/backend/app/routers/docs.py @@ -80,35 +80,49 @@ def _wrap_docs_html(html: str) -> HTMLResponse: return HTMLResponse(html.replace("", f"{custom_css}")) +def _get_openapi_url(request: Request) -> str: + """Return the OpenAPI URL relative to the current request, respecting root_path.""" + root_path = request.scope.get("root_path", "") + return f"{root_path}/api/openapi.json" + + @router.get("/docs", include_in_schema=False) def redirect_to_custom_swagger_docs(request: Request) -> RedirectResponse: """Redirect to the custom-themed Swagger UI page.""" - return RedirectResponse(url="/api/docs") + root_path = request.scope.get("root_path", "") + return RedirectResponse(url=f"{root_path}/api/docs") @router.get("/api/docs", include_in_schema=False) def custom_swagger_docs(request: Request) -> HTMLResponse: """Serve a custom-themed Swagger UI page.""" - html = get_swagger_ui_html( - openapi_url=request.app.openapi_url, + openapi_url = _get_openapi_url(request) + response = get_swagger_ui_html( + openapi_url=openapi_url, title=f"{request.app.title} - Swagger UI", swagger_ui_parameters={ "defaultModelsExpandDepth": -1, "displayRequestDuration": True, "docExpansion": "list", }, - ).body.decode("utf-8") + ) + body = response.body.tobytes() if isinstance(response.body, memoryview) else response.body + html = body.decode("utf-8") return _wrap_docs_html(html) @router.get("/redoc", include_in_schema=False) def redirect_to_custom_redoc_docs(request: Request) -> RedirectResponse: """Redirect to the custom-themed ReDoc page.""" - return RedirectResponse(url="/api/redoc") + root_path = request.scope.get("root_path", "") + return RedirectResponse(url=f"{root_path}/api/redoc") @router.get("/api/redoc", include_in_schema=False) def custom_redoc_docs(request: Request) -> HTMLResponse: """Serve a custom-themed ReDoc page.""" - html = get_redoc_html( - openapi_url=request.app.openapi_url, + openapi_url = _get_openapi_url(request) + response = get_redoc_html( + openapi_url=openapi_url, title=f"{request.app.title} - ReDoc", - ).body.decode("utf-8") + ) + body = response.body.tobytes() if isinstance(response.body, memoryview) else response.body + html = body.decode("utf-8") return _wrap_docs_html(html) diff --git a/backend/app/routers/embed.py b/backend/app/routers/embed.py new file mode 100644 index 00000000..5bc33af9 --- /dev/null +++ b/backend/app/routers/embed.py @@ -0,0 +1,297 @@ +"""Embed HTML widget endpoints for iframe dashboard integrations.""" + +import html +import logging +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi.responses import HTMLResponse, Response +from sqlmodel import Session, select + +from app.auth import EMBED_TOKEN_SCOPE_STATS_READ, hash_embed_token +from app.database import get_session +from app.models import Book, EmbedToken, ReadingStatus, User +from app.time_utils import utcnow + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/embed/v1", tags=["embed"]) + +VALID_STAT_KEYS = {"books", "reading", "read", "to_read", "pages", "avg_pages"} +LAYOUT_MODES = {"grid", "list"} + + +def _load_stat_labels() -> dict[str, dict[str, str]]: + import json + + i18n_dir = Path(__file__).resolve().parent.parent / "i18n" + required = {"books", "reading", "read", "to_read", "pages", "avg_pages"} + loaded: dict[str, dict[str, str]] = {} + + for file_path in i18n_dir.glob("*.json"): + locale = file_path.stem.lower() + with file_path.open("r", encoding="utf-8") as f: + data = json.load(f) + + stats_labels = data.get("embed", {}).get("stats", {}) + if not isinstance(stats_labels, dict): + continue + + missing = required - set(stats_labels.keys()) + if missing: + raise RuntimeError( + f"Invalid i18n file {file_path.name}: missing embed.stats keys {', '.join(sorted(missing))}" + ) + loaded[locale] = {k: str(v) for k, v in stats_labels.items() if k in required} + + if "en" not in loaded: + raise RuntimeError("Invalid i18n files: expected at least en.json with embed.stats labels") + + return loaded + + +STAT_LABELS = _load_stat_labels() + + +def _normalize_lang(lang: str) -> str: + base = (lang or "en").strip().lower().replace("_", "-").split("-", 1)[0] + return base if base in STAT_LABELS else "en" + + +def _verify_embed_token( + token: str, + session: Session, + request: Request, +) -> User: + token_hash_val = hash_embed_token(token) + db_token = session.exec( + select(EmbedToken).where( + EmbedToken.token_hash == token_hash_val, + EmbedToken.revoked_at.is_(None), + ) + ).first() + + if not db_token: + logger.warning("Embed auth: invalid token prefix=%s", token[:12]) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or revoked token", + ) + + if db_token.expires_at and db_token.expires_at < utcnow(): + logger.warning( + "Embed auth: expired token id=%s user=%s", + db_token.id, db_token.user_id, + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token expired", + ) + + scopes = [s.strip() for s in db_token.scopes.split(",")] + if EMBED_TOKEN_SCOPE_STATS_READ not in scopes: + logger.warning( + "Embed auth: missing scope token id=%s scopes=%s", + db_token.id, db_token.scopes, + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token lacks required scope", + ) + + if db_token.allowed_origins: + origin = request.headers.get("origin") or request.headers.get("referer") or "" + if origin: + from urllib.parse import urlparse + parsed = urlparse(origin) + request_origin = f"{parsed.scheme}://{parsed.netloc}".lower() + else: + request_origin = "" + + allowed = [ + o.strip().lower() + for o in db_token.allowed_origins.split(",") + if o.strip() + ] + if request_origin and request_origin not in allowed: + logger.warning( + "Embed auth: origin denied token id=%s user=%s origin=%s allowed=%s", + db_token.id, db_token.user_id, request_origin, allowed, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Origin not allowed", + ) + + user = session.get(User, db_token.user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token user not found", + ) + + db_token.last_used_at = utcnow() + session.add(db_token) + session.commit() + + return user + + +def _compute_stats(books: list[Book]) -> dict[str, int]: + total = len(books) + read = sum(1 for b in books if b.reading_status == ReadingStatus.read) + reading = sum(1 for b in books if b.reading_status == ReadingStatus.currently_reading) + want_to_read = sum(1 for b in books if b.reading_status == ReadingStatus.want_to_read) + pages = sum(b.page_count or 0 for b in books if b.page_count) + avg_pages = round(pages / total, 0) if total > 0 else 0 + return { + "books": total, + "reading": reading, + "read": read, + "to_read": want_to_read, + "pages": pages, + "avg_pages": int(avg_pages), + } + + +def _render_stats_html( + stats: dict[str, int], + theme: str, + accent: str, + radius: str, + density: str, + hide_labels: bool, + lang: str, + font_scale: float, + layout: str, + show: Optional[set[str]] = None, +) -> str: + density_gap = {"compact": "0.25rem", "normal": "0.5rem", "comfortable": "0.75rem"}.get(density, "0.5rem") + radius_map = {"none": "0", "sm": "0.375rem", "md": "0.5rem", "lg": "0.75rem", "xl": "1rem"}.get(radius, "0.5rem") + + bg = "#ffffff" if theme == "light" else "#1d232a" + fg = "#1f2937" if theme == "light" else "#e5e7eb" + muted = "#6b7280" if theme == "light" else "#9ca3af" + card_bg = "#f3f4f6" if theme == "light" else "#2a323d" + card_border = "#e5e7eb" if theme == "light" else "#3d4452" + + base_font_size = f"{round(14 * font_scale, 1)}px" + + card_style = ( + f"background:{card_bg};border:1px solid {card_border};" + f"border-radius:{radius_map};padding:{density_gap};" + f"text-align:center;min-width:0" + ) + + parts: list[str] = [] + + labels = STAT_LABELS[_normalize_lang(lang)] + + if show: + keys = [k for k in VALID_STAT_KEYS if k in show] + else: + keys = ["books", "reading", "read", "to_read", "pages", "avg_pages"] + + items = [(labels[k], stats.get(k, 0)) for k in keys] + + if layout == "list": + parts.append(f"
") + cols_per_row = 1 + else: + cols_per_row = min(len(items), 3) + if cols_per_row == 0: + cols_per_row = 1 + parts.append(f"
") + + for label, value in items: + parts.append(f"
") + parts.append( + f"
{html.escape(str(value))}
" + ) + if not hide_labels: + parts.append( + f"
" + f"{html.escape(label)}
" + ) + parts.append("
") + parts.append("
") + + body = "".join(parts) + + return f""" + + + + + + + +{body} + +""" + + +@router.get("/stats", response_class=HTMLResponse) +def get_embed_stats( + request: Request, + token: str = Query(..., description="Embed token"), + theme: str = Query("light", description="Theme: light|dark"), + accent: str = Query("#3b82f6", description="Accent hex color"), + radius: str = Query("md", description="Border radius: none|sm|md|lg|xl"), + density: str = Query("normal", description="Density: compact|normal|comfortable"), + hide_labels: bool = Query(False, description="Hide text labels"), + show: Optional[str] = Query(None, description="Comma-separated stat keys to display"), + lang: str = Query("en", description="HTML lang attribute"), + font_scale: float = Query(1.0, ge=0.5, le=3.0, description="Font size multiplier"), + layout: str = Query("grid", description="Layout: grid|list"), + session: Session = Depends(get_session), +): + user = _verify_embed_token(token, session, request) + + books = list( + session.exec( + select(Book).where(Book.user_id == user.id) + ).all() + ) + + stats = _compute_stats(books) + + show_set: Optional[set[str]] = None + if show: + keys = {k.strip() for k in show.split(",") if k.strip()} + invalid = keys - VALID_STAT_KEYS + if invalid: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + 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, + detail=f"Invalid layout '{layout}'. Valid: {', '.join(sorted(LAYOUT_MODES))}", + ) + + html_content = _render_stats_html( + stats, theme, accent, radius, density, hide_labels, lang, font_scale, layout, show_set, + ) + + return Response( + content=html_content, + media_type="text/html; charset=utf-8", + headers={ + "Cache-Control": "private, max-age=60", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors *", + }, + ) diff --git a/backend/app/routers/profile.py b/backend/app/routers/profile.py index 256a66aa..fa6c169b 100644 --- a/backend/app/routers/profile.py +++ b/backend/app/routers/profile.py @@ -9,14 +9,17 @@ clear_browser_session, ensure_password_complexity, generate_api_key, + generate_embed_token, get_api_key_prefix, + get_embed_token_prefix, get_password_hash, hash_api_key, + hash_embed_token, require_user, ) from app.config import settings as app_settings from app.database import get_session -from app.models import ApiKey, User, UserSettings +from app.models import ApiKey, EmbedToken, User, UserSettings from app.schemas import ( ApiKeyCreate, ApiKeyCreateResponse, @@ -24,6 +27,10 @@ ConfirmationPhrase, DataResetDeleted, DataResetResponse, + EmbedTokenCreate, + EmbedTokenCreateResponse, + EmbedTokenRead, + EmbedTokenUpdate, ProfileUpdate, UserRead, UserSettingsRead, @@ -94,7 +101,6 @@ def get_settings( user_id=settings.user_id, language=settings.language, timezone=settings.timezone, - quote_service_enabled=app_settings.dashboard_quote_enabled, theme=settings.theme, custom_theme=settings.custom_theme, ) @@ -123,7 +129,6 @@ def update_settings( user_id=settings.user_id, language=settings.language, timezone=settings.timezone, - quote_service_enabled=app_settings.dashboard_quote_enabled, theme=settings.theme, custom_theme=settings.custom_theme, ) @@ -200,7 +205,7 @@ def list_api_keys( select(ApiKey).where( ApiKey.user_id == current_user.id, ApiKey.revoked_at.is_(None), - ) + ).order_by(ApiKey.created_at.desc()) ).all() return [ApiKeyRead.model_validate(k) for k in keys] @@ -239,3 +244,119 @@ def delete_api_key( key.revoked_at = utcnow() session.add(key) session.commit() + + +@router.get("/embed-tokens", response_model=list[EmbedTokenRead]) +def list_embed_tokens( + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> list[EmbedTokenRead]: + """List non-revoked embed tokens for the current user.""" + tokens = session.exec( + select(EmbedToken).where( + EmbedToken.user_id == current_user.id, + EmbedToken.revoked_at.is_(None), + ).order_by(EmbedToken.created_at.desc()) + ).all() + return [EmbedTokenRead.model_validate(t) for t in tokens] + + +@router.post("/embed-tokens", response_model=EmbedTokenCreateResponse, status_code=201) +def create_embed_token( + body: EmbedTokenCreate, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> EmbedTokenCreateResponse: + """Create a new embed token for the current user.""" + plain_token = generate_embed_token() + token = EmbedToken( + user_id=current_user.id, + name=body.name, + token_prefix=get_embed_token_prefix(plain_token), + token_hash=hash_embed_token(plain_token), + allowed_origins=body.allowed_origins or None, + expires_at=body.expires_at, + ) + session.add(token) + session.commit() + session.refresh(token) + return EmbedTokenCreateResponse( + token=plain_token, + embed_token=EmbedTokenRead.model_validate(token), + ) + + +@router.patch("/embed-tokens/{token_id}", response_model=EmbedTokenRead) +def update_embed_token( + token_id: int, + body: EmbedTokenUpdate, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> EmbedTokenRead: + """Update name, allowed_origins, or expires_at for an embed token.""" + token = session.get(EmbedToken, token_id) + if not token or token.user_id != current_user.id or token.revoked_at is not None: + raise HTTPException(status_code=404, detail="Embed token not found") + + update_data = body.model_dump(exclude_unset=True) + token.sqlmodel_update(update_data) + session.add(token) + session.commit() + session.refresh(token) + return EmbedTokenRead.model_validate(token) + + +@router.post("/embed-tokens/{token_id}/rotate", response_model=EmbedTokenCreateResponse) +def rotate_embed_token( + token_id: int, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> EmbedTokenCreateResponse: + """Revoke the current embed token and create a new one with the same settings.""" + token = session.get(EmbedToken, token_id) + if not token or token.user_id != current_user.id or token.revoked_at is not None: + raise HTTPException(status_code=404, detail="Embed token not found") + + now = utcnow() + if token.expires_at is not None and token.expires_at < now: + raise HTTPException( + status_code=409, + detail="Expired embed tokens cannot be rotated", + ) + + token.revoked_at = now + session.add(token) + + plain_token = generate_embed_token() + new_token = EmbedToken( + user_id=current_user.id, + name=token.name, + token_prefix=get_embed_token_prefix(plain_token), + token_hash=hash_embed_token(plain_token), + scopes=token.scopes, + allowed_origins=token.allowed_origins, + expires_at=token.expires_at, + ) + session.add(new_token) + session.commit() + session.refresh(new_token) + return EmbedTokenCreateResponse( + token=plain_token, + embed_token=EmbedTokenRead.model_validate(new_token), + ) + + +@router.delete("/embed-tokens/{token_id}", status_code=204) +def delete_embed_token( + token_id: int, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> None: + """Revoke an embed token by ID.""" + token = session.get(EmbedToken, token_id) + if not token or token.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Embed token not found") + + token.revoked_at = utcnow() + session.add(token) + session.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 80810a62..96c3b018 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -324,7 +324,6 @@ class UserSettingsRead(SQLModel): user_id: int language: str timezone: str - quote_service_enabled: bool theme: str custom_theme: Optional[str] = None @@ -419,6 +418,13 @@ class OidcConfigRead(SQLModel): provider_name: Optional[str] = None +class AppConfigRead(SQLModel): + """Application-level feature flags.""" + embed_enabled: bool + dashboard_quote_enabled: bool + thalia_cover_search_enabled: bool + + class OidcLinkRead(SQLModel): """OIDC link status.""" linked: bool @@ -599,6 +605,38 @@ class DataImportPreviewResponse(SQLModel): errors: list[str] = [] +class EmbedTokenCreate(SQLModel): + """Embed token creation request.""" + name: str = Field(max_length=255) + allowed_origins: Optional[str] = None + expires_at: Optional[datetime] = None + + +class EmbedTokenUpdate(SQLModel): + """Embed token update request.""" + name: Optional[str] = None + allowed_origins: Optional[str] = None + expires_at: Optional[datetime] = None + + +class EmbedTokenRead(SQLModel): + """Embed token read response (without the raw token value).""" + id: int + name: str + token_prefix: str + scopes: str + allowed_origins: Optional[str] + expires_at: Optional[datetime] + last_used_at: Optional[datetime] + created_at: datetime + + +class EmbedTokenCreateResponse(SQLModel): + """Embed token creation response containing the raw token (shown once).""" + token: str + embed_token: EmbedTokenRead + + class DataImportExecuteResult(SQLModel): """Import execution result summary.""" imported: int diff --git a/backend/scripts/gen_db_docs.py b/backend/scripts/gen_db_docs.py new file mode 100644 index 00000000..de62c19b --- /dev/null +++ b/backend/scripts/gen_db_docs.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Generate docs/guide/database-layout.md from SQLModel metadata. + +Usage: + uv run --directory backend python scripts/gen_db_docs.py +""" + +import sys +from pathlib import Path +from datetime import datetime +from enum import Enum as PyEnum + +BACKEND_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BACKEND_DIR)) + +import sqlalchemy as sa +from sqlalchemy import TypeDecorator +from sqlmodel import SQLModel +from app import models # noqa: E402 +from app.models import UtcDateTime + +OUTPUT = Path(BACKEND_DIR).parent / "docs" / "guide" / "database-layout.md" +NOW = datetime.now().strftime("%Y-%m-%d") + + +def _model_for_table(name: str): + for obj in models.__dict__.values(): + if isinstance(obj, type) and issubclass(obj, SQLModel) and hasattr(obj, '__tablename__'): + if obj.__tablename__ == name: + return obj + return None + + +def _resolve_type(col: sa.Column) -> str: + t = col.type + if isinstance(t, UtcDateTime): + return "DATETIME" + if isinstance(t, sa.Enum): + return "VARCHAR" + return str(t).upper() + + +def _resolve_constraints(col: sa.Column) -> str: + parts = [] + if col.primary_key: + parts.append("PK") + for fk in col.foreign_keys: + parts.append(f"FK → {fk.column.table.name}.{fk.column.name}") + if col.unique and not col.primary_key: + parts.append("UNIQUE") + if not col.nullable and not col.primary_key: + parts.append("NOT NULL") + if col.index and not col.primary_key and not col.unique: + parts.append("INDEX") + return ", ".join(parts) + + +def _field_metadata(col: sa.Column): + model = _model_for_table(col.table.name) + if model and col.name in (model.model_fields or {}): + return model.model_fields[col.name] + return None + + +def _resolve_notes(col: sa.Column) -> str: + note_parts = [] + + if isinstance(col.type, UtcDateTime): + note_parts.append("UTC") + + single_pk = len(col.table.primary_key.columns) == 1 + if col.primary_key and single_pk and isinstance(col.type, sa.Integer): + note_parts.append("Auto-increment") + + # default value + if col.default is not None: + arg = getattr(col.default, "arg", None) + if arg is not None and not callable(arg): + if isinstance(arg, PyEnum): + note_parts.append(f"default `{arg.value}`") + elif isinstance(arg, str): + note_parts.append(f"default `{arg}`") + else: + note_parts.append(f"default {arg}") + + # ge / le from pydantic field metadata + field = _field_metadata(col) + if field: + for item in field.metadata: + cls_name = type(item).__name__ + if cls_name == "Ge": + note_parts.append(f"≥ {item.ge}") + elif cls_name == "Le": + note_parts.append(f"≤ {item.le}") + + return "; ".join(note_parts) if note_parts else "" + + +def _cardinality(col: sa.Column) -> str: + if col.unique: + return "o|" if col.nullable else "||" + return "o{" if col.nullable else "|{" + + +def _generate_er_diagram(tables) -> str: + lines = ["```mermaid", "erDiagram", ""] + rels = [] + seen = set() + for table in tables: + for col in table.columns: + for fk in col.foreign_keys: + parent = fk.column.table.name + child = table.name + key = (parent, child) + if key not in seen: + seen.add(key) + rels.append((parent, child, col)) + card_map = {"||": "1:1", "|{": "1:N", "o|": "0..1", "o{": "0..N"} + for parent, child, col in rels: + card = _cardinality(col) + label = card_map.get(card, card) + lines.append(f' {parent} ||--{card} {child} : "{label}"') + lines.append("") + for table in tables: + lines.append(f" {table.name} {{") + for col in table.columns: + typ = _resolve_type(col).lower() + name = col.name + key_flag = "PK" if col.primary_key else ("UK" if col.unique else "") + parts = [typ, name] + if key_flag: + parts.append(key_flag) + lines.append(f" {' '.join(parts)}") + lines.append(" }") + lines.append("") + lines.append("```") + return "\n".join(lines) + + +def _generate_table_docs(tables) -> str: + lines = ["## Tables", ""] + for table in tables: + model = _model_for_table(table.name) + doc = (model.__doc__ or "").strip() if model else "" + + lines.append(f"### `{table.name}`") + lines.append("") + if doc: + lines.append(doc) + lines.append("") + + lines.append("| Column | Type | Constraints | Notes |") + lines.append("|--------|------|-------------|-------|") + for col in table.columns: + typ = _resolve_type(col) + cnst = _resolve_constraints(col) + notes = _resolve_notes(col) + lines.append(f"| `{col.name}` | `{typ}` | {cnst} | {notes} |") + + ucs = [uc for uc in table.constraints + if isinstance(uc, sa.UniqueConstraint) + and not any(c.unique for c in uc.columns)] + if ucs: + lines.append("") + for uc in ucs: + col_names = ", ".join(c.name for c in uc.columns) + lines.append(f"**Unique constraint:** `({col_names})` — {uc.name}") + + lines.append("") + return "\n".join(lines) + + +def _generate_enum_docs() -> str: + lines = ["## Enums", ""] + for name in dir(models): + cls = getattr(models, name) + if isinstance(cls, type) and issubclass(cls, PyEnum) and cls.__module__ == models.__name__: + lines.append(f"### `{name}`") + lines.append("") + lines.append("| Value | Meaning |") + lines.append("|-------|---------|") + for member in cls: + lines.append(f"| `{member.value}` | {member.value.replace('_', ' ').title()} |") + lines.append("") + return "\n".join(lines) + + +def main(): + tables = list(SQLModel.metadata.sorted_tables) + + content = f"""# Database Layout + +> _Auto-generated from SQLModel metadata on {NOW}._ + +This page documents the LibrisLog database schema. It is intended for +developers who need to understand the data model, write queries, or extend +the application. + +{_generate_er_diagram(tables)} + +{_generate_table_docs(tables)} + +{_generate_enum_docs()} + +## Conventions + +- **Timestamps** are stored as UTC via the `UtcDateTime` type decorator. + Values are stored as naive UTC in SQLite and returned as timezone-aware + `datetime` objects by the application. +- **Soft deletes** — `ApiKey` and `EmbedToken` use a `revoked_at` timestamp + instead of `DELETE`. Revoked entries are excluded from all queries. +- **Foreign keys** — all user-owned tables reference `user.id` via foreign + key constraints. Cascading behavior is handled in application code (not + at the database level). +- **Unique constraints** — compound constraints like `(user_id, isbn)` on + `book` and `(user_id, name)` on `tag` enforce per-user uniqueness without + restricting other users. +""" + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(content) + print(f"✓ Wrote {OUTPUT}") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_embed.py b/backend/tests/test_embed.py new file mode 100644 index 00000000..2b0e45b5 --- /dev/null +++ b/backend/tests/test_embed.py @@ -0,0 +1,359 @@ +"""Tests for embed token lifecycle and the embed HTML widget endpoint.""" + +import re +from collections.abc import Callable +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +from app.auth import generate_embed_token, hash_embed_token +from app.models import Book, EmbedToken, UserRole +from app.time_utils import utcnow + +# ── Helpers ────────────────────────────────────────────────────────────── + + +def _create_token( + session: Session, + user_id: int, + *, + name: str = "Test Token", + allowed_origins: str | None = None, + expires_at: datetime | None = None, +) -> str: + plain = generate_embed_token() + token = EmbedToken( + user_id=user_id, + name=name, + token_prefix=plain[:12], + token_hash=hash_embed_token(plain), + allowed_origins=allowed_origins, + expires_at=expires_at, + ) + session.add(token) + session.commit() + return plain + + +# ── Profile CRUD Tests ─────────────────────────────────────────────────── + + +class TestListEmbedTokens: + def test_lists_own_tokens(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + resp = client.get("/api/profile/embed-tokens") + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 1 + assert data[0]["token_prefix"] == plain[:12] + + def test_does_not_list_revoked_tokens( + self, client: TestClient, session: Session, create_user_with_key: Callable[..., Any] + ) -> None: + user, key = create_user_with_key(email="embed_list@example.com") + client.headers["X-API-Key"] = key + plain = _create_token(session, user.id) + # Revoke it + token = session.exec( + select(EmbedToken).where(EmbedToken.token_hash == hash_embed_token(plain)) + ).first() + assert token is not None + token.revoked_at = utcnow() + session.add(token) + session.commit() + resp = client.get("/api/profile/embed-tokens") + assert resp.status_code == 200 + assert all(t["token_prefix"] != plain[:12] for t in resp.json()) + + def test_other_user_cannot_see_tokens( + self, client: TestClient, session: Session, create_user_with_key: Callable[..., Any] + ) -> None: + user, key = create_user_with_key(email="other_embed@example.com") + client.headers["X-API-Key"] = key + _create_token(session, user_id=user.id, name="Secret") + resp = client.get("/api/profile/embed-tokens") + assert resp.status_code == 200 + assert len(resp.json()) == 1 # only own tokens + + +class TestCreateEmbedToken: + def test_creates_token(self, client: TestClient) -> None: + resp = client.post( + "/api/profile/embed-tokens", + json={"name": "Homarr Widget"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["token"].startswith("le_") + assert data["embed_token"]["name"] == "Homarr Widget" + assert data["embed_token"]["token_prefix"] == data["token"][:12] + + def test_with_allowed_origins(self, client: TestClient) -> None: + resp = client.post( + "/api/profile/embed-tokens", + json={ + "name": "With Origins", + "allowed_origins": "https://homarr.local,https://dashy.local", + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["embed_token"]["allowed_origins"] == "https://homarr.local,https://dashy.local" + + def test_with_expiry(self, client: TestClient) -> None: + expires = (datetime.now(timezone.utc) + timedelta(days=90)).isoformat() + resp = client.post( + "/api/profile/embed-tokens", + json={"name": "Expiring", "expires_at": expires}, + ) + assert resp.status_code == 201 + + +class TestUpdateEmbedToken: + def test_updates_name_and_origins(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + token = session.exec( + select(EmbedToken).where(EmbedToken.token_hash == hash_embed_token(plain)) + ).first() + assert token is not None + resp = client.patch( + f"/api/profile/embed-tokens/{token.id}", + json={"name": "Renamed", "allowed_origins": "https://example.com"}, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "Renamed" + assert resp.json()["allowed_origins"] == "https://example.com" + + def test_not_found_for_other_user( + self, client: TestClient, session: Session, create_user_with_key: Callable[..., Any] + ) -> None: + user, key = create_user_with_key(email="other_update@example.com") + client.headers["X-API-Key"] = key + resp = client.patch("/api/profile/embed-tokens/99999", json={"name": "Nope"}) + assert resp.status_code == 404 + + +class TestRotateEmbedToken: + def test_rotate_returns_new_token(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1, name="Rotate Me") + token = session.exec( + select(EmbedToken).where(EmbedToken.token_hash == hash_embed_token(plain)) + ).first() + assert token is not None + old_id = token.id + resp = client.post(f"/api/profile/embed-tokens/{old_id}/rotate") + assert resp.status_code == 200 + data = resp.json() + assert data["token"].startswith("le_") + assert data["token"] != plain + assert data["embed_token"]["name"] == "Rotate Me" + assert data["embed_token"]["id"] != old_id + # Old token should be revoked + session.expire_all() + old = session.get(EmbedToken, old_id) + assert old is not None + assert old.revoked_at is not None + + def test_cannot_rotate_expired_token(self, client: TestClient, session: Session) -> None: + plain = _create_token( + session, + user_id=1, + name="Expired", + expires_at=datetime.now(timezone.utc) - timedelta(hours=1), + ) + token = session.exec( + select(EmbedToken).where(EmbedToken.token_hash == hash_embed_token(plain)) + ).first() + assert token is not None + + resp = client.post(f"/api/profile/embed-tokens/{token.id}/rotate") + assert resp.status_code == 409 + assert resp.json()["detail"] == "Expired embed tokens cannot be rotated" + + +class TestDeleteEmbedToken: + def test_revokes_token(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + token = session.exec( + select(EmbedToken).where(EmbedToken.token_hash == hash_embed_token(plain)) + ).first() + assert token is not None + resp = client.delete(f"/api/profile/embed-tokens/{token.id}") + assert resp.status_code == 204 + session.expire_all() + deleted = session.get(EmbedToken, token.id) + assert deleted is not None + assert deleted.revoked_at is not None + + def test_not_found(self, client: TestClient) -> None: + resp = client.delete("/api/profile/embed-tokens/99999") + assert resp.status_code == 404 + + +# ── Embed Widget Endpoint Tests ────────────────────────────────────────── + + +class TestEmbedStatsEndpoint: + def test_returns_html_with_valid_token(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + resp = client.get(f"/embed/v1/stats?token={plain}") + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/html") + assert "" in resp.text + assert "Books" in resp.text + + def test_401_for_invalid_token(self, client: TestClient) -> None: + resp = client.get("/embed/v1/stats?token=le_invalid") + assert resp.status_code == 401 + + def test_401_for_revoked_token(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + token = session.exec( + select(EmbedToken).where(EmbedToken.token_hash == hash_embed_token(plain)) + ).first() + assert token is not None + token.revoked_at = utcnow() + session.add(token) + session.commit() + resp = client.get(f"/embed/v1/stats?token={plain}") + assert resp.status_code == 401 + + def test_401_for_expired_token(self, client: TestClient, session: Session) -> None: + plain = _create_token( + session, user_id=1, + expires_at=datetime.now(timezone.utc) - timedelta(hours=1), + ) + resp = client.get(f"/embed/v1/stats?token={plain}") + assert resp.status_code == 401 + + def test_shows_stats_for_user(self, client: TestClient, session: Session) -> None: + for i in range(3): + session.add(Book(title=f"Book {i}", author="Author", page_count=200, user_id=1, reading_status="read")) + session.commit() + + plain = _create_token(session, user_id=1) + resp = client.get(f"/embed/v1/stats?token={plain}") + assert resp.status_code == 200 + assert "3" in resp.text + + def test_user_isolation(self, client: TestClient, session: Session) -> None: + for i in range(5): + session.add(Book(title=f"U1 Book {i}", author="Author", page_count=100, user_id=1, reading_status="want_to_read")) + session.commit() + + from app.auth import generate_api_key, get_api_key_prefix, get_password_hash, hash_api_key, encrypt_api_key + from app.models import ApiKey, User, UserSettings + + user2 = User(firstname="User", lastname="Two", email="u2@example.com", + role=UserRole.user, hashed_password=get_password_hash("secret")) + session.add(user2) + session.commit() + session.refresh(user2) + 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), + key_hash=hash_api_key(key2), key_encrypted=encrypt_api_key(key2), + description="Key")) + session.commit() + plain = _create_token(session, user2.id) + resp = client.get(f"/embed/v1/stats?token={plain}") + assert resp.status_code == 200 + text = resp.text + books_match = re.search(r']*>\s*0\s*
', text) + assert books_match, f"Expected '0' somewhere in stats, got: {text[:500]}" + + def test_403_for_disallowed_origin(self, client: TestClient, session: Session) -> None: + plain = _create_token( + session, user_id=1, + allowed_origins="https://allowed.example.com", + ) + resp = client.get( + f"/embed/v1/stats?token={plain}", + headers={"Origin": "https://evil.example.com"}, + ) + assert resp.status_code == 403 + + def test_wildcard_origin_allows_any(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) # allowed_origins=None = wildcard + resp = client.get( + f"/embed/v1/stats?token={plain}", + headers={"Origin": "https://any-dashboard.local"}, + ) + assert resp.status_code == 200 + + def test_style_params_are_applied(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + resp = client.get( + f"/embed/v1/stats?token={plain}&theme=dark&accent=%23ff0000&radius=lg&density=compact" + ) + assert resp.status_code == 200 + assert "#ff0000" in resp.text + + def test_missing_token_returns_422(self, client: TestClient) -> None: + resp = client.get("/embed/v1/stats") + assert resp.status_code == 422 + + def test_show_param_filters_stats(self, client: TestClient, session: Session) -> None: + for i in range(3): + session.add(Book(title=f"Book {i}", author="Author", page_count=200, user_id=1, reading_status="read")) + session.commit() + plain = _create_token(session, user_id=1) + resp = client.get(f"/embed/v1/stats?token={plain}&show=books,pages") + assert resp.status_code == 200 + assert "Books" in resp.text + assert "Pages" in resp.text + assert "Reading" not in resp.text + + def test_show_param_invalid_key_returns_422(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + resp = client.get(f"/embed/v1/stats?token={plain}&show=invalid_key") + assert resp.status_code == 422 + + def test_lang_param_sets_html_lang_and_translates_labels(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + resp = client.get(f"/embed/v1/stats?token={plain}&lang=de") + assert resp.status_code == 200 + assert 'lang="de"' in resp.text + assert "Gelesen" in resp.text + assert "Books" not in resp.text + + def test_lang_param_with_region_falls_back_to_base_language(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + resp = client.get(f"/embed/v1/stats?token={plain}&lang=fr-CA") + assert resp.status_code == 200 + assert 'lang="fr-CA"' in resp.text + assert "Livres" in resp.text + + def test_font_scale_param(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + resp = client.get(f"/embed/v1/stats?token={plain}&font_scale=1.5") + assert resp.status_code == 200 + assert "21.0px" in resp.text + + def test_font_scale_out_of_range_returns_422(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + resp = client.get(f"/embed/v1/stats?token={plain}&font_scale=5.0") + assert resp.status_code == 422 + + def test_layout_list(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + resp = client.get(f"/embed/v1/stats?token={plain}&layout=list") + assert resp.status_code == 200 + assert "flex-direction:column" in resp.text + + def test_layout_invalid_returns_422(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + resp = client.get(f"/embed/v1/stats?token={plain}&layout=bad") + assert resp.status_code == 422 + + def test_security_headers(self, client: TestClient, session: Session) -> None: + plain = _create_token(session, user_id=1) + resp = client.get(f"/embed/v1/stats?token={plain}") + assert resp.status_code == 200 + assert resp.headers.get("cache-control") == "private, max-age=60" + 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 *" diff --git a/docs/.vitepress/config.base.ts b/docs/.vitepress/config.base.ts index 2023b175..a0d94eac 100644 --- a/docs/.vitepress/config.base.ts +++ b/docs/.vitepress/config.base.ts @@ -50,6 +50,7 @@ export default defineConfig({ collapsed: true, items: [ { text: 'CLI Reference', link: '/guide/cli' }, + { text: 'Database Layout', link: '/guide/database-layout' }, ], }, { text: 'Integrations 🔗', link: '/api/integrations/' }, @@ -81,8 +82,11 @@ export default defineConfig({ link: '/api/integrations/', collapsed: true, items: [ + { text: 'Embed API', link: '/api/integrations/embed-api' }, { text: 'Dashy', link: '/api/integrations/dashy' }, + { text: 'Glance', link: '/api/integrations/glance' }, { text: 'Home Assistant', link: '/api/integrations/homeassistant' }, + { text: 'Homarr', link: '/api/integrations/homarr' }, { text: 'Homepage', link: '/api/integrations/homepage' }, ], }, diff --git a/docs/.vitepress/config.nightly.ts b/docs/.vitepress/config.nightly.ts index 672c4b5b..5539aae1 100644 --- a/docs/.vitepress/config.nightly.ts +++ b/docs/.vitepress/config.nightly.ts @@ -1,7 +1,8 @@ import { defineConfig } from 'vitepress' +import { withMermaid } from 'vitepress-plugin-mermaid' import baseConfig from './config.base' -export default defineConfig({ +export default withMermaid(defineConfig({ ...baseConfig, base: '/next/', head: [ @@ -16,4 +17,4 @@ export default defineConfig({ { text: 'Release Docs', link: 'https://docs.librislog.app/' }, ], }, -}) +})) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index e354033d..ca8e8ed9 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,7 +1,8 @@ import { defineConfig } from 'vitepress' +import { withMermaid } from 'vitepress-plugin-mermaid' import baseConfig from './config.base' -export default defineConfig({ +export default withMermaid(defineConfig({ ...baseConfig, base: '/', head: [ @@ -16,4 +17,4 @@ export default defineConfig({ { text: 'Nightly Docs', link: 'https://docs.librislog.app/next/' }, ], }, -}) +})) diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index b4c603f9..be7040e4 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,9 +1,11 @@ import type { Theme } from 'vitepress' import DefaultTheme from 'vitepress/theme' import { useRoute } from 'vitepress' +import { watch } from 'vue' import imageViewer from 'vitepress-plugin-image-viewer' import vImageViewer from 'vitepress-plugin-image-viewer/lib/vImageViewer.vue' import CommitInfo from './components/CommitInfo.vue' +import { installMermaidZoom } from './mermaid-zoom' import 'viewerjs/dist/viewer.min.css' export default { @@ -20,5 +22,15 @@ export default { return true } }) + + if (typeof window !== 'undefined') { + const init = () => { + setTimeout(() => { + document.querySelectorAll('.mermaid').forEach(installMermaidZoom) + }, 800) + } + init() + watch(() => route.path, init) + } }, } satisfies Theme diff --git a/docs/.vitepress/theme/mermaid-zoom.ts b/docs/.vitepress/theme/mermaid-zoom.ts new file mode 100644 index 00000000..337608ca --- /dev/null +++ b/docs/.vitepress/theme/mermaid-zoom.ts @@ -0,0 +1,132 @@ +export function installMermaidZoom(el: HTMLElement): void { + if (el.dataset.mz) return + el.dataset.mz = '1' + + const svg = el.querySelector('svg') + if (!svg) return + + const group = document.createElement('div') + Object.assign(group.style, { + margin: '1em 0', + }) + el.parentNode?.insertBefore(group, el) + group.appendChild(el) + + const wrapper = document.createElement('div') + Object.assign(wrapper.style, { + overflow: 'hidden', + cursor: 'grab', + border: '1px solid var(--vp-c-divider)', + borderRadius: '6px', + position: 'relative', + touchAction: 'none', + }) + el.style.margin = '0' + group.insertBefore(wrapper, el) + wrapper.appendChild(el) + svg.style.display = 'block' + + let scale = 1, tx = 0, ty = 0 + + const set = () => { + svg.style.transform = `translate(${tx}px,${ty}px) scale(${scale})` + svg.style.transformOrigin = '0 0' + } + + wrapper.addEventListener('wheel', (e) => { + e.preventDefault() + const rect = wrapper.getBoundingClientRect() + const mx = e.clientX - rect.left + const my = e.clientY - rect.top + const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15 + const ns = Math.min(5, Math.max(0.15, scale * factor)) + tx = mx - (mx - tx) * (ns / scale) + ty = my - (my - ty) * (ns / scale) + scale = ns + set() + }, { passive: false }) + + let dragging = false, sx = 0, sy = 0, px = 0, py = 0 + + wrapper.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return + dragging = true + wrapper.style.cursor = 'grabbing' + sx = e.clientX; sy = e.clientY + px = tx; py = ty + wrapper.setPointerCapture(e.pointerId) + e.preventDefault() + }) + + wrapper.addEventListener('pointermove', (e) => { + if (!dragging) return + tx = px + (e.clientX - sx) + ty = py + (e.clientY - sy) + set() + }) + + const endDrag = () => { dragging = false; wrapper.style.cursor = 'grab' } + wrapper.addEventListener('pointerup', endDrag) + wrapper.addEventListener('pointercancel', endDrag) + + wrapper.addEventListener('dblclick', () => { scale = 1; tx = 0; ty = 0; set() }) + + const zoom = (factor: number) => { + const rect = wrapper.getBoundingClientRect() + const mx = rect.width / 2 + const my = rect.height / 2 + const ns = Math.min(5, Math.max(0.15, scale * factor)) + tx = mx - (mx - tx) * (ns / scale) + ty = my - (my - ty) * (ns / scale) + scale = ns + set() + } + + const bar = document.createElement('div') + Object.assign(bar.style, { + display: 'flex', + gap: '2px', + justifyContent: 'center', + padding: '6px 0 0 0', + }) + + const btnStyle: Record = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + border: '1px solid var(--vp-c-divider)', + borderRadius: '6px', + background: 'var(--vp-c-bg)', + color: 'var(--vp-c-text-2)', + fontFamily: 'var(--vp-font-family-base)', + fontSize: '16px', + lineHeight: '1', + cursor: 'pointer', + userSelect: 'none', + } + + const mkBtn = (text: string, title: string, action: () => void) => { + const b = document.createElement('button') + Object.assign(b.style, btnStyle) + b.textContent = text + b.title = title + const restore = () => Object.assign(b.style, btnStyle) + b.addEventListener('mouseenter', () => { + b.style.background = 'var(--vp-c-default-soft)' + b.style.borderColor = 'var(--vp-c-brand-1)' + b.style.color = 'var(--vp-c-brand-1)' + }) + b.addEventListener('mouseleave', restore) + b.addEventListener('click', action) + return b + } + + const plus = mkBtn('+', 'Zoom in', () => zoom(1.3)) + const minus = mkBtn('−', 'Zoom out', () => zoom(1 / 1.3)) + const reset = mkBtn('⟲', 'Reset zoom', () => { scale = 1; tx = 0; ty = 0; set() }) + + ;[minus, reset, plus].forEach(b => bar.appendChild(b)) + group.appendChild(bar) +} diff --git a/docs/api/integrations/dashy.md b/docs/api/integrations/dashy.md index 73ee033e..ad63d697 100644 --- a/docs/api/integrations/dashy.md +++ b/docs/api/integrations/dashy.md @@ -1,25 +1,57 @@ # Dashy LibrisLog can be integrated into [Dashy](https://dashy.to/), a self-hosted -dashboard for your services, using its -[HTML embedded widget](https://dashy.to/docs/widgets#html-embedded-widget). +dashboard for your services, using one of its widget types: -This widget displays your reading statistics as styled stat cards directly on -your Dashy dashboard. +- **[Custom API widget](#custom-api-widget-recommended)** (Dashy ≥4.3.1) — + a simple, built-in widget that fetches your stats directly (recommended). +- **[HTML embedded widget](#html-embedded-widget)** — a fully customizable + widget with styled stat cards. ## Prerequisites - A running LibrisLog instance reachable from your Dashy server - An [API key](/api/integrations/#api-keys) with access to the statistics endpoint -- **CORS must be configured** — add your Dashy URL to the - [`CORS_ORIGINS`](/guide/configuration#core-settings) environment variable - of the LibrisLog backend so that the browser can fetch the API directly -## Configuration +## Custom API Widget (Recommended) + +Since Dashy v4.3.1 you can use the built-in +[custom API widget](https://dashy.to/docs/widgets#api-response). +Add the following to your Dashy `conf.yml`: + +```yaml +widgets: + - type: customapi + options: + url: /api/books/stats + headers: + X-API-Key: "" + refreshInterval: 60000 + display: block + mappings: + - field: books_read + label: Read + format: number + - field: books_reading + label: Reading + format: number + - field: books_want_to_read + label: Want to read + format: number + - field: total_books + label: Total + format: number +``` + +The `refreshInterval` is specified in milliseconds. `60000` equals 1 minute. + +## HTML Embedded Widget -Add the following to the Dashy `conf.yml` under the section or item where you -want the widget to appear: +For full control over the appearance you can use the +[HTML embedded widget](https://dashy.to/docs/widgets#html-embedded-widget). + +Add the following to your Dashy `conf.yml`: ```yaml widgets: @@ -112,6 +144,10 @@ widgets: })(); ``` +The `updateInterval` is specified in seconds. `300` equals 5 minutes. + +## Placeholders + Replace the placeholders with your own values: | Placeholder | Example | Description | @@ -119,19 +155,24 @@ Replace the placeholders with your own values: | `` | `http://192.168.1.100:8000` | The base URL of your LibrisLog instance (http or https) | | `` | `lk_nRHsF3jxIBDa9u....` | An API key with access to the statistics endpoint | -The `updateInterval` is specified in seconds. `300` equals 5 minutes. - ## CORS -Since the widget runs inside the Dashy iframe and fetches the LibrisLog API -directly from the browser, you must add your Dashy URL to the +The embed widget runs inside the Dashy iframe and fetches the API directly from +the browser. You must add your Dashy URL to the [`CORS_ORIGINS`](/guide/configuration#core-settings) environment variable of -the LibrisLog backend. For example: +the LibrisLog backend: ``` -CORS_ORIGINS=["https://dashy.YOUR-DOMAIN"] +CORS_ORIGINS=[""] ``` -## Result +The custom API widget also requires CORS if Dashy is configured to render +widgets client-side. If CORS errors occur, add the origin as shown above. + +## Results + +### Custom API (API Response) Widget +![Dashy Custom API Widget](/screenshots/integrations-dashy-customapi.png) -![Dashy Widget](/screenshots/integrations-dashy.png) +### HTML Embed Widget +![Dashy HTML Embedded Widget](/screenshots/integrations-dashy-embed.png) diff --git a/docs/api/integrations/embed-api.md b/docs/api/integrations/embed-api.md new file mode 100644 index 00000000..c25042ed --- /dev/null +++ b/docs/api/integrations/embed-api.md @@ -0,0 +1,111 @@ +# Embed API + +LibrisLog provides an **embed API** that returns a self-contained HTML page +with your reading statistics. It is designed for iframe widgets in dashboards +that cannot set custom HTTP headers (Homarr, Homepage, etc.). + +The embed endpoint (`/embed/v1/stats`) renders your stats as styled stat cards +with inline CSS, no JavaScript, and no external dependencies. + +## Prerequisites + +- A running LibrisLog instance reachable from your dashboard server +- An **embed token** with access to the embed widget endpoint +- Embed endpoints must be enabled (default: enabled). To disable, set + `EMBED_ENABLED=false` in your environment. + +## Creating an Embed Token + +1. Go to your LibrisLog [Profile](/guide/using-librislog/profile) page. +2. Scroll to the **Embed Tokens** section. +3. Enter a name for your widget (e.g. "My Dashboard"). +4. Optionally limit allowed origins for security. Leave empty (wildcard) + or set to your dashboard's URL. +5. Optionally set an expiry date for the token. +6. Click **Add token**. +7. **Copy the displayed token immediately** — it is shown only once. + +## Endpoint + +``` +GET /embed/v1/stats?token= +``` + +The token is passed as a query parameter. No `X-API-Key` header or browser +session is required, making it ideal for iframes. + +### Customizing the Look + +Optional query parameters to style the widget: + +| Parameter | Default | Values | Description | +|-----------|---------|--------|-------------| +| `theme` | `light` | `light`, `dark` | Color theme | +| `accent` | `#3b82f6` | hex color | Accent color for stat numbers | +| `radius` | `md` | `none`, `sm`, `md`, `lg`, `xl` | Card border radius | +| `density` | `normal` | `compact`, `normal`, `comfortable` | Spacing between stat cards | +| `hide_labels` | `false` | `true`, `false` | Hide text labels | +| `show` | all stats | `books`, `reading`, `read`, `to_read`, `pages`, `avg_pages` | Comma-separated list of stat keys to display | +| `lang` | `en` | any language code | HTML `lang` attribute; translates stat labels when a matching locale is available | +| `font_scale` | `1.0` | `0.5`–`3.0` | Font size multiplier | +| `layout` | `grid` | `grid`, `list` | Layout mode | + +Example with custom styling: + +``` +/embed/v1/stats?token=&theme=dark&accent=%23f59e0b&radius=sm&density=compact&hide_labels=true +``` + +> **Note:** The accent color must be URL-encoded (e.g. `%23` for `#`). + +## How It Works + +The embed endpoint returns a minimal, self-contained HTML page with your +reading statistics. It includes: + +- Inline CSS (no external dependencies) +- No-referrer policy and security headers +- No JavaScript, no app shell, no auth redirects + +Authentication is handled via the embed token in the query string. + +## Security + +- Embed tokens are scoped to `embed:stats:read` only — they cannot access + any other API endpoint. +- Tokens can be revoked or rotated individually from your Profile page. +- If configured, origin restrictions prevent token reuse on unauthorized + dashboards. +- The embed response does not include the token in the rendered HTML body. +- Response headers include: + - `Cache-Control: private, max-age=60` + - `X-Content-Type-Options: nosniff` + - `Referrer-Policy: no-referrer` + - `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; frame-ancestors *` +- The embed endpoint can be fully disabled by setting `EMBED_ENABLED=false`. + +## Troubleshooting + +**Widget shows "Invalid or revoked token"** + +- Verify the token was not revoked or rotated. +- Check that the token has not expired. +- Ensure `EMBED_ENABLED` is not set to `false` in your environment. + +**Widget returns 403 Forbidden** + +- If you configured allowed origins on your token, ensure the `Origin` + header sent by your dashboard matches one of the listed origins. +- Try removing the allowed origins restriction (set to empty/wildcard) + for testing. + +**Stats are empty (all zeros)** + +- The widget displays statistics for the token owner's account. Ensure you + have added books to your LibrisLog library. + +**CSP errors in browser console** + +- The embed endpoint sets a `Content-Security-Policy` header optimized + for its minimal HTML. If your dashboard adds a CSP of its own, you may + need to adjust it to allow inline styles from the iframe. diff --git a/docs/api/integrations/glance.md b/docs/api/integrations/glance.md new file mode 100644 index 00000000..5045a531 --- /dev/null +++ b/docs/api/integrations/glance.md @@ -0,0 +1,61 @@ +# Glance + +LibrisLog can be integrated into [Glance](https://github.com/glanceapp/glance), +a self-hosted dashboard for your services, using its +[custom API widget](https://github.com/glanceapp/glance/blob/main/docs/custom-api.md). + +This widget displays your reading statistics as styled stat cards directly on +your Glance dashboard. + +## Prerequisites + +- A running LibrisLog instance reachable from your Glance server +- An [API key](/api/integrations/#api-keys) with access to the + statistics endpoint + +## Configuration + +Add the following to your Glance `glance.yml` under the widget section: + +```yaml +widgets: + - type: custom-api + title: LibrisLog stats + cache: 1h + url: /api/books/stats + headers: + x-api-key: + Accept: application/json + template: | +
+
+
{{ .JSON.Int "books_read" | formatNumber }}
+
READ
+
+
+
{{ .JSON.Int "books_reading" | formatNumber }}
+
READING
+
+
+
{{ .JSON.Int "books_want_to_read" | formatNumber }}
+
WANT TO READ
+
+
+
{{ .JSON.Int "total_books" | formatNumber }}
+
TOTAL
+
+
+``` + +Replace the placeholders with your own values: + +| Placeholder | Example | Description | +|---|---|---| +| `` | `http://192.168.1.100:8000` | The base URL of your LibrisLog instance (http or https) | +| `` | `lk_nRHsF3jxIBDa9u....` | An API key with access to the statistics endpoint | + +## Result + +![Glance Widget (dark)](/screenshots/integrations-glance.png) + +![Glance Widget (light)](/screenshots/integrations-glance-light.png) diff --git a/docs/api/integrations/homarr.md b/docs/api/integrations/homarr.md new file mode 100644 index 00000000..ac468749 --- /dev/null +++ b/docs/api/integrations/homarr.md @@ -0,0 +1,58 @@ +# Homarr + +LibrisLog can be integrated into [Homarr](https://homarr.dev/), a modern +dashboard for your self-hosted services, using its +[iframe widget](https://homarr.dev/docs/widgets/iframe/). + +This displays your reading statistics as a self-contained stats card on your +Homarr dashboard. + +## Prerequisites + +- A running LibrisLog instance reachable from your Homarr server +- An [embed token](/api/integrations/embed-api#creating-an-embed-token) with + access to the embed widget endpoint +- Embed endpoints must be enabled (default: enabled). See the + [Embed API](/api/integrations/embed-api) page for details. + +## Configuration + +Add an **iframe widget** to your Homarr dashboard: + +1. In Homarr, navigate to your dashboard, click **Edit** → **Add a tile**. +2. Select the **Iframe** widget type. +3. Configure the widget with the following URL: + +``` +/embed/v1/stats?token= +``` + +Replace `` with your LibrisLog instance URL (e.g. +`https://librislog.example.com`) and `` with the token you +created. + +### Customizing the Look + +See the [Embed API style parameter reference](/api/integrations/embed-api#customizing-the-look) +for all available options (`theme`, `accent`, `radius`, `density`, +`hide_labels`, `show`, `lang`, `font_scale`, `layout`). + +Example: + +``` +/embed/v1/stats?token=&theme=dark&accent=%23f59e0b&density=compact&hide_labels=true +``` + +## Troubleshooting + +**Widget does not appear or is cut off** + +- Adjust the iframe widget size in Homarr. The widget is responsive and + will fit the available space. +- Try `density=compact` to reduce spacing. +- Try `hide_labels=true` for a more compact display. +- Use `show` to display only the stat keys you need. + +For other issues (invalid token, 403 Forbidden, empty stats, CSP errors), +see the [Embed API troubleshooting](/api/integrations/embed-api#troubleshooting) +guide. diff --git a/docs/api/integrations/index.md b/docs/api/integrations/index.md index 75017507..9635fbf4 100644 --- a/docs/api/integrations/index.md +++ b/docs/api/integrations/index.md @@ -14,15 +14,37 @@ API key to use them. You can create one either: [Headless Setup](/api/setup#3-create-an-api-key) guide for a CLI-based workflow. +## Embed Widgets (No Header Auth) + +Some dashboards only support iframe widgets and cannot set custom HTTP +headers. For these integrations you need an **embed token**, used with the +[Embed API](/api/integrations/embed-api): + + + +- **Create one** from your [Profile page](/guide/using-librislog/profile) under + "Embed Tokens". +- Embed tokens are **scoped to embed endpoints only** and can be revoked + or rotated independently of API keys. +- They are passed as query parameter (`?token=...`) in the widget URL. + ## Available Integrations +- [Embed API](/api/integrations/embed-api) — Generic embed widget API for + iframe-based dashboards. Uses scoped embed tokens and supports custom + styling. - [Dashy](/api/integrations/dashy) — Display your LibrisLog statistics as styled stat cards on a [Dashy](https://dashy.to/) dashboard using the HTML embedded widget. +- [Glance](/api/integrations/glance) — Display your LibrisLog statistics on a + [Glance](https://github.com/glanceapp/glance) dashboard using the custom API + widget. - [Home Assistant](/api/integrations/homeassistant) — Expose your LibrisLog reading statistics as sensors in [Home Assistant](https://www.home-assistant.io/) using the RESTful integration. +- [Homarr](/api/integrations/homarr) — Display your LibrisLog statistics on a + [Homarr](https://homarr.dev/) dashboard using the iframe widget. - [Homepage](/api/integrations/homepage) — Display your LibrisLog statistics on a [Homepage](https://gethomepage.dev/) dashboard using the custom API widget. diff --git a/docs/guide/database-layout.md b/docs/guide/database-layout.md new file mode 100644 index 00000000..db8e8e5b --- /dev/null +++ b/docs/guide/database-layout.md @@ -0,0 +1,3 @@ +# Database Layout + +> _Auto-generated from SQLModel metadata — run `npm run docs:gen-db` (or `uv run --directory backend python scripts/gen_db_docs.py`) to regenerate._ diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md index dde0f2fe..02fa02b5 100644 --- a/docs/guide/using-librislog/profile.md +++ b/docs/guide/using-librislog/profile.md @@ -26,6 +26,22 @@ Create and manage API keys for headless access to the REST API. Each key can hav See the [API Keys guide](/guide/api-keys) for detailed setup instructions. +## Embed Tokens + +Create scoped embed tokens for iframe dashboard integrations (e.g. Homarr). Each token can have an optional name and a comma-separated list of allowed origins. You can also configure an expiry date. + +To create a token: + +1. Enter a name for your token. +2. Optionally restrict allowed origins (comma-separated URLs). Leave empty for wildcard access. +3. Click **Add token**. +4. **Copy the displayed token immediately** — it is shown only once. + +Existing tokens can be **rotated** (revokes the old token and creates a new one with the same settings) or **deleted** from the list. + +See the [Embed API](/api/integrations/embed-api) integration guide for usage +details and a list of supported dashboard integrations. + ## Data Management Two data management tools are available: diff --git a/docs/package-lock.json b/docs/package-lock.json index 26a3f57e..9fb6e9b5 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -7,7 +7,8 @@ "name": "librislog-docs", "dependencies": { "viewerjs": "^1.11.7", - "vitepress-plugin-image-viewer": "^1.1.6" + "vitepress-plugin-image-viewer": "^1.1.6", + "vitepress-plugin-mermaid": "^2.0.17" }, "devDependencies": { "vitepress": "^1.6.4" @@ -17,7 +18,6 @@ "version": "1.18.1", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.1.tgz", "integrity": "sha512-aehCadlWOGvrT91KUIZpC0MbB8KBW9yUuvTJFd2xesR7le/IsT4nJUnjCCZ4ZqZCeTcPHPV5mo//fZ5oxcSVYw==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.1", @@ -33,7 +33,6 @@ "version": "1.17.7", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", @@ -44,7 +43,6 @@ "version": "1.17.7", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/autocomplete-shared": "1.17.7" @@ -57,7 +55,6 @@ "version": "1.17.7", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/autocomplete-shared": "1.17.7" @@ -71,7 +68,6 @@ "version": "1.17.7", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", - "dev": true, "license": "MIT", "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", @@ -82,7 +78,6 @@ "version": "5.52.1", "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.52.1.tgz", "integrity": "sha512-HmXOGBOAOJPounpBzBpuY0zDYeiCpxgHnQmuA7JO6ScukcBdGp3/XM9zJk5pJx/xNGD68mbPGXWpDxGtl6BwDQ==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.1", @@ -98,7 +93,6 @@ "version": "5.52.1", "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.52.1.tgz", "integrity": "sha512-5oo4+I8iixie9vXhCyNFCzeIr8pqA3FQ//VsLHTDvZAV4ttYOPGvYHGQq5NSalrLx5Jc3dRro/5uDOlnUMcBJg==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.1", @@ -114,7 +108,6 @@ "version": "5.52.1", "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.52.1.tgz", "integrity": "sha512-qCDoZfx5MpX7XQzvQ3bC4tSEMkQWQMaF/ABtLuoze03Y/flR563CCSws02qIJ23oX7lxl92LsilZjINVyTdtLw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14.0.0" @@ -124,7 +117,6 @@ "version": "5.52.1", "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.52.1.tgz", "integrity": "sha512-hnGs0/lsFJ2PWDxNBz7pxreXo/Xz7gxYRcfePBUjsH26ad0kU/sgnVZd9LwWBpsQv65z2jlb5dkyaB9WE9M9FQ==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.1", @@ -140,7 +132,6 @@ "version": "5.52.1", "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.52.1.tgz", "integrity": "sha512-2VxxNc/uBysyKvGeBdSM5n9eIDKH8kWD7wd9/yqbJAiVwU4Yv6tU1LSJusHKrXV/aCu1KW7t9Gug9QyeEmtn/Q==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.1", @@ -156,7 +147,6 @@ "version": "5.52.1", "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.52.1.tgz", "integrity": "sha512-O6mPtsw3xEfNOe6gWFpYLeAZAIljNa4Hgna3bq15PwyN7nbjTY0wXJFRbzs/0YVf75Br+SbOQUmjKxXYjDiSiQ==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.1", @@ -172,7 +162,6 @@ "version": "5.52.1", "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.52.1.tgz", "integrity": "sha512-gA8oJOV1LnQQkDf91iebNnFInHuW0gRPEgLSOQ7EfipCEjYTHm5swm1DlH9H5RaRw4RrHuzHBegnlzc0MAstcg==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.1", @@ -188,7 +177,6 @@ "version": "1.52.1", "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.52.1.tgz", "integrity": "sha512-U9zZfc5xIu9wRxZkt+HceJUAD4VKHKbAyLSloJdEyMRmphXeibfrY9cxqIXBcmPeZzGhn3Imb35Dq8l19PkJhw==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.1", @@ -204,7 +192,6 @@ "version": "1.52.1", "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.52.1.tgz", "integrity": "sha512-a3SGNceHmkQfq77iG8Ka+w1pvwfZa/0lzEIgse30fL0kD+yKnd/dg0dQvSfFPAEt2f21DMcGkDSSeJlO3KdQjQ==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.1", @@ -220,7 +207,6 @@ "version": "5.52.1", "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.52.1.tgz", "integrity": "sha512-z98QEguCFDpxb4S/PyrUK1igqF8tPsdbqOUUO6ON91vJ58w+Gwa6ncrI0oNXSFcrkxA5EqPKPQ2A1PBCn08TYQ==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.1", @@ -236,7 +222,6 @@ "version": "5.52.1", "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.52.1.tgz", "integrity": "sha512-CI7+/0I11QeZM59Uc8whd2or0kqzFVjpaPn9Qpwll/krHcBAxk24WkAQ6WX+IwDVMfpont4YGbKwAmCre3vE8Q==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.1" @@ -249,7 +234,6 @@ "version": "5.52.1", "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.52.1.tgz", "integrity": "sha512-S6bDuw9byfOvm3T71cgdoZgrgnZq6hpdMLkx52Louh57nUAmvGQESz2aojOynQHjbTiV55smvAFbgn0qT4tJrg==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.1" @@ -262,7 +246,6 @@ "version": "5.52.1", "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.52.1.tgz", "integrity": "sha512-tqZXM+54rWo4mk5jL5Z/flE11nPmNEdXwFBM5py9DkOmbjeCNemfVd45FyM97XdzfZ0dl9uOJC6PYn1FpkeyQg==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.1" @@ -271,11 +254,24 @@ "node": ">= 14.0.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -285,7 +281,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -295,7 +290,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -311,7 +305,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -321,18 +314,30 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT", + "peer": true + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/@docsearch/css": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", - "dev": true, "license": "MIT" }, "node_modules/@docsearch/js": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", - "dev": true, "license": "MIT", "dependencies": { "@docsearch/react": "3.8.2", @@ -343,7 +348,6 @@ "version": "3.8.2", "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/autocomplete-core": "1.17.7", @@ -379,7 +383,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -396,7 +399,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -413,7 +415,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -430,7 +431,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -447,7 +447,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -464,7 +463,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -481,7 +479,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -498,7 +495,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -515,7 +511,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -532,7 +527,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -549,7 +543,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -566,7 +559,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -583,7 +575,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -600,7 +591,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -617,7 +607,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -634,7 +623,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -651,7 +639,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -668,7 +655,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -685,7 +671,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -702,7 +687,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -719,7 +703,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -736,7 +719,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -753,7 +735,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -767,7 +748,6 @@ "version": "1.2.84", "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.84.tgz", "integrity": "sha512-v4JVu6xIewGoETD4mm2k6UAdFAbTlY1duw5ZNSxYORfs2yFsHDhoU9Omn/BgrV0nR/ptWkF3ZIr/ZHoYXI/6Jw==", - "dev": true, "license": "CC0-1.0", "dependencies": { "@iconify/types": "*" @@ -777,16 +757,59 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "dev": true, "license": "MIT" }, + "node_modules/@iconify/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, + "node_modules/@mermaid-js/mermaid-mindmap": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/mermaid-mindmap/-/mermaid-mindmap-9.3.0.tgz", + "integrity": "sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@braintree/sanitize-url": "^6.0.0", + "cytoscape": "^3.23.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.1.0", + "d3": "^7.0.0", + "khroma": "^2.0.0", + "non-layered-tidy-tree-layout": "^2.0.2" + } + }, + "node_modules/@mermaid-js/mermaid-mindmap/node_modules/@braintree/sanitize-url": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz", + "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==", + "license": "MIT", + "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==", + "license": "MIT", + "peer": true, + "dependencies": { + "@chevrotain/types": "~11.1.1" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", @@ -794,7 +817,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -808,7 +830,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -822,7 +843,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -836,7 +856,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -850,7 +869,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -864,7 +882,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -878,7 +895,6 @@ "cpu": [ "arm" ], - "dev": true, "libc": [ "glibc" ], @@ -895,7 +911,6 @@ "cpu": [ "arm" ], - "dev": true, "libc": [ "musl" ], @@ -912,7 +927,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -929,7 +943,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -946,7 +959,6 @@ "cpu": [ "loong64" ], - "dev": true, "libc": [ "glibc" ], @@ -963,7 +975,6 @@ "cpu": [ "loong64" ], - "dev": true, "libc": [ "musl" ], @@ -980,7 +991,6 @@ "cpu": [ "ppc64" ], - "dev": true, "libc": [ "glibc" ], @@ -997,7 +1007,6 @@ "cpu": [ "ppc64" ], - "dev": true, "libc": [ "musl" ], @@ -1014,7 +1023,6 @@ "cpu": [ "riscv64" ], - "dev": true, "libc": [ "glibc" ], @@ -1031,7 +1039,6 @@ "cpu": [ "riscv64" ], - "dev": true, "libc": [ "musl" ], @@ -1048,7 +1055,6 @@ "cpu": [ "s390x" ], - "dev": true, "libc": [ "glibc" ], @@ -1065,7 +1071,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -1082,7 +1087,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -1099,7 +1103,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1113,7 +1116,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1127,7 +1129,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1141,7 +1142,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1155,7 +1155,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1169,7 +1168,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1180,7 +1178,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", - "dev": true, "license": "MIT", "dependencies": { "@shikijs/engine-javascript": "2.5.0", @@ -1195,7 +1192,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", - "dev": true, "license": "MIT", "dependencies": { "@shikijs/types": "2.5.0", @@ -1207,7 +1203,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", - "dev": true, "license": "MIT", "dependencies": { "@shikijs/types": "2.5.0", @@ -1218,7 +1213,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", - "dev": true, "license": "MIT", "dependencies": { "@shikijs/types": "2.5.0" @@ -1228,7 +1222,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", - "dev": true, "license": "MIT", "dependencies": { "@shikijs/types": "2.5.0" @@ -1238,7 +1231,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", - "dev": true, "license": "MIT", "dependencies": { "@shikijs/core": "2.5.0", @@ -1249,7 +1241,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", - "dev": true, "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -1260,21 +1251,309 @@ "version": "10.0.2", "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT", + "peer": true + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "*" @@ -1284,14 +1563,12 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "dev": true, "license": "MIT" }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "dev": true, "license": "MIT", "dependencies": { "@types/linkify-it": "^5", @@ -1302,7 +1579,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "*" @@ -1312,35 +1588,49 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, "license": "MIT" }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", - "dev": true, "license": "MIT" }, "node_modules/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", - "dev": true, "license": "ISC" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "peer": true, + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, "license": "MIT", "engines": { "node": "^18.0.0 || >=20.0.0" @@ -1354,7 +1644,6 @@ "version": "3.5.34", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.3", @@ -1368,7 +1657,6 @@ "version": "3.5.34", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", - "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-core": "3.5.34", @@ -1379,7 +1667,6 @@ "version": "3.5.34", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.3", @@ -1397,7 +1684,6 @@ "version": "3.5.34", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", - "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.34", @@ -1408,7 +1694,6 @@ "version": "7.7.9", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", - "dev": true, "license": "MIT", "dependencies": { "@vue/devtools-kit": "^7.7.9" @@ -1418,7 +1703,6 @@ "version": "7.7.9", "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", - "dev": true, "license": "MIT", "dependencies": { "@vue/devtools-shared": "^7.7.9", @@ -1434,7 +1718,6 @@ "version": "7.7.9", "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", - "dev": true, "license": "MIT", "dependencies": { "rfdc": "^1.4.1" @@ -1444,7 +1727,6 @@ "version": "3.5.34", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", - "dev": true, "license": "MIT", "dependencies": { "@vue/shared": "3.5.34" @@ -1454,7 +1736,6 @@ "version": "3.5.34", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", - "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.34", @@ -1465,7 +1746,6 @@ "version": "3.5.34", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", - "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.34", @@ -1478,7 +1758,6 @@ "version": "3.5.34", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", - "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-ssr": "3.5.34", @@ -1492,14 +1771,12 @@ "version": "3.5.34", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", - "dev": true, "license": "MIT" }, "node_modules/@vueuse/core": { "version": "12.8.2", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/web-bluetooth": "^0.0.21", @@ -1515,7 +1792,6 @@ "version": "12.8.2", "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", - "dev": true, "license": "MIT", "dependencies": { "@vueuse/core": "12.8.2", @@ -1582,7 +1858,6 @@ "version": "12.8.2", "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -1592,7 +1867,6 @@ "version": "12.8.2", "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", - "dev": true, "license": "MIT", "dependencies": { "vue": "^3.5.13" @@ -1605,7 +1879,6 @@ "version": "5.52.1", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.52.1.tgz", "integrity": "sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==", - "dev": true, "license": "MIT", "dependencies": { "@algolia/abtesting": "1.18.1", @@ -1631,7 +1904,6 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -1641,7 +1913,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -1652,7 +1923,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -1663,7 +1933,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -1674,18 +1943,25 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true, "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", - "dev": true, "license": "MIT", "dependencies": { "is-what": "^5.2.0" @@ -1697,18 +1973,546 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC", + "peer": true + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "peer": true, + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT", + "peer": true + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1718,7 +2522,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, "license": "MIT", "dependencies": { "dequal": "^2.0.0" @@ -1728,18 +2531,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dompurify": { + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", + "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/emoji-regex-xs": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", - "dev": true, "license": "MIT" }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -1748,11 +2559,21 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-toolkit": { + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", + "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", + "license": "MIT", + "peer": true, + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1791,14 +2612,12 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, "license": "MIT" }, "node_modules/focus-trap": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", - "dev": true, "license": "MIT", "dependencies": { "tabbable": "^6.4.0" @@ -1808,7 +2627,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1819,11 +2637,17 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT", + "peer": true + }, "node_modules/hast-util-to-html": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -1847,7 +2671,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" @@ -1861,25 +2684,54 @@ "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "dev": true, "license": "MIT" }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "dev": true, "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-what": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1888,11 +2740,55 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "peer": true, + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT", + "peer": true + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -1902,14 +2798,25 @@ "version": "8.11.1", "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", - "dev": true, "license": "MIT" }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -1927,11 +2834,40 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mermaid": { + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", + "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "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", + "es-toolkit": "^1.45.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, "node_modules/micromark-util-character": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -1952,7 +2888,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -1969,7 +2904,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -1991,7 +2925,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -2008,7 +2941,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -2025,21 +2957,18 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", - "dev": true, "license": "MIT" }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, "funding": [ { "type": "github", @@ -2054,11 +2983,17 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/non-layered-tidy-tree-layout": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", + "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==", + "license": "MIT", + "optional": true + }, "node_modules/oniguruma-to-es": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex-xs": "^1.0.0", @@ -2066,25 +3001,54 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT", + "peer": true + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT", + "peer": true + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT", + "peer": true + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "peer": true, + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2113,7 +3077,6 @@ "version": "10.29.2", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", - "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -2124,7 +3087,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -2135,7 +3097,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", - "dev": true, "license": "MIT", "dependencies": { "regex-utilities": "^2.3.0" @@ -2145,7 +3106,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "dev": true, "license": "MIT", "dependencies": { "regex-utilities": "^2.3.0" @@ -2155,21 +3115,24 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "dev": true, "license": "MIT" }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, "license": "MIT" }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -2210,11 +3173,35 @@ "fsevents": "~2.3.2" } }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/search-insights": { "version": "2.17.3", "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "dev": true, "license": "MIT", "peer": true }, @@ -2222,7 +3209,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", - "dev": true, "license": "MIT", "dependencies": { "@shikijs/core": "2.5.0", @@ -2239,7 +3225,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2249,7 +3234,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -2260,7 +3244,6 @@ "version": "14.0.1", "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2270,7 +3253,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dev": true, "license": "MIT", "dependencies": { "character-entities-html4": "^2.0.0", @@ -2281,11 +3263,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT", + "peer": true + }, "node_modules/superjson": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "dev": true, "license": "MIT", "dependencies": { "copy-anything": "^4" @@ -2298,25 +3286,42 @@ "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", - "dev": true, "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true, "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.10" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -2330,7 +3335,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -2344,7 +3348,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -2358,7 +3361,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -2374,7 +3376,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -2385,11 +3386,24 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "peer": true, + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -2404,7 +3418,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -2425,7 +3438,6 @@ "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -2485,7 +3497,6 @@ "version": "1.6.4", "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", - "dev": true, "license": "MIT", "dependencies": { "@docsearch/css": "3.8.2", @@ -2532,11 +3543,23 @@ "viewerjs": "^1.11.6" } }, + "node_modules/vitepress-plugin-mermaid": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/vitepress-plugin-mermaid/-/vitepress-plugin-mermaid-2.0.17.tgz", + "integrity": "sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==", + "license": "MIT", + "optionalDependencies": { + "@mermaid-js/mermaid-mindmap": "^9.3.0" + }, + "peerDependencies": { + "mermaid": "10 || 11", + "vitepress": "^1.0.0 || ^1.0.0-alpha" + } + }, "node_modules/vue": { "version": "3.5.34", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", - "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.34", @@ -2558,7 +3581,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, "license": "MIT", "funding": { "type": "github", diff --git a/docs/package.json b/docs/package.json index fc1bc97c..7cb7a797 100644 --- a/docs/package.json +++ b/docs/package.json @@ -5,13 +5,15 @@ "scripts": { "docs:dev": "vitepress dev", "docs:build": "vitepress build", - "docs:preview": "vitepress preview" + "docs:preview": "vitepress preview", + "docs:gen-db": "uv run --directory ../backend python scripts/gen_db_docs.py" }, "devDependencies": { "vitepress": "^1.6.4" }, "dependencies": { "viewerjs": "^1.11.7", - "vitepress-plugin-image-viewer": "^1.1.6" + "vitepress-plugin-image-viewer": "^1.1.6", + "vitepress-plugin-mermaid": "^2.0.17" } } diff --git a/docs/public/screenshots/integrations-dashy-customapi.png b/docs/public/screenshots/integrations-dashy-customapi.png new file mode 100644 index 00000000..5c873630 Binary files /dev/null and b/docs/public/screenshots/integrations-dashy-customapi.png differ diff --git a/docs/public/screenshots/integrations-dashy.png b/docs/public/screenshots/integrations-dashy-embed.png similarity index 100% rename from docs/public/screenshots/integrations-dashy.png rename to docs/public/screenshots/integrations-dashy-embed.png diff --git a/docs/public/screenshots/integrations-glance-light.png b/docs/public/screenshots/integrations-glance-light.png new file mode 100644 index 00000000..7fa2572d Binary files /dev/null and b/docs/public/screenshots/integrations-glance-light.png differ diff --git a/docs/public/screenshots/integrations-glance.png b/docs/public/screenshots/integrations-glance.png new file mode 100644 index 00000000..72403c5d Binary files /dev/null and b/docs/public/screenshots/integrations-glance.png differ diff --git a/docs/public/screenshots/profile-thumb.png b/docs/public/screenshots/profile-thumb.png index 2623f87c..5cf41245 100644 Binary files a/docs/public/screenshots/profile-thumb.png and b/docs/public/screenshots/profile-thumb.png differ diff --git a/docs/public/screenshots/profile.png b/docs/public/screenshots/profile.png index 24325392..560380e5 100644 Binary files a/docs/public/screenshots/profile.png and b/docs/public/screenshots/profile.png differ diff --git a/frontend/e2e/specs/11-profile.spec.ts b/frontend/e2e/specs/11-profile.spec.ts index bbbcbae1..8815933d 100644 --- a/frontend/e2e/specs/11-profile.spec.ts +++ b/frontend/e2e/specs/11-profile.spec.ts @@ -54,4 +54,63 @@ test.describe('Profile', () => { await expect(page.locator('body')).toContainText(/key|api/i); } }); + + test('11.5 create embed token', async ({ page }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + await page.locator('#section-embed-tokens').scrollIntoViewIfNeeded(); + await page.waitForTimeout(500); + + const input = page.locator('input[name="embed-token-name"]'); + await input.fill('E2E Test Widget'); + await page.locator('#section-embed-tokens button.btn-primary').first().click(); + await page.waitForTimeout(1000); + + await expect(page.locator('#section-embed-tokens')).toContainText(/le_/); + + const tokenText = await page.locator('#section-embed-tokens div.font-mono.break-all').first().textContent(); + expect(tokenText).toBeTruthy(); + + if (tokenText) { + const backendHealthUrl = process.env.E2E_BACKEND_URL || 'http://backend:8000/api/health'; + const backendOrigin = new URL(backendHealthUrl).origin; + const iframeUrl = `${backendOrigin}/embed/v1/stats?token=${tokenText.trim()}`; + const resp = await page.request.get(iframeUrl); + expect(resp.status()).toBe(200); + const html = await resp.text(); + expect(html).toContain('Books'); + expect(html).toMatch(/^/i); + } + }); + + test('11.6 embed tokens section respects embed_enabled flag', async ({ page }) => { + // Mock the config endpoint to simulate embed being disabled + await page.route('**/api/config', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + embed_enabled: false, + dashboard_quote_enabled: true, + thalia_cover_search_enabled: false + }) + }); + }); + + await page.goto('/profile'); + await page.waitForTimeout(1000); + + // The embed tokens section should not exist in the DOM + await expect(page.locator('#section-embed-tokens')).toHaveCount(0); + + // Remove the route override so next navigation uses real config + await page.unroute('**/api/config'); + + await page.goto('/profile'); + await page.waitForTimeout(1000); + + // Now the embed tokens section should be visible + await expect(page.locator('#section-embed-tokens')).toBeVisible(); + }); }); diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index 1261fd30..f6491ae4 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -18,7 +18,7 @@ describe('api.covers.upload', () => { .mockResolvedValue({ ok: true, json: async () => ({ cover_url: '/api/covers/uploaded.jpg' }) - } as Response); + } as unknown as Response); const file = new File(['fake-image-bytes'], 'cover.jpg', { type: 'image/jpeg' }); const coverUrl = await api.covers.upload(file); @@ -59,7 +59,7 @@ describe('api.books.list', () => { ok: true, headers: { get: () => 'application/json' }, json: async () => mockResponse, - } as Response); + } as unknown as Response); const result = await api.books.list(); @@ -76,7 +76,7 @@ describe('api.books.list', () => { ok: true, headers: { get: () => 'application/json' }, json: async () => ({ books: [], total: 0 }), - } as Response); + } as unknown as Response); const result = await api.books.list(); @@ -89,7 +89,7 @@ describe('api.books.list', () => { ok: true, headers: { get: () => 'application/json' }, json: async () => ({ books: [], total: 0 }), - } as Response); + } as unknown as Response); await api.books.list({ status: 'read', q: 'dune', sort: 'title', order: 'asc' }); @@ -105,7 +105,7 @@ describe('api.books.list', () => { ok: true, headers: { get: () => 'application/json' }, json: async () => ({ books: [], total: 0 }), - } as Response); + } as unknown as Response); await api.books.list({ q: '' }); @@ -113,3 +113,109 @@ describe('api.books.list', () => { expect(url).not.toContain('q='); }); }); + +describe('api.profile.embedTokens', () => { + afterEach(() => { + apiKey.set(null); + csrfToken.set(null); + vi.restoreAllMocks(); + }); + + const mockHeaders = { get: () => 'application/json' }; + + it('listEmbedTokens calls GET /profile/embed-tokens', async () => { + apiKey.set('test-key'); + csrfToken.set('test-csrf'); + + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: mockHeaders, + json: async () => [{ id: 1, name: 'Test', token_prefix: 'le_abc', scopes: 'embed:stats:read', allowed_origins: null, expires_at: null, last_used_at: null, created_at: '2024-01-01T00:00:00Z' }] + } as unknown as Response); + + const result = await api.profile.listEmbedTokens(); + expect(result).toHaveLength(1); + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toContain('/profile/embed-tokens'); + }); + + it('createEmbedToken sends POST with name', async () => { + apiKey.set('test-key'); + csrfToken.set('test-csrf'); + + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: mockHeaders, + json: async () => ({ token: 'le_newtoken123', embed_token: { id: 2, name: 'My Widget', token_prefix: 'le_newtoken1', scopes: 'embed:stats:read', allowed_origins: null, expires_at: null, last_used_at: null, created_at: '2024-01-01T00:00:00Z' } }) + } as unknown as Response); + + const result = await api.profile.createEmbedToken({ name: 'My Widget' }); + expect(result.token).toBe('le_newtoken123'); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('POST'); + expect(init.body).toContain('My Widget'); + }); + + it('createEmbedToken includes allowed_origins and expires_at when provided', async () => { + apiKey.set('test-key'); + csrfToken.set('test-csrf'); + + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: mockHeaders, + json: async () => ({ token: 'le_newtoken456', embed_token: { id: 3, name: 'With Origins', token_prefix: 'le_newtoken4', scopes: 'embed:stats:read', allowed_origins: 'https://homarr.local', expires_at: null, last_used_at: null, created_at: '2024-01-01T00:00:00Z' } }) + } as unknown as Response); + + await api.profile.createEmbedToken({ name: 'With Origins', allowed_origins: 'https://homarr.local' }); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.body).toContain('https://homarr.local'); + }); + + it('updateEmbedToken sends PATCH with fields', async () => { + apiKey.set('test-key'); + csrfToken.set('test-csrf'); + + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: mockHeaders, + json: async () => ({ id: 1, name: 'Renamed', token_prefix: 'le_abc', scopes: 'embed:stats:read', allowed_origins: null, expires_at: null, last_used_at: null, created_at: '2024-01-01T00:00:00Z' }) + } as unknown as Response); + + await api.profile.updateEmbedToken(1, { name: 'Renamed' }); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('PATCH'); + expect(fetchMock.mock.calls[0][0]).toContain('/profile/embed-tokens/1'); + }); + + it('rotateEmbedToken sends POST .../rotate', async () => { + apiKey.set('test-key'); + csrfToken.set('test-csrf'); + + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: mockHeaders, + json: async () => ({ token: 'le_rotated', embed_token: { id: 4, name: 'Rotated', token_prefix: 'le_rotated', scopes: 'embed:stats:read', allowed_origins: null, expires_at: null, last_used_at: null, created_at: '2024-01-01T00:00:00Z' } }) + } as unknown as Response); + + await api.profile.rotateEmbedToken(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('POST'); + expect(fetchMock.mock.calls[0][0]).toContain('/profile/embed-tokens/1/rotate'); + }); + + it('deleteEmbedToken sends DELETE', async () => { + apiKey.set('test-key'); + csrfToken.set('test-csrf'); + + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: mockHeaders, + json: async () => ({}) + } as unknown as Response); + + await api.profile.deleteEmbedToken(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('DELETE'); + expect(fetchMock.mock.calls[0][0]).toContain('/profile/embed-tokens/1'); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index be93ad17..9378fd44 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,6 @@ import type { ApiKeyMeta, + AppConfig, BookListResponse, DataExportDataset, DataExportFormat, @@ -10,6 +11,8 @@ import type { DataImportPreviewResponse, DataImportValidateResponse, DataResetResponse, + EmbedTokenCreateResponse, + EmbedTokenMeta, HygieneAttribute, HygieneBatchUpdateRequest, HygieneBatchUpdateResponse, @@ -165,6 +168,34 @@ export const api = { return request(`/profile/api-keys/${id}`, { method: 'DELETE' }); }, + listEmbedTokens(): Promise { + return request('/profile/embed-tokens'); + }, + + createEmbedToken(data: { name: string; allowed_origins?: string | null; expires_at?: string | null }): Promise { + return request('/profile/embed-tokens', { + method: 'POST', + body: JSON.stringify(data) + }); + }, + + updateEmbedToken(id: number, data: { name?: string; allowed_origins?: string | null; expires_at?: string | null }): Promise { + return request(`/profile/embed-tokens/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); + }, + + rotateEmbedToken(id: number): Promise { + return request(`/profile/embed-tokens/${id}/rotate`, { + method: 'POST' + }); + }, + + deleteEmbedToken(id: number): Promise { + return request(`/profile/embed-tokens/${id}`, { method: 'DELETE' }); + }, + resetData(confirmation: string): Promise { return request('/profile/reset-data', { method: 'POST', @@ -221,6 +252,12 @@ export const api = { } }, + app: { + config(): Promise { + return request('/config'); + } + }, + oidc: { config(): Promise { return request('/oidc/config'); diff --git a/frontend/src/lib/components/AddBookModal.test.ts b/frontend/src/lib/components/AddBookModal.test.ts index 70ec91b7..55212d8c 100644 --- a/frontend/src/lib/components/AddBookModal.test.ts +++ b/frontend/src/lib/components/AddBookModal.test.ts @@ -42,6 +42,7 @@ vi.mock('html5-qrcode/esm/core', () => { vi.mock('html5-qrcode/esm/code-decoder', () => { const Html5QrcodeShim = class { + decodeAsync: () => Promise<{ text: string }>; constructor() { this.decodeAsync = function () { return Promise.resolve({ text: '' }); }; } diff --git a/frontend/src/lib/components/AutoSearchCoverModal.test.ts b/frontend/src/lib/components/AutoSearchCoverModal.test.ts index df6e4e46..7bc84f49 100644 --- a/frontend/src/lib/components/AutoSearchCoverModal.test.ts +++ b/frontend/src/lib/components/AutoSearchCoverModal.test.ts @@ -1,15 +1,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import AutoSearchCoverModal from './AutoSearchCoverModal.svelte'; +import type { CoverCandidate } from '$lib/types'; describe('AutoSearchCoverModal', () => { const onCancel = vi.fn(); const onSelect = vi.fn(); - const candidates = [ - { source: 'AbeBooks', url: 'https://example.com/1.jpg', available: true, filesize: 512, width: 200, height: 300 }, - { source: 'OpenLibrary', url: 'https://example.com/2.jpg', available: true, filesize: 1024 * 1024, width: 400, height: 600 }, - { source: 'Amazon', url: 'https://example.com/3.jpg', available: false, filesize: null, width: null, height: null } + const candidates: CoverCandidate[] = [ + { source: 'abebooks', url: 'https://example.com/1.jpg', available: true, filesize: 512, width: 200, height: 300, content_type: 'image/jpeg' }, + { source: 'openlibrary', url: 'https://example.com/2.jpg', available: true, filesize: 1024 * 1024, width: 400, height: 600, content_type: 'image/jpeg' }, + { source: 'amazon', url: 'https://example.com/3.jpg', available: false, filesize: null, width: null, height: null, content_type: null } ]; beforeEach(() => { @@ -76,7 +77,7 @@ describe('AutoSearchCoverModal', () => { await fireEvent.click(imgButtons[0]); expect(onSelect).toHaveBeenCalledOnce(); // First card is sorted by resolution descending: OpenLibrary (400x600) before AbeBooks (200x300) - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ source: 'OpenLibrary' })); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ source: 'openlibrary' })); }); it('calls onCancel when close button clicked', async () => { @@ -103,13 +104,15 @@ describe('AutoSearchCoverModal', () => { render(AutoSearchCoverModal, { props: { open: true, loading: false, candidates: [], error: null, onCancel, onSelect } }); - await fireEvent.click(document.querySelector('.modal-backdrop')); + const backdrop = document.querySelector('.modal-backdrop'); + expect(backdrop).toBeTruthy(); + await fireEvent.click(backdrop as Element); expect(onCancel).toHaveBeenCalledOnce(); }); it('shows n/a for missing filesize and resolution', () => { - const candidateNoMeta = [ - { source: 'Test', url: 'https://example.com/x.jpg', available: true, filesize: null, width: null, height: null } + const candidateNoMeta: CoverCandidate[] = [ + { source: 'amazon', url: 'https://example.com/x.jpg', available: true, filesize: null, width: null, height: null, content_type: null } ]; render(AutoSearchCoverModal, { props: { open: true, loading: false, candidates: candidateNoMeta, error: null, onCancel, onSelect } @@ -118,8 +121,8 @@ describe('AutoSearchCoverModal', () => { }); it('shows KB filesize label', () => { - const candidateKB = [ - { source: 'Test', url: 'https://example.com/kb.jpg', available: true, filesize: 5120, width: 100, height: 150 } + const candidateKB: CoverCandidate[] = [ + { source: 'amazon', url: 'https://example.com/kb.jpg', available: true, filesize: 5120, width: 100, height: 150, content_type: 'image/jpeg' } ]; render(AutoSearchCoverModal, { props: { open: true, loading: false, candidates: candidateKB, error: null, onCancel, onSelect } @@ -128,8 +131,8 @@ describe('AutoSearchCoverModal', () => { }); it('updates resolution map when image loads', async () => { - const candidateWithLoad = [ - { source: 'Test', url: 'https://example.com/load.jpg', available: true, filesize: 1000, width: null, height: null } + const candidateWithLoad: CoverCandidate[] = [ + { source: 'amazon', url: 'https://example.com/load.jpg', available: true, filesize: 1000, width: null, height: null, content_type: null } ]; render(AutoSearchCoverModal, { props: { open: true, loading: false, candidates: candidateWithLoad, error: null, onCancel, onSelect } @@ -146,8 +149,8 @@ describe('AutoSearchCoverModal', () => { }); it('skips resolution update when image has no natural dimensions', async () => { - const candidateWithLoad = [ - { source: 'Test', url: 'https://example.com/load2.jpg', available: true, filesize: 1000, width: null, height: null } + const candidateWithLoad: CoverCandidate[] = [ + { source: 'amazon', url: 'https://example.com/load2.jpg', available: true, filesize: 1000, width: null, height: null, content_type: null } ]; render(AutoSearchCoverModal, { props: { open: true, loading: false, candidates: candidateWithLoad, error: null, onCancel, onSelect } diff --git a/frontend/src/lib/components/BookDetailDialog.test.ts b/frontend/src/lib/components/BookDetailDialog.test.ts index fae7e163..5856f650 100644 --- a/frontend/src/lib/components/BookDetailDialog.test.ts +++ b/frontend/src/lib/components/BookDetailDialog.test.ts @@ -2,22 +2,23 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; import { writable } from 'svelte/store'; import BookDetailDialog from './BookDetailDialog.svelte'; +import type { Book, ReadingProgressEntry } from '$lib/types'; vi.mock('svelte-chartjs', () => ({ Line: vi.fn().mockImplementation(() => ({ default: {} })), })); -const mockProgressList = vi.fn(async () => []); -const mockProgressCreate = vi.fn(async (_bookId: number, _page: number) => ({ id: 1, book_id: _bookId, page: _page, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' })); -const mockProgressDelete = vi.fn(async () => {}); -const mockBooksDelete = vi.fn(async () => {}); -const mockBooksUpdate = vi.fn(async (_id: number, _data: unknown) => ({ ..._data, id: _id })); +const mockProgressList = vi.fn(async (_bookId: number): Promise => []); +const mockProgressCreate = vi.fn(async (_bookId: number, _page: number): Promise => ({ id: 1, book_id: _bookId, page: _page, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' })); +const mockProgressDelete = vi.fn(async (_bookId: number, _entryId: number) => {}); +const mockBooksDelete = vi.fn(async (_id: number) => {}); +const mockBooksUpdate = vi.fn(async (_id: number, _data: Partial) => ({ ...mockBook, ..._data, id: _id })); const mockToastsAdd = vi.fn(); vi.mock('$lib/api', () => ({ api: { books: { - update: (id: number, data: unknown) => mockBooksUpdate(id, data), + update: (id: number, data: Partial) => mockBooksUpdate(id, data), progress: { list: (bookId: number) => mockProgressList(bookId), create: (bookId: number, page: number) => mockProgressCreate(bookId, page), diff --git a/frontend/src/lib/components/BookDrawer.test.ts b/frontend/src/lib/components/BookDrawer.test.ts index b551a521..16e70e93 100644 --- a/frontend/src/lib/components/BookDrawer.test.ts +++ b/frontend/src/lib/components/BookDrawer.test.ts @@ -3,11 +3,11 @@ import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/sv import { writable } from 'svelte/store'; import BookDrawer from './BookDrawer.svelte'; -const mockBooksUpdate = vi.fn(async () => ({ id: 1, title: 'Updated' })); -const mockTransitionStatus = vi.fn(async () => ({ book: { id: 1, title: 'Updated' }, date_conflict: null })); -const mockSuggestionsAuthors = vi.fn(async () => ['Author 1']); -const mockSuggestionsPublishers = vi.fn(async () => ['Publisher 1']); -const mockSuggestionsTags = vi.fn(async () => ['Tag 1']); +const mockBooksUpdate = vi.fn(async (_id: number, _data: unknown) => ({ id: 1, title: 'Updated' })); +const mockTransitionStatus = vi.fn(async (_id: number, _data: unknown) => ({ book: { id: 1, title: 'Updated' }, date_conflict: null })); +const mockSuggestionsAuthors = vi.fn(async (_q: string) => ['Author 1']); +const mockSuggestionsPublishers = vi.fn(async (_q: string) => ['Publisher 1']); +const mockSuggestionsTags = vi.fn(async (_q: string) => ['Tag 1']); const mockToastsAdd = vi.fn(); vi.mock('$lib/api', () => ({ @@ -33,6 +33,7 @@ vi.mock('html5-qrcode/esm/core', () => ({ vi.mock('html5-qrcode/esm/code-decoder', () => { const Html5QrcodeShim = class { + decodeAsync: () => Promise<{ text: string }>; constructor() { this.decodeAsync = function () { return Promise.resolve({ text: '' }); }; } diff --git a/frontend/src/lib/components/CoverCandidateGrid.test.ts b/frontend/src/lib/components/CoverCandidateGrid.test.ts index e2bffec3..64c9c620 100644 --- a/frontend/src/lib/components/CoverCandidateGrid.test.ts +++ b/frontend/src/lib/components/CoverCandidateGrid.test.ts @@ -4,10 +4,10 @@ import CoverCandidateGrid from './CoverCandidateGrid.svelte'; import type { CoverCandidate } from '$lib/types'; const candidates: CoverCandidate[] = [ - { source: 'abebooks', url: 'https://example.com/1.jpg', available: true, filesize: 20408, width: 200, height: 300 }, - { source: 'hardcover', url: 'https://example.com/2.jpg', available: true, filesize: 3706413, width: 500, height: 800 }, - { source: 'amazon', url: 'https://example.com/3.jpg', available: false, filesize: null, width: null, height: null }, - { source: 'thalia', url: 'https://example.com/4.jpg', available: false, filesize: 12036, width: null, height: null } + { source: 'abebooks', url: 'https://example.com/1.jpg', available: true, filesize: 20408, width: 200, height: 300, content_type: 'image/jpeg' }, + { source: 'hardcover', url: 'https://example.com/2.jpg', available: true, filesize: 3706413, width: 500, height: 800, content_type: 'image/jpeg' }, + { source: 'amazon', url: 'https://example.com/3.jpg', available: false, filesize: null, width: null, height: null, content_type: null }, + { source: 'thalia', url: 'https://example.com/4.jpg', available: false, filesize: 12036, width: null, height: null, content_type: null } ]; describe('CoverCandidateGrid', () => { @@ -69,16 +69,16 @@ describe('CoverCandidateGrid', () => { }); it('shows n/a for missing filesize and resolution when unloaded', () => { - const missing = [ - { source: 'amazon', url: 'https://example.com/x.jpg', available: true, filesize: null, width: null, height: null } + const missing: CoverCandidate[] = [ + { source: 'amazon', url: 'https://example.com/x.jpg', available: true, filesize: null, width: null, height: null, content_type: null } ]; render(CoverCandidateGrid, { props: { loading: false, candidates: missing, onSelect: vi.fn() } }); expect(document.body.textContent).toContain('n/a'); }); it('updates resolution from image onload event', async () => { - const single = [ - { source: 'abebooks', url: 'https://example.com/load.jpg', available: true, filesize: 1000, width: null, height: null } + const single: CoverCandidate[] = [ + { source: 'abebooks', url: 'https://example.com/load.jpg', available: true, filesize: 1000, width: null, height: null, content_type: null } ]; render(CoverCandidateGrid, { props: { loading: false, candidates: single, onSelect: vi.fn() } }); const img = document.querySelector('img'); diff --git a/frontend/src/lib/components/DataExport.test.ts b/frontend/src/lib/components/DataExport.test.ts index 98bbb8f7..08048f98 100644 --- a/frontend/src/lib/components/DataExport.test.ts +++ b/frontend/src/lib/components/DataExport.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; import DataExport from './DataExport.svelte'; -const mockExportData = vi.fn(async () => new Blob(['test'])); +const mockExportData = vi.fn(async (_params: unknown) => new Blob(['test'])); const mockToastsAdd = vi.fn(); vi.mock('$lib/api', () => ({ diff --git a/frontend/src/lib/components/DataImport.test.ts b/frontend/src/lib/components/DataImport.test.ts index 4c9e560e..8f62c411 100644 --- a/frontend/src/lib/components/DataImport.test.ts +++ b/frontend/src/lib/components/DataImport.test.ts @@ -3,25 +3,25 @@ import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/sv import { writable } from 'svelte/store'; import DataImport from './DataImport.svelte'; -const mockParseImportFile = vi.fn(async () => ({ +const mockParseImportFile = vi.fn(async (_file: File) => ({ file_id: 'test-file-123', format: 'csv' as const, source_fields: ['Book Title', 'Author Name', 'ISBN'], sample_rows: [{ 'Book Title': 'Dune', 'Author Name': 'Frank Herbert', 'ISBN': '978-3-16-148410-0' }], row_count: 1 })); -const mockSuggestMapping = vi.fn(async () => ({ +const mockSuggestMapping = vi.fn(async (_fileId: string) => ({ suggested_mapping: { title: 'Book Title', author: 'Author Name', isbn: 'ISBN' }, db_fields: ['title', 'author', 'isbn', 'publisher', 'page_count'] })); -const mockValidateImport = vi.fn(async () => ({ +const mockValidateImport = vi.fn(async (_params: unknown) => ({ valid: true, row_count: 1, warnings: [], errors: [] })); const mockListMappings = vi.fn(async () => []); -const mockExecuteImport = vi.fn(async function* () { +const mockExecuteImport = vi.fn(async function* (_params: unknown) { yield { event: 'start', total_rows: 1 }; yield { event: 'progress', processed: 1, total: 1, percent: 100 }; yield { event: 'complete', imported: 1, failed: 0, failures: [] }; diff --git a/frontend/src/lib/components/StarRating.test.ts b/frontend/src/lib/components/StarRating.test.ts index 063c3f6b..ed3df54b 100644 --- a/frontend/src/lib/components/StarRating.test.ts +++ b/frontend/src/lib/components/StarRating.test.ts @@ -56,7 +56,7 @@ describe('StarRating', () => { }); try { const { container } = render(StarRating, { props: { value: null } }); - const radios = container.querySelectorAll('input[type="radio"]'); + const radios = container.querySelectorAll('input[type="radio"]'); expect(radios[0].name).toMatch(/^rating-\d+$/); expect(radios[0].name).toBe(radios[1].name); } finally { diff --git a/frontend/src/lib/components/Toaster.test.ts b/frontend/src/lib/components/Toaster.test.ts index 8d09ce3f..5b139d3c 100644 --- a/frontend/src/lib/components/Toaster.test.ts +++ b/frontend/src/lib/components/Toaster.test.ts @@ -1,32 +1,42 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/svelte'; -import { writable } from 'svelte/store'; import Toaster from './Toaster.svelte'; -// Create mock store inside a helper that can be used by both mock and tests -const createMockToasts = () => writable>([]); +type ToastItem = { id: number; message: string; level: string }; + +function createStore(initial: ToastItem[]) { + let value = initial; + const subscribers = new Set<(next: ToastItem[]) => void>(); + return { + subscribe(run: (next: ToastItem[]) => void) { + run(value); + subscribers.add(run); + return () => subscribers.delete(run); + }, + set(next: ToastItem[]) { + value = next; + subscribers.forEach((run) => run(value)); + } + }; +} vi.mock('$lib/toasts', async () => { - const { writable } = await import('svelte/store'); - const store = writable>([]); + const store = createStore([]); return { toasts: { subscribe: store.subscribe, add: vi.fn(), - remove: vi.fn() - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ToastLevel: {} as any, - // Expose for tests - _mockStore: store + remove: vi.fn(), + __set: store.set + } }; }); -import { toasts, _mockStore } from '$lib/toasts'; +import { toasts } from '$lib/toasts'; describe('Toaster', () => { afterEach(() => { - _mockStore.set([]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([]); cleanup(); vi.clearAllMocks(); }); @@ -37,13 +47,13 @@ describe('Toaster', () => { }); it('renders a toast message', () => { - _mockStore.set([{ id: 1, message: 'Book saved', level: 'success' }]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([{ id: 1, message: 'Book saved', level: 'success' }]); render(Toaster); expect(screen.getByText('Book saved')).toBeInTheDocument(); }); it('renders multiple toasts', () => { - _mockStore.set([ + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([ { id: 1, message: 'First toast', level: 'info' }, { id: 2, message: 'Second toast', level: 'warning' } ]); @@ -53,7 +63,7 @@ describe('Toaster', () => { }); it('calls remove when dismiss button clicked', async () => { - _mockStore.set([{ id: 42, message: 'Dismiss me', level: 'error' }]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([{ id: 42, message: 'Dismiss me', level: 'error' }]); render(Toaster); const dismissBtn = screen.getByRole('button', { name: 'Dismiss' }); @@ -63,25 +73,25 @@ describe('Toaster', () => { }); it('applies correct alert class for error level', () => { - _mockStore.set([{ id: 1, message: 'Error!', level: 'error' }]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([{ id: 1, message: 'Error!', level: 'error' }]); const { container } = render(Toaster); expect(container.querySelector('.alert-error')).toBeInTheDocument(); }); it('applies correct alert class for success level', () => { - _mockStore.set([{ id: 1, message: 'Success!', level: 'success' }]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([{ id: 1, message: 'Success!', level: 'success' }]); const { container } = render(Toaster); expect(container.querySelector('.alert-success')).toBeInTheDocument(); }); it('applies correct alert class for warning level', () => { - _mockStore.set([{ id: 1, message: 'Warning!', level: 'warning' }]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([{ id: 1, message: 'Warning!', level: 'warning' }]); const { container } = render(Toaster); expect(container.querySelector('.alert-warning')).toBeInTheDocument(); }); it('applies correct alert class for info level', () => { - _mockStore.set([{ id: 1, message: 'Info!', level: 'info' }]); + (toasts as unknown as { __set: (items: ToastItem[]) => void }).__set([{ id: 1, message: 'Info!', level: 'info' }]); const { container } = render(Toaster); expect(container.querySelector('.alert-info')).toBeInTheDocument(); }); diff --git a/frontend/src/lib/components/UserMenu.svelte b/frontend/src/lib/components/UserMenu.svelte index 25e6af82..86fa5959 100644 --- a/frontend/src/lib/components/UserMenu.svelte +++ b/frontend/src/lib/components/UserMenu.svelte @@ -6,7 +6,7 @@ import { _ } from '$lib/i18n'; import { cycleTheme, applyThemeToDocument, saveThemeToStorage, getThemeMode, getThemeIcon, getCustomTheme, getThemeVersion } from '$lib/stores/theme'; import AnimalAvatar from '$lib/components/AnimalAvatar.svelte'; - import { Sun, Moon, Palette, CloudDownload } from '@lucide/svelte'; + import { Sun, Moon, Palette, CloudDownload, ExternalLink } from '@lucide/svelte'; let { floating = true, updateInfo = null }: { floating?: boolean; updateInfo?: UpdateInfo | null } = $props(); @@ -108,6 +108,12 @@ {/if}
  • (open = false)}>{$_('user.profile')}
  • (open = false)}>{$_('user.about')}
  • +
  • + (open = false)}> + + {$_('user.docs')} + +