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
+
-
+### HTML Embed Widget
+
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
+
+
+
+
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')}
+
+