diff --git a/.env.example b/.env.example index 6e9627bb..bc6123dc 100644 --- a/.env.example +++ b/.env.example @@ -47,4 +47,11 @@ MAIL_FROM_NAME=LibrisLog MAIL_STARTTLS=True MAIL_SSL_TLS=False PUBLIC_APP_URL=http://localhost:5173 -PASSWORD_RESET_TOKEN_MAX_AGE=3600 \ No newline at end of file +PASSWORD_RESET_TOKEN_MAX_AGE=3600 + +# Anonymous installation telemetry (see docs: guide/telemetry) +# Sends a minimal, anonymous installation census heartbeat (install id, version, os, architecture, runtime). +# Set TELEMETRY_DISABLED=true to opt out completely. +TELEMETRY_DISABLED=false +TELEMETRY_ENDPOINT=https://metrics.librislog.app/api/telemetry +TELEMETRY_TIMEOUT_SECONDS=10 \ No newline at end of file diff --git a/README.md b/README.md index dca46a07..0e7edcd0 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Open **http://localhost:8001** and create your account. ## Why LibrisLog? -- **Your data, your rules.** Fully self-hosted under MIT license — no ads, no tracking, no vendor lock-in. A single SQLite file you can back up anytime. +- **Your data, your rules.** Fully self-hosted under MIT license — no ads, no user tracking, no vendor lock-in. A single SQLite file you can back up anytime. - **No API keys required.** Works with Open Library out of the box. Add Google Books or Hardcover.app tokens optionally for richer search results. - **Rich insights from day one.** Calendar heatmap, language/status/page distribution charts, books finished per month/year, top authors — all on your hardware. - **Multi-user from the start.** User roles (admin/user), optional OIDC SSO, per-user libraries. One instance works for your whole household or small group. @@ -105,6 +105,19 @@ uv run uvicorn app.main:app --reload --- +## Telemetry + +LibrisLog sends a minimal, anonymous installation census (installation id, version, OS, CPU architecture, runtime) to understand how many installations exist and on which platforms they run. No user, book, reading, host, or personal data is ever collected, and it can be disabled with `TELEMETRY_DISABLED=true`. + +Both sides are fully open and verifiable: + +- **Telemetry server source:** [github.com/codebude/librislog-telemetry](https://github.com/codebude/librislog-telemetry) +- **Live aggregated results:** [metrics.librislog.app](https://metrics.librislog.app/) + +See [docs.librislog.app/guide/telemetry](https://docs.librislog.app/guide/telemetry) for exactly what is and is not collected. + +--- + ## Stack | Layer | Technology | diff --git a/backend/alembic/versions/c3d4e5f6a7b8_add_installation_info_table.py b/backend/alembic/versions/c3d4e5f6a7b8_add_installation_info_table.py new file mode 100644 index 00000000..f6059cad --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b8_add_installation_info_table.py @@ -0,0 +1,40 @@ +"""add installation_info table for anonymous telemetry id + +Revision ID: c3d4e5f6a7b8 +Revises: a1b2c3d4e5f7 +Create Date: 2026-09-01 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine.reflection import Inspector + + +# revision identifiers, used by Alembic. +revision: str = "c3d4e5f6a7b8" +down_revision: Union[str, Sequence[str], None] = "a1b2c3d4e5f7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create the installation_info singleton table.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + tables = inspector.get_table_names() + if "installation_info" not in tables: + op.create_table( + "installation_info", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("installation_id", sa.String(length=64), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + """Drop the installation_info table.""" + op.drop_table("installation_info") \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index ebf8adeb..9876a1d6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,6 +1,6 @@ from typing import List -from pydantic import field_validator +from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -53,6 +53,9 @@ class Settings(BaseSettings): password_reset_token_max_age: int = 3600 public_app_url: str = "http://localhost:5173" forwarded_allow_ips: str = "*" + telemetry_disabled: bool = False + telemetry_endpoint: str = "https://metrics.librislog.app/api/telemetry" + telemetry_timeout_seconds: float = Field(default=10.0, gt=0) @field_validator("api_key_encryption_key") @classmethod diff --git a/backend/app/main.py b/backend/app/main.py index 79638caa..fa734698 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,9 @@ 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 +from app.services.telemetry import send_telemetry_once + +_TELEMETRY_INTERVAL_SECONDS = 24 * 3600 logger = logging.getLogger(__name__) @@ -59,6 +62,20 @@ async def _periodic_maintenance(interval_hours: int = 1) -> None: logger.warning("Periodic maintenance failed (%d): %s", failures, exc) +async def _telemetry_heartbeat() -> None: + """Send one telemetry heartbeat at startup, then every 24 hours. + + Telemetry is an installation census, not event tracking. ``send_telemetry_once`` + never raises, so failures cannot interrupt the loop or the application. + """ + while True: + try: + await send_telemetry_once() + except Exception as exc: # noqa: BLE001 — telemetry must never interfere + logger.debug("Telemetry heartbeat failed: %s", exc) + await asyncio.sleep(_TELEMETRY_INTERVAL_SECONDS) + + @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan: create required directories and start background tasks.""" @@ -73,12 +90,21 @@ async def lifespan(app: FastAPI): ) maintenance_task = asyncio.create_task(_periodic_maintenance()) + telemetry_task = ( + asyncio.create_task(_telemetry_heartbeat()) if not settings.telemetry_disabled else None + ) yield maintenance_task.cancel() try: await maintenance_task except asyncio.CancelledError: pass + if telemetry_task is not None: + telemetry_task.cancel() + try: + await telemetry_task + except asyncio.CancelledError: + pass if __git_sha__ != "unknown" and __version__.find(__git_sha__[:7]) == -1: diff --git a/backend/app/models.py b/backend/app/models.py index 99e771ea..67d2d656 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -303,3 +303,25 @@ class ImportMapping(SQLModel, table=True): default_factory=utcnow, sa_column=Column(UtcDateTime, default=utcnow) ) + + +class InstallationInfo(SQLModel, table=True): + """Singleton row storing the anonymous installation id for telemetry. + + The id is a random UUIDv4 generated on first startup and persisted in the + database so it stays stable across container restarts and updates. It is + never derived from any machine-specific identifier. + """ + + __tablename__: str = "installation_info" + + id: int = Field(default=1, primary_key=True) + installation_id: str = Field(max_length=64) + created_at: datetime = Field( + default_factory=utcnow, + sa_column=Column(UtcDateTime, default=utcnow) + ) + updated_at: datetime = Field( + default_factory=utcnow, + sa_column=Column(UtcDateTime, default=utcnow) + ) diff --git a/backend/app/services/telemetry.py b/backend/app/services/telemetry.py new file mode 100644 index 00000000..fa9e9642 --- /dev/null +++ b/backend/app/services/telemetry.py @@ -0,0 +1,206 @@ +"""Anonymous, privacy-focused installation telemetry (best-effort). + +Sends a minimal heartbeat — installation id, version, OS, CPU architecture +and runtime — to the LibrisLog telemetry API so that the project can track +how many installations exist, which versions are in use and on which +platforms they run. + +This is an installation census, not user-behavior tracking. No user, book, +reading, host, network or configuration data is ever collected. The payload +is built strictly from the allow-list in :data:`TELEMETRY_FIELDS` and is +validated against it before sending. + +Telemetry is best-effort and must never interfere with LibrisLog: network +failures are swallowed and only logged at debug level. +""" + +import asyncio +import logging +import os +import platform +from datetime import datetime, timezone +from typing import Optional +from uuid import uuid4 + +import httpx +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session + +from app._build_info import __version__ +from app.config import settings +from app.database import engine +from app.models import InstallationInfo + +logger = logging.getLogger(__name__) + +# Message version of the telemetry API schema (TelemetryInV1). +MESSAGE_VERSION = 1 + +# Fixed, generic user-agent so the transport carries no client-identifying +# details (httpx would otherwise send ``python-httpx/``). +_USER_AGENT = "librislog-telemetry/1" + +# Strict allow-list: the only fields a telemetry payload may ever contain. +TELEMETRY_FIELDS = frozenset( + { + "message_version", + "installation_id", + "version", + "os", + "architecture", + "runtime", + "client_ts", + } +) + +# Marker files indicating a containerized runtime. Kept generic on purpose: +# LibrisLog may run under Docker, containerd, Kubernetes, Podman, etc. +_CONTAINER_MARKERS = ("/.dockerenv", "/.containerenv") + +# Runtime substrings looked up in /proc/1/cgroup to detect containerized +# environments that do not leave a marker file (e.g. containerd/Kubernetes). +_CONTAINER_CGROUP_MARKERS = ("docker", "containerd", "kubepods", "libpod", "lxc") + + +def normalize_os(system: str | None) -> str: + """Normalize ``platform.system()`` output to lowercase.""" + return (system or "").strip().lower() + + +def normalize_architecture(machine: str | None) -> str: + """Map common CPU architecture names to canonical values. + + ``x86_64``/``AMD64`` -> ``amd64``, ``aarch64``/``arm64`` -> ``arm64``. + Anything else is reported as ``unknown``. + """ + arch = (machine or "").strip().lower() + if arch in {"x86_64", "amd64", "x64"}: + return "amd64" + if arch in {"aarch64", "arm64"}: + return "arm64" + return "unknown" + + +def _container_marker_present() -> bool: + """Return True when a well-known container marker file exists.""" + return any(os.path.exists(marker) for marker in _CONTAINER_MARKERS) + + +def _cgroup_container_signal() -> bool | None: + """Derive a container signal from ``/proc/1/cgroup`` without capturing it. + + Returns True when a container runtime path is present, False when the file + is readable but contains no runtime marker, and None when it cannot be + read. Only a boolean is derived — the file content (including any cgroup or + container IDs) is never stored, logged, or sent. + """ + try: + with open("/proc/1/cgroup", "r", encoding="utf-8", errors="ignore") as fh: + content = fh.read() + except OSError: + return None + return any(marker in content for marker in _CONTAINER_CGROUP_MARKERS) + + +def detect_runtime() -> str: + """Detect whether LibrisLog runs inside a container or directly on a host. + + Returns ``container`` when a marker file is present or a container runtime + appears in the cgroup paths, ``baremetal`` when the environment is readable + and shows no container, and ``unknown`` when it cannot be determined. + Container IDs and other environment-specific identifiers are never + inspected, collected, or reported. + """ + if _container_marker_present(): + return "container" + signal = _cgroup_container_signal() + if signal is True: + return "container" + if signal is False: + return "baremetal" + return "unknown" + + +def collect_system_info() -> dict[str, str]: + """Collect the anonymous system attributes reported in the payload.""" + return { + "os": normalize_os(platform.system()), + "architecture": normalize_architecture(platform.machine()), + "runtime": detect_runtime(), + } + + +def get_or_create_installation_id(session: Session) -> str: + """Return the persisted installation id, creating a random UUIDv4 once. + + The id is generated with ``uuid.uuid4()`` — a cryptographically random + UUID completely independent of the host. It is stored in the database so + it remains stable across restarts and updates. Deleting the database and + reinstalling produces a fresh id. + + If two processes race on first boot and both try to insert the singleton + row, the loser of the unique-primary-key race rolls back and re-reads the + winner's row. + """ + info = session.get(InstallationInfo, 1) + if info is None: + try: + info = InstallationInfo(id=1, installation_id=str(uuid4())) + session.add(info) + session.commit() + session.refresh(info) + except IntegrityError: + session.rollback() + info = session.get(InstallationInfo, 1) + if info is None: + raise + return info.installation_id + + +def build_payload(installation_id: str, client_ts: Optional[datetime] = None) -> dict[str, object]: + """Build the telemetry payload using only allow-listed fields.""" + system = collect_system_info() + payload = { + "message_version": MESSAGE_VERSION, + "installation_id": installation_id, + "version": __version__, + "os": system["os"], + "architecture": system["architecture"], + "runtime": system["runtime"], + "client_ts": (client_ts or datetime.now(timezone.utc)).isoformat(), + } + if set(payload) != TELEMETRY_FIELDS: + raise RuntimeError("telemetry payload contains fields outside the allow-list") + return payload + + +def _load_installation_id() -> str: + """Load (creating on first run) the installation id in its own session. + + The session is created, used and closed entirely inside the executor + thread, so the event loop thread never touches a live SQLModel session. + """ + with Session(engine) as session: + return get_or_create_installation_id(session) + + +async def send_telemetry_once() -> None: + """Send one best-effort telemetry heartbeat. + + Never raises: startup and the heartbeat loop must not be affected by + telemetry problems. Failures are logged at debug level only. + """ + if settings.telemetry_disabled: + return + loop = asyncio.get_running_loop() + try: + installation_id = await loop.run_in_executor(None, _load_installation_id) + payload = build_payload(installation_id) + async with httpx.AsyncClient( + timeout=settings.telemetry_timeout_seconds, headers={"User-Agent": _USER_AGENT} + ) as client: + response = await client.post(settings.telemetry_endpoint, json=payload) + response.raise_for_status() + logger.debug("Telemetry heartbeat sent") + except Exception as exc: # noqa: BLE001 — telemetry must never interfere + logger.debug("Telemetry heartbeat failed: %s", exc) \ No newline at end of file diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ecc0c7c7..2852bdbb 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,8 @@ from sqlmodel.pool import StaticPool os.environ.setdefault("API_KEY_ENCRYPTION_KEY", "test-api-key-encryption-secret") +# Telemetry must never fire during the test suite (no network, no dev-DB writes). +os.environ["TELEMETRY_DISABLED"] = "true" from app.auth import encrypt_api_key, generate_api_key, get_api_key_prefix, get_password_hash, hash_api_key from app.models import ApiKey, User, UserRole, UserSettings diff --git a/backend/tests/test_telemetry.py b/backend/tests/test_telemetry.py new file mode 100644 index 00000000..aa1a9143 --- /dev/null +++ b/backend/tests/test_telemetry.py @@ -0,0 +1,474 @@ +"""Tests for anonymous installation telemetry.""" + +import asyncio +import platform +import socket +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from sqlmodel import Session, SQLModel, create_engine +from sqlmodel.pool import StaticPool + +from app.models import InstallationInfo + + +# --- OS / architecture / runtime normalization --------------------------------- + + +def test_os_normalized_to_lowercase() -> None: + """OS names should be normalized to lowercase.""" + from app.services.telemetry import normalize_os + + assert normalize_os("Linux") == "linux" + assert normalize_os("Darwin") == "darwin" + assert normalize_os("Windows") == "windows" + assert normalize_os("") == "" + assert normalize_os(None) == "" + + +@pytest.mark.parametrize( + ("machine", "expected"), + [ + ("x86_64", "amd64"), + ("AMD64", "amd64"), + ("x64", "amd64"), + ("aarch64", "arm64"), + ("arm64", "arm64"), + ("armv7l", "unknown"), + ("i386", "unknown"), + ("ppc64le", "unknown"), + ("", "unknown"), + (None, "unknown"), + ], +) +def test_architecture_normalization(machine: str | None, expected: str) -> None: + """Common architectures are normalized, unknown ones become ``unknown``.""" + from app.services.telemetry import normalize_architecture + + assert normalize_architecture(machine) == expected + + +def test_linux_is_detected() -> None: + """On Linux, the OS should be reported as ``linux``.""" + from app.services.telemetry import collect_system_info, normalize_os + + assert normalize_os(platform.system()) == platform.system().lower() + info = collect_system_info() + assert set(info) == {"os", "architecture", "runtime"} + assert "os" in info + + +def test_collect_system_info_reports_only_three_fields(monkeypatch) -> None: + """System info must contain exactly the three telemetry fields.""" + from app.services import telemetry + + monkeypatch.setattr(telemetry, "normalize_os", lambda s: "linux") + monkeypatch.setattr(telemetry, "normalize_architecture", lambda m: "amd64") + monkeypatch.setattr(telemetry, "detect_runtime", lambda: "container") + + info = telemetry.collect_system_info() + assert info == {"os": "linux", "architecture": "amd64", "runtime": "container"} + + +def test_detect_runtime_container_marker(monkeypatch) -> None: + """A ``/.dockerenv`` marker should report ``container``.""" + from app.services import telemetry + + monkeypatch.setattr(telemetry, "_container_marker_present", lambda: True) + monkeypatch.setattr(telemetry, "_cgroup_container_signal", lambda: False) + assert telemetry.detect_runtime() == "container" + + +def test_detect_runtime_container_via_cgroup(monkeypatch) -> None: + """A container runtime in the cgroup paths should report ``container``.""" + from app.services import telemetry + + monkeypatch.setattr(telemetry, "_container_marker_present", lambda: False) + monkeypatch.setattr(telemetry, "_cgroup_container_signal", lambda: True) + assert telemetry.detect_runtime() == "container" + + +def test_detect_runtime_baremetal(monkeypatch) -> None: + """A readable cgroup without a container runtime should report ``baremetal``.""" + from app.services import telemetry + + monkeypatch.setattr(telemetry, "_container_marker_present", lambda: False) + monkeypatch.setattr(telemetry, "_cgroup_container_signal", lambda: False) + assert telemetry.detect_runtime() == "baremetal" + + +def test_detect_runtime_unknown_when_unreadable(monkeypatch) -> None: + """When no marker exists and the cgroup cannot be read, report ``unknown``.""" + from app.services import telemetry + + monkeypatch.setattr(telemetry, "_container_marker_present", lambda: False) + monkeypatch.setattr(telemetry, "_cgroup_container_signal", lambda: None) + assert telemetry.detect_runtime() == "unknown" + + +def test_detect_runtime_only_checks_known_marker_files(monkeypatch) -> None: + """Only the well-known marker files are probed — never arbitrary paths.""" + from app.services import telemetry + + monkeypatch.setattr(telemetry, "_CONTAINER_MARKERS", ("/.dockerenv", "/.containerenv")) + monkeypatch.setattr(telemetry, "_cgroup_container_signal", lambda: None) + with patch("os.path.exists", return_value=False) as mock_exists: + assert telemetry.detect_runtime() == "unknown" + checked = {call.args[0] for call in mock_exists.call_args_list} + assert checked == {"/.dockerenv", "/.containerenv"} + + +def test_cgroup_detection_never_exposes_ids(monkeypatch) -> None: + """The cgroup probe returns a boolean — never the raw content or container IDs.""" + from app.services import telemetry + + class FakeFile: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self) -> str: + return "0::/docker/0123456789abcdef\n" + + monkeypatch.setattr("builtins.open", lambda *args, **kwargs: FakeFile()) + assert telemetry._cgroup_container_signal() is True + + class CleanFile(FakeFile): + def read(self) -> str: + return "0::/init.scope\n" + + monkeypatch.setattr("builtins.open", lambda *args, **kwargs: CleanFile()) + assert telemetry._cgroup_container_signal() is False + + +def test_cgroup_detection_handles_unreadable(monkeypatch) -> None: + """An unreadable cgroup file yields None (undetermined).""" + from app.services import telemetry + + def _raise(*args, **kwargs): + raise OSError("no /proc/1/cgroup") + + monkeypatch.setattr("builtins.open", _raise) + assert telemetry._cgroup_container_signal() is None + + +# --- Installation id ------------------------------------------------------------ + + +def test_new_installation_receives_random_uuid4(session: Session) -> None: + """A fresh installation should get a random UUIDv4.""" + from app.services.telemetry import get_or_create_installation_id + + installation_id = get_or_create_installation_id(session) + assert uuid.UUID(installation_id).version == 4 + persisted = session.get(InstallationInfo, 1) + assert persisted is not None + assert persisted.installation_id == installation_id + + +def _installation_id_from_fresh_db() -> str: + """Create a fresh DB and return the installation id generated in it.""" + from app.services.telemetry import get_or_create_installation_id + + engine = create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + SQLModel.metadata.create_all(engine) + try: + with Session(engine) as session: + return get_or_create_installation_id(session) + finally: + engine.dispose() + + +def test_installation_id_differs_between_fresh_installs() -> None: + """Two independent installations must get different ids.""" + assert _installation_id_from_fresh_db() != _installation_id_from_fresh_db() + + +def test_installation_id_stable_across_starts() -> None: + """The id must stay stable across subsequent application starts.""" + from app.services.telemetry import get_or_create_installation_id + + engine = create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + SQLModel.metadata.create_all(engine) + + try: + with Session(engine) as session: + first = get_or_create_installation_id(session) + with Session(engine) as session: + second = get_or_create_installation_id(session) + finally: + engine.dispose() + + assert first == second + assert uuid.UUID(first).version == 4 + + +def test_installation_id_not_derived_from_mac_or_hostname(session: Session) -> None: + """The id must be a random UUIDv4, not a deterministic host-derived value.""" + from app.services.telemetry import get_or_create_installation_id + + installation_id = get_or_create_installation_id(session) + parsed = uuid.UUID(installation_id) + assert parsed.version == 4 + + assert installation_id != str(uuid.uuid5(uuid.NAMESPACE_DNS, socket.gethostname())) + assert installation_id != str(uuid.uuid5(uuid.NAMESPACE_DNS, str(uuid.getnode()))) + # The UUID's random bits must not be the machine node id. + assert parsed.node != uuid.getnode() + + +def test_installation_id_recovers_from_insert_race(session: Session) -> None: + """If the singleton insert loses a concurrent-worker race, re-read the winner's row.""" + from app.services.telemetry import get_or_create_installation_id + + existing_id = "existing-0000-0000-4000-8000-000000000001" + session.add(InstallationInfo(id=1, installation_id=existing_id)) + session.commit() + + real_get = session.get + calls = {"count": 0} + + def fake_get(model, pk): + calls["count"] += 1 + if calls["count"] == 1: + return None # simulate a concurrent worker not yet seeing the row + return real_get(model, pk) + + with patch.object(session, "get", side_effect=fake_get): + got = get_or_create_installation_id(session) + + assert got == existing_id + assert calls["count"] == 2 + + +# --- Payload --------------------------------------------------------------------- + + +def test_payload_contains_only_allowed_fields() -> None: + """The payload may only contain the strictly allow-listed fields.""" + from app._build_info import __version__ + from app.services.telemetry import TELEMETRY_FIELDS, build_payload + + installation_id = "11111111-2222-4333-8444-555555555555" + payload = build_payload(installation_id) + + assert set(payload) == TELEMETRY_FIELDS + assert payload["message_version"] == 1 + assert payload["installation_id"] == installation_id + assert payload["version"] == __version__ + assert payload["os"] == platform.system().lower() + assert payload["architecture"] in {"amd64", "arm64", "unknown"} + assert payload["runtime"] in {"container", "baremetal", "unknown"} + assert payload["client_ts"] # non-empty ISO timestamp + + +def test_build_payload_rejects_unknown_fields() -> None: + """Adding a field outside the allow-list must raise.""" + from app.services import telemetry + from app.services.telemetry import build_payload + + with patch.object(telemetry, "collect_system_info", return_value={"os": "linux", "architecture": "amd64", "runtime": "unknown"}): + with patch.object(telemetry, "TELEMETRY_FIELDS", frozenset({"message_version", "installation_id"})): + with pytest.raises(RuntimeError, match="allow-list"): + build_payload("some-id") + + +# --- Sending --------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_telemetry_disabled_prevents_any_request(session: Session, monkeypatch) -> None: + """With telemetry disabled, no HTTP request may be made.""" + from app.config import settings + from app.services import telemetry + + monkeypatch.setattr(settings, "telemetry_disabled", True) + + with patch("httpx.AsyncClient") as mock_client: + await telemetry.send_telemetry_once() + + mock_client.assert_not_called() + + +@pytest.mark.anyio +async def test_send_telemetry_posts_allowlisted_payload(session: Session, monkeypatch) -> None: + """An enabled send should POST exactly the allow-listed payload once.""" + from app.config import settings + from app.services import telemetry + + monkeypatch.setattr(settings, "telemetry_disabled", False) + monkeypatch.setattr(settings, "telemetry_endpoint", "https://telemetry.test/api/telemetry") + monkeypatch.setattr( + telemetry, "_load_installation_id", lambda: telemetry.get_or_create_installation_id(session) + ) + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls = MagicMock(return_value=mock_client) + + with patch("httpx.AsyncClient", mock_client_cls): + await telemetry.send_telemetry_once() + + mock_client_cls.assert_called_once_with( + timeout=settings.telemetry_timeout_seconds, + headers={"User-Agent": telemetry._USER_AGENT}, + ) + mock_client.post.assert_awaited_once() + call = mock_client.post.await_args + assert call is not None + assert call.args[0] == settings.telemetry_endpoint + sent = call.kwargs["json"] + assert set(sent) == telemetry.TELEMETRY_FIELDS + assert sent["message_version"] == 1 + assert uuid.UUID(sent["installation_id"]).version == 4 + + persisted = session.get(InstallationInfo, 1) + assert persisted is not None + assert persisted.installation_id == sent["installation_id"] + + +@pytest.mark.anyio +async def test_send_telemetry_end_to_end_with_real_session(tmp_path, monkeypatch) -> None: + """A send using the real session/engine path persists the id and posts.""" + from sqlmodel import SQLModel, Session, create_engine + + from app.config import settings + from app.services import telemetry + + engine = create_engine( + f"sqlite:///{tmp_path / 'telemetry.db'}", + connect_args={"check_same_thread": False}, + ) + SQLModel.metadata.create_all(engine) + monkeypatch.setattr(telemetry, "engine", engine) + monkeypatch.setattr(settings, "telemetry_disabled", False) + monkeypatch.setattr(settings, "telemetry_endpoint", "https://telemetry.test/api/telemetry") + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls = MagicMock(return_value=mock_client) + + try: + with patch("httpx.AsyncClient", mock_client_cls): + await telemetry.send_telemetry_once() + + mock_client.post.assert_awaited_once() + with Session(engine) as session: + persisted = session.get(InstallationInfo, 1) + finally: + engine.dispose() + assert persisted is not None + assert uuid.UUID(persisted.installation_id).version == 4 + + +@pytest.mark.anyio +async def test_network_failure_is_swallowed(session: Session, monkeypatch) -> None: + """A network failure must not raise out of send_telemetry_once.""" + from app.config import settings + from app.services import telemetry + + monkeypatch.setattr(settings, "telemetry_disabled", False) + monkeypatch.setattr( + telemetry, "_load_installation_id", lambda: telemetry.get_or_create_installation_id(session) + ) + + with patch("httpx.AsyncClient", side_effect=httpx.ConnectError("boom")): + await telemetry.send_telemetry_once() # must not raise + + +@pytest.mark.anyio +async def test_database_failure_is_swallowed(monkeypatch) -> None: + """A database failure must not raise and must not send a request.""" + from app.config import settings + from app.services import telemetry + + monkeypatch.setattr(settings, "telemetry_disabled", False) + monkeypatch.setattr( + telemetry, "_load_installation_id", MagicMock(side_effect=RuntimeError("db down")) + ) + + with patch("httpx.AsyncClient") as mock_client: + await telemetry.send_telemetry_once() # must not raise + + mock_client.assert_not_called() + + +@pytest.mark.anyio +async def test_heartbeat_sends_then_waits_24h() -> None: + """The heartbeat sends on start, then sleeps 24h and keeps going on failure.""" + import app.main as main_module + + calls = 0 + + async def failing_send() -> None: + nonlocal calls + calls += 1 + raise RuntimeError("boom") + + mock_sleep = AsyncMock(side_effect=[None, asyncio.CancelledError()]) + with patch("app.main.send_telemetry_once", new=failing_send): + with patch("app.main.asyncio.sleep", new=mock_sleep): + with pytest.raises(asyncio.CancelledError): + await main_module._telemetry_heartbeat() + + assert calls == 2 + assert mock_sleep.call_args_list[0].args[0] == 24 * 3600 + + +@pytest.mark.anyio +async def test_startup_continues_when_telemetry_fails(monkeypatch) -> None: + """Lifespan must complete normally even if the telemetry heartbeat errors.""" + from app.config import settings + import app.main as main_module + + monkeypatch.setattr(settings, "telemetry_disabled", False) + with patch("app.main._periodic_maintenance", new=AsyncMock()): + with patch("app.main.send_telemetry_once", new=AsyncMock(side_effect=RuntimeError("boom"))): + async with main_module.lifespan(main_module.app): + pass + + +@pytest.mark.anyio +async def test_lifespan_starts_telemetry_task_when_enabled(monkeypatch) -> None: + """When telemetry is enabled, a heartbeat task is started at startup.""" + from app.config import settings + import app.main as main_module + + monkeypatch.setattr(settings, "telemetry_disabled", False) + with patch("app.main._periodic_maintenance", new=AsyncMock()): + with patch("app.main._telemetry_heartbeat", new=AsyncMock()) as mock_heartbeat: + async with main_module.lifespan(main_module.app): + await asyncio.sleep(0) + + mock_heartbeat.assert_awaited_once() + + +@pytest.mark.anyio +async def test_lifespan_skips_telemetry_when_disabled(monkeypatch) -> None: + """When telemetry is disabled, no heartbeat task may be started.""" + from app.config import settings + import app.main as main_module + + monkeypatch.setattr(settings, "telemetry_disabled", True) + with patch("app.main._periodic_maintenance", new=AsyncMock()): + with patch("app.main._telemetry_heartbeat", new=AsyncMock()) as mock_heartbeat: + async with main_module.lifespan(main_module.app): + pass + + mock_heartbeat.assert_not_called() \ No newline at end of file diff --git a/cli/src/llc/pr.py b/cli/src/llc/pr.py index 32855259..5282953e 100644 --- a/cli/src/llc/pr.py +++ b/cli/src/llc/pr.py @@ -1,4 +1,3 @@ -import click import typer import llc._git import llc._gh @@ -24,7 +23,7 @@ def cmd_create() -> None: if llc._interactive.confirm("Uncommitted changes found. Commit first?", default=True): console.print("[yellow]Please commit your changes manually, then re-run.[/yellow]") raise typer.Exit() - except click.exceptions.Exit: + except typer.Exit: raise except Exception: console.print("[red]Failed to check for uncommitted changes.[/red]") diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 6170cfe0..76609f38 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -15,6 +15,7 @@ services: environment: DATABASE_URL: sqlite:///./data/librislog.db API_KEY_ENCRYPTION_KEY: zK7qP9mX2vR5tW8yA4cF6hJ1lN3pS0uB # dummy key for testing purposes only + TELEMETRY_DISABLED: "true" # E2E runs must not send telemetry healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')\""] interval: 2s diff --git a/docs/.vitepress/config.base.ts b/docs/.vitepress/config.base.ts index c4aaa661..e9e5a9a6 100644 --- a/docs/.vitepress/config.base.ts +++ b/docs/.vitepress/config.base.ts @@ -50,6 +50,7 @@ export default defineConfig({ items: [ { text: 'Quick Start', link: '/guide/getting-started' }, { text: 'Configuration', link: '/guide/configuration' }, + { text: 'Telemetry', link: '/guide/telemetry' }, { text: 'API Keys', link: '/guide/api-keys' }, { text: 'Developer Setup', @@ -97,6 +98,7 @@ export default defineConfig({ { text: 'Home Assistant', link: '/api/integrations/homeassistant' }, { text: 'Homarr', link: '/api/integrations/homarr' }, { text: 'Homepage', link: '/api/integrations/homepage' }, + { text: 'Homer', link: '/api/integrations/homer' }, ], }, ], diff --git a/docs/api/integrations/homer.md b/docs/api/integrations/homer.md new file mode 100644 index 00000000..8bb7b2cf --- /dev/null +++ b/docs/api/integrations/homer.md @@ -0,0 +1,64 @@ +# Homer + +LibrisLog can be integrated into [Homer](https://github.com/bastienwirtz/homer), +a self-hosted dashboard for your services, using its +[LibrisLog custom service](https://github.com/bastienwirtz/homer/blob/main/docs/customservices.md#librislog). + +This smart card displays your reading statistics: total books, books read, +currently reading, and want-to-read counts directly on your Homer dashboard. + +## Prerequisites + +- A running LibrisLog instance reachable from the browser you use to view + your Homer dashboard (the card fetches data client-side) +- An [API key](/api/integrations/#api-keys) with access to the + statistics endpoint + +## Configuration + +Add the following service entry to your Homer `config.yml`: + +```yaml +- name: "LibrisLog" + type: "LibrisLog" + logo: "https://docs.librislog.app/logo.png" + url: "" + apikey: "" +``` + +The card supports auto refresh, which can be enabled individually for each +service using the `updateIntervalMs` option. + +> [!WARNING] +> Homer serves your `config.yml` at `/assets/config.yml` over HTTP. The API +> key in it is readable by anyone who can access your Homer instance. Only +> include it if your Homer instance is protected by authentication or access +> controls. + +## Placeholders + +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 | +| `` | `http://192.168.1.100:8080` | The base URL of your Homer instance | + +## CORS + +The Homer card fetches the API directly from the browser. You must add your +Homer URL to the +[`CORS_ORIGINS`](/guide/configuration#core-settings) environment variable of +the LibrisLog backend: + +``` +CORS_ORIGINS=[""] +``` + +If the card stays empty or shows no statistics, check your browser console +for CORS errors. + +## Result + +![Homer Widget](/screenshots/integrations-homer.png) diff --git a/docs/api/integrations/index.md b/docs/api/integrations/index.md index 9635fbf4..055f0f8e 100644 --- a/docs/api/integrations/index.md +++ b/docs/api/integrations/index.md @@ -48,3 +48,6 @@ headers. For these integrations you need an **embed token**, used with the - [Homepage](/api/integrations/homepage) — Display your LibrisLog statistics on a [Homepage](https://gethomepage.dev/) dashboard using the custom API widget. +- [Homer](/api/integrations/homer) — Display your LibrisLog statistics on a + [Homer](https://github.com/bastienwirtz/homer) dashboard using the + LibrisLog custom service. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index d8292eb4..ea39a248 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -87,6 +87,24 @@ The Mailpit web UI is available at http://localhost:8025 to inspect sent emails. | `DASHBOARD_QUOTE_URL` | Quote API endpoint | `https://motivational-spark-api.vercel.app/api/quotes/random` | | `DASHBOARD_QUOTE_CACHE_TTL` | Quote cache time-to-live (seconds) | `86400` | +## Telemetry + +LibrisLog sends minimal, anonymous installation telemetry (see [Telemetry](/guide/telemetry)) to understand how many installations exist and on which platforms they run. It is an installation census. No user, book, reading, or host data is ever collected. + +The telemetry server is open source ([github.com/codebude/librislog-telemetry](https://github.com/codebude/librislog-telemetry)) and the aggregated results are public at [metrics.librislog.app](https://metrics.librislog.app/). + +| Variable | Description | Default | +|----------|-------------|---------| +| `TELEMETRY_DISABLED` | Disable all telemetry (`true`/`false`) | `false` | +| `TELEMETRY_ENDPOINT` | Telemetry API endpoint | `https://metrics.librislog.app/api/telemetry` | +| `TELEMETRY_TIMEOUT_SECONDS` | HTTP timeout for telemetry requests | `10` | + +To opt out entirely: + +```bash +TELEMETRY_DISABLED=true +``` + ## Frontend Build | Variable | Description | Default | diff --git a/docs/guide/telemetry.md b/docs/guide/telemetry.md new file mode 100644 index 00000000..53de900c --- /dev/null +++ b/docs/guide/telemetry.md @@ -0,0 +1,59 @@ +# Telemetry + +LibrisLog sends minimal, **anonymous** installation telemetry to help the project understand how many installations exist and on which platforms they run. This is an installation census, not user-behavior tracking. + +## What is collected + +Only the following fields are transmitted: once when LibrisLog starts, then every 24 hours. + +| Field | Meaning | Example | +|-------|---------|---------| +| `message_version` | Telemetry schema version (always 1) | `1` | +| `installation_id` | Random UUIDv4 generated on first startup | `f47ac10b-58cc-4372-a567-0e02b2c3d479` | +| `version` | LibrisLog version | `v1.2.3` | +| `os` | Operating system seen by LibrisLog | `linux`, `windows`, `darwin` | +| `architecture` | CPU architecture | `amd64`, `arm64`, `unknown` | +| `runtime` | Container or directly on the host | `container`, `baremetal`, `unknown` | +| `client_ts` | Timestamp of the heartbeat | ISO 8601 UTC | + +The runtime is reported as `container` when a container marker file (`/.dockerenv`, `/.containerenv`) is present or a container runtime is visible in the process cgroup paths (this covers Docker, Podman, containerd and Kubernetes), as `baremetal` when no container is detected, and as `unknown` only if the environment cannot be determined. The cgroup check derives a simple yes/no signal — no cgroup or container IDs are ever read, stored, or sent. + +The `installation_id` is a cryptographically random UUIDv4 that is **not** derived from your MAC address, hostname, `/etc/machine-id`, or any other hardware identifier. It is stored in the LibrisLog database, so it stays stable across container restarts and updates — but is regenerated if you delete the database and reinstall. + +## What is NEVER collected + +LibrisLog deliberately does **not** collect: + +- IP addresses or geographic information +- Hostnames +- Usernames or user IDs +- MAC addresses or `/etc/machine-id` +- Container IDs or Docker host information +- Book data, book counts, or user counts +- Reading activity, search queries, or feature usage +- Database contents or environment variables +- Filesystem paths +- User-Agent or other client-identifying details (telemetry requests send only a fixed, generic user-agent string, never browser or OS specifics) + +The payload is built from a strict allow-list: only the fields listed above may ever be sent. + +## When telemetry is sent + +One heartbeat is sent when LibrisLog starts, then once every 24 hours. Telemetry is **best-effort**: if the telemetry server is unreachable, the request is dropped silently, no retries are attempted, and LibrisLog continues to work normally. + +## Transparency & verification + +Telemetry is fully verifiable by anyone: + +- **The telemetry server is open source.** The complete code that ingests, stores, and serves telemetry data is public at [github.com/codebude/librislog-telemetry](https://github.com/codebude/librislog-telemetry). You can inspect it to confirm that nothing beyond the allow-listed fields above is ever stored or exposed. +- **The results are public.** The aggregated census is published for everyone to see at [metrics.librislog.app](https://metrics.librislog.app/) — total and active installations, versions in use, operating systems, architectures, and runtimes. There is no private dashboard: the exact same data shown to the project maintainers is visible to anyone. + +## How to disable telemetry + +Set the following in your `.env` file: + +```bash +TELEMETRY_DISABLED=true +``` + +Restart LibrisLog after changing it. No telemetry is sent while `TELEMETRY_DISABLED=true` is set. \ No newline at end of file diff --git a/docs/public/screenshots/integrations-homer.png b/docs/public/screenshots/integrations-homer.png new file mode 100644 index 00000000..9e6b07f2 Binary files /dev/null and b/docs/public/screenshots/integrations-homer.png differ diff --git a/docs/releases.md b/docs/releases.md index 20e9088a..2f86aa1d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -8,14 +8,15 @@ You can also browse the [GitHub Releases](https://github.com/codebude/librislog/ ## Latest Release -::: tip ⭐ v1.7.0 — Reading Streaks & Goals -LibrisLog v1.7.0 brings reading streaks and reading goals to the dashboard, a richer book model with multiple authors, a new field-specific search syntax, and a more flexible file import. +::: tip ⭐ v1.8.0 — Camera & Zoom Control, Optional Telemetry +LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner, optional anonymous installation telemetry with a publicly verifiable census, a new Homer dashboard integration, and several usability and dependency fixes. ::: ### All releases | Version | Date | Type | |---|---|---| +| [v1.8.0](#v1-8-0-—-camera-zoom-control-optional-telemetry) | 2026-09-02 | Feature release | | [v1.7.0](#v1-7-0-—-reading-streaks-goals) | 2026-08-26 | Feature release | | [v1.6.0](#v1-6-0-—-reading-progress-possession-tracking) | 2026-08-23 | Feature release | | [v1.5.2](#v1-5-2-—-maintenance) | 2026-06-22 | Maintenance | @@ -33,6 +34,29 @@ LibrisLog v1.7.0 brings reading streaks and reading goals to the dashboard, a ri --- +## v1.8.0 — Camera & Zoom Control, Optional Telemetry + + + +**Summary:** Adds camera selection and zoom control to the barcode scanner, optional anonymous installation telemetry with a publicly verifiable census, a new Homer dashboard integration, and several usability and dependency fixes. + +**Features** +- 📷 **Barcode scanner camera & zoom** — switch between your available cameras and zoom in on the code directly in the scan view. Your camera and zoom level are remembered for the next scan +- 📡 **Optional anonymous telemetry** — a privacy-focused installation census (version, OS, architecture, runtime) sent on startup and then every 24 hours. It is enabled by default and can be disabled with `TELEMETRY_DISABLED=true`. The telemetry server is open source and the aggregated results are public at [metrics.librislog.app](https://metrics.librislog.app/). See the [Telemetry guide](/guide/telemetry) for details +- 🖼️ **Homer dashboard integration** — new documentation for showing your reading statistics on a [Homer](https://github.com/bastienwirtz/homer) dashboard using its LibrisLog smart card +- 🔢 **Current-page input clamping** — the page input in the book details now clamps to the book's page count while you type, so an out-of-range value can no longer be entered +- 🔒 **Dependency updates** — upgraded backend and frontend dependencies, including fixes for security advisories in Starlette and joserfc +- 🛠️ **Developer CLI fix** — adjusted the `llc` CLI for the updated Typer exception handling + +**Bug fixes** +- 🏷️ Fixed the reading-status badge overflowing its border in the book details view for books with many authors — the author list now wraps and the badge stays on a single line + +**Breaking changes:** None. + +[Compare with v1.7.0](https://github.com/codebude/librislog/compare/v1.7.0...main) + +--- + ## v1.7.0 — Reading Streaks & Goals diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1ee2502a..ecb65371 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -224,9 +224,9 @@ } }, "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==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -246,18 +246,18 @@ "license": "MIT" }, "node_modules/@lucide/svelte": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.33.0.tgz", - "integrity": "sha512-b+osTYG2V4dge5Lr7tnaTaahhy/4vH7Xb8zcqVjmctjwgkpXS0NNOJPkGzHHZoxtw/OO+2YAU28omeMfYCaawg==", + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.39.0.tgz", + "integrity": "sha512-jCgA/RqDv9uWHs4DiIUef4kpf5TfLl9y8OMGrh+W4aQTCOscT4X2BX14ta65J+5ooslrN1bXXiO9AaeAOHAcPA==", "license": "ISC", "peerDependencies": { "svelte": "^5" } }, "node_modules/@oxc-project/types": { - "version": "0.146.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", - "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -287,9 +287,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", - "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", "cpu": [ "arm" ], @@ -303,9 +303,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", - "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", "cpu": [ "arm64" ], @@ -319,9 +319,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", - "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", "cpu": [ "arm64" ], @@ -335,9 +335,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", - "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", "cpu": [ "x64" ], @@ -351,9 +351,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", - "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", "cpu": [ "x64" ], @@ -367,9 +367,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", - "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", "cpu": [ "arm" ], @@ -383,9 +383,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", - "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", "cpu": [ "arm64" ], @@ -402,9 +402,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", - "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", "cpu": [ "arm64" ], @@ -421,9 +421,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", - "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", "cpu": [ "ppc64" ], @@ -440,9 +440,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", - "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", "cpu": [ "s390x" ], @@ -459,9 +459,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", - "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", "cpu": [ "x64" ], @@ -478,9 +478,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", - "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", "cpu": [ "x64" ], @@ -497,9 +497,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", - "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", "cpu": [ "arm64" ], @@ -513,9 +513,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", - "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", "cpu": [ "arm64" ], @@ -529,9 +529,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", - "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", "cpu": [ "x64" ], @@ -659,9 +659,9 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte/node_modules/magic-string": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.2.tgz", - "integrity": "sha512-veT/+7iXrXzT39XnEN4lOxtNl72dMgJ8Lp+5Bd6YcMSWpb0n0MjBM8Uuooi6jgJr8dhUW2swQgBmoZVMni5SVg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", "dev": true, "license": "MIT", "dependencies": { @@ -957,23 +957,6 @@ "node": ">=18" } }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, "node_modules/@testing-library/jest-dom": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", @@ -1003,6 +986,13 @@ } } }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/svelte": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.4.2.tgz", @@ -1088,21 +1078,15 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", + "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" } }, - "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" - }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -1306,13 +1290,13 @@ } }, "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" + "dependencies": { + "dequal": "^2.0.3" } }, "node_modules/assertion-error": { @@ -1489,9 +1473,9 @@ } }, "node_modules/daisyui": { - "version": "5.7.20", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.20.tgz", - "integrity": "sha512-qoL9qXXo/K/MzcteD1SvZOSeBaL8F9qBJvwX3KEpiVHQLzIEtGkNl/ZznSI7J0d+qnQJa2dAAFzTFDAx9df1rw==", + "version": "5.7.27", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.27.tgz", + "integrity": "sha512-Je10yYK7UFJYyLSQrIxhFmpwX+86gXdNTnfNkA1jlev7j9oz3QrqioEQ6/0JYOvYPs5NYH4aaJ+X35/N80G/xg==", "license": "MIT", "funding": { "url": "https://github.com/saadeghi/daisyui?sponsor=1" @@ -1538,15 +1522,15 @@ } }, "node_modules/devalue": { - "version": "5.9.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.1.tgz", - "integrity": "sha512-+17vil3EVQRzvtDJSFuTWEb8XJRvXqAiV3qZyQWD398QeXUa6CxsUyMdD1fxzEhUrd4FojitFz7lhIHBTlV4fw==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", + "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", "license": "MIT" }, "node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, "license": "MIT" }, @@ -1772,9 +1756,9 @@ } }, "node_modules/happy-dom": { - "version": "20.11.6", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.6.tgz", - "integrity": "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==", + "version": "20.12.2", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.12.2.tgz", + "integrity": "sha512-vCIoyoKQ7vxLb9zRiTc7uKryHDG3YY6tXK96D6MuugFnV9xTbmiPGM/GYFn1q+fWoIBv53q4rBEgydDkQwnDZw==", "dev": true, "license": "MIT", "dependencies": { @@ -2338,9 +2322,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -2460,12 +2444,12 @@ } }, "node_modules/rolldown": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", - "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.146.0", + "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2475,21 +2459,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.5", - "@rolldown/binding-android-arm64": "1.2.5", - "@rolldown/binding-darwin-arm64": "1.2.5", - "@rolldown/binding-darwin-x64": "1.2.5", - "@rolldown/binding-freebsd-x64": "1.2.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", - "@rolldown/binding-linux-arm64-gnu": "1.2.5", - "@rolldown/binding-linux-arm64-musl": "1.2.5", - "@rolldown/binding-linux-ppc64-gnu": "1.2.5", - "@rolldown/binding-linux-s390x-gnu": "1.2.5", - "@rolldown/binding-linux-x64-gnu": "1.2.5", - "@rolldown/binding-linux-x64-musl": "1.2.5", - "@rolldown/binding-openharmony-arm64": "1.2.5", - "@rolldown/binding-win32-arm64-msvc": "1.2.5", - "@rolldown/binding-win32-x64-msvc": "1.2.5" + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" } }, "node_modules/sade": { @@ -2596,16 +2580,15 @@ } }, "node_modules/svelte": { - "version": "5.56.10", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.10.tgz", - "integrity": "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g==", + "version": "5.57.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.0.tgz", + "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", @@ -3793,9 +3776,9 @@ } }, "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", "license": "MIT" } } diff --git a/frontend/src/lib/components/BarcodeScanner.svelte b/frontend/src/lib/components/BarcodeScanner.svelte index a6f495ec..54298112 100644 --- a/frontend/src/lib/components/BarcodeScanner.svelte +++ b/frontend/src/lib/components/BarcodeScanner.svelte @@ -1,10 +1,10 @@ -