Przeglądaj źródła

feat(external-links): an app can show a count on its sidebar link (PUT /external-links/badge with "Send notifications"; Bambuddy Orders shows its To do count), the count disappears when not updated for ten minutes

maziggy 1 dzień temu
rodzic
commit
29a81520d9
65 zmienionych plików z 445 dodań i 47 usunięć
  1. 1 0
      CHANGELOG.md
  2. 79 4
      backend/app/api/routes/external_links.py
  3. 5 3
      backend/app/core/auth.py
  4. 3 0
      backend/app/core/sqlite_fk.py
  5. 3 2
      backend/app/models/api_key.py
  6. 14 1
      backend/app/models/external_link.py
  7. 1 1
      backend/app/schemas/api_key.py
  8. 16 0
      backend/app/schemas/external_link.py
  9. 237 0
      backend/tests/integration/test_external_link_badge.py
  10. 1 0
      backend/tests/unit/test_not_null_update_schemas.py
  11. 1 0
      backend/tests/unit/test_outbound_url_ssrf_guards.py
  12. 37 0
      frontend/src/__tests__/components/Layout.test.tsx
  13. 2 0
      frontend/src/api/client.ts
  14. 27 18
      frontend/src/components/Layout.tsx
  15. 1 1
      frontend/src/i18n/locales/de.ts
  16. 1 1
      frontend/src/i18n/locales/en.ts
  17. 1 1
      frontend/src/i18n/locales/es.ts
  18. 1 1
      frontend/src/i18n/locales/fr.ts
  19. 1 1
      frontend/src/i18n/locales/it.ts
  20. 1 1
      frontend/src/i18n/locales/ja.ts
  21. 1 1
      frontend/src/i18n/locales/ko.ts
  22. 1 1
      frontend/src/i18n/locales/nl.ts
  23. 1 1
      frontend/src/i18n/locales/pt-BR.ts
  24. 1 1
      frontend/src/i18n/locales/ru.ts
  25. 1 1
      frontend/src/i18n/locales/sv.ts
  26. 1 1
      frontend/src/i18n/locales/tr.ts
  27. 1 1
      frontend/src/i18n/locales/uk.ts
  28. 1 1
      frontend/src/i18n/locales/zh-CN.ts
  29. 1 1
      frontend/src/i18n/locales/zh-TW.ts
  30. 0 0
      static/assets/ArchivesPage-Cijsih6K.js
  31. 0 0
      static/assets/CameraPage-Wnku0dqm.js
  32. 0 0
      static/assets/FileManagerPage-CLYHqJkv.js
  33. 0 0
      static/assets/InventoryPage-BasVhHe0.js
  34. 0 0
      static/assets/Layout-ONJVQ_oD.js
  35. 0 0
      static/assets/ModelSourcesPage-BvLs6J_U.js
  36. 0 0
      static/assets/PhotoGalleryModal-btgMm3DW.js
  37. 0 0
      static/assets/PrintersPage-BsUyKUN8.js
  38. 0 0
      static/assets/SettingsPage-Beo2lGCx.js
  39. 0 0
      static/assets/SliceModal-Cba5GYI7.js
  40. 1 1
      static/assets/SpoolBuddySettingsPage-BeM5JwM3.js
  41. 0 0
      static/assets/de-D39JI_8d.js
  42. 0 0
      static/assets/es-BHL-6pV1.js
  43. 0 0
      static/assets/es-QuPepRKs.js
  44. 0 0
      static/assets/fr-C_28VCx0.js
  45. 0 0
      static/assets/fr-CpEo28WL.js
  46. 0 0
      static/assets/index-BNCUzO2G.js
  47. 0 0
      static/assets/it-CFRvX4yY.js
  48. 0 0
      static/assets/it-Dl6JGckA.js
  49. 0 0
      static/assets/ja-vehaWIqv.js
  50. 0 0
      static/assets/ko-CbTWUFFi.js
  51. 0 0
      static/assets/locationSensorDefaults-BuT1z5wg.js
  52. 0 0
      static/assets/nl-CixN_KdW.js
  53. 0 0
      static/assets/nl-CyemxYPq.js
  54. 0 0
      static/assets/pt-BR-DBfeh188.js
  55. 0 0
      static/assets/pt-BR-sDjXgo3_.js
  56. 0 0
      static/assets/ru-rVUUdG_R.js
  57. 0 0
      static/assets/sv-BbgCZKfM.js
  58. 0 0
      static/assets/sv-Cqpp_hoE.js
  59. 0 0
      static/assets/tr-BXpm1KWk.js
  60. 0 0
      static/assets/tr-Fokmo5P7.js
  61. 0 0
      static/assets/uk-DO3--z4v.js
  62. 0 0
      static/assets/zh-CN-D7IpC6DP.js
  63. 0 0
      static/assets/zh-CN-D8DqooDL.js
  64. 0 0
      static/assets/zh-TW-DARWgtcC.js
  65. 2 2
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.3.0b1] - Unreleased
 
 ### Added
+- **A count on an app's sidebar link** — An app added to the sidebar can show a number on its link, the way the print queue shows how many jobs wait: Bambuddy Orders shows how many orders and top-ups wait in its To do. The app reports the count for its own address with an API key that has **Send notifications** (`PUT /api/v1/external-links/badge`); the link must point at that address. A count not updated for ten minutes disappears, so an app that stopped doesn't leave an old number. Wiki updated.
 - **USB cameras are picked from a list** — With an external camera set to **USB Camera (V4L2)**, Settings > Camera now lists the video devices the Bambuddy server can see, by name and path (`HD Pro Webcam C920 (/dev/video0)`), instead of asking for the device path. A refresh button looks again after a camera is plugged in, and a saved camera that is unplugged stays selected, marked **not found**. When no device is visible, the list says how to pass a camera to the Docker container. **Enter path manually** still takes a typed path. Looking for devices no longer holds up the server while it runs.
 - **The MQTT relay publishes printer offline/online and low filament** — Three new topics for Home Assistant, Node-RED and other subscribers. `printers/{serial}/offline` is sent once a printer has been disconnected for a minute, and `printers/{serial}/online` when it comes back after that, so a short blip sends neither. `printers/{serial}/filament/low` is sent once when the spool in an AMS slot drops below its low-stock threshold, at the same moment as the Low Filament notification, and works even when no notification provider has that event switched on. Wiki updated with the payloads.
 - **Notify! notification provider with iOS Live Activities and Lock Screen widgets (#2919, requested and contributed by @simplytoast1 in #3265)** — Notify! used to need the separate notify-bambuddy companion container. Bambuddy now talks to it directly: add a provider of type Notify! with the device ID and token.

+ 79 - 4
backend/app/api/routes/external_links.py

@@ -2,25 +2,37 @@
 
 import logging
 import uuid
+from datetime import timedelta
 from pathlib import Path
+from typing import cast
+from urllib.parse import urlsplit
 
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi.responses import FileResponse
-from sqlalchemy import select
+from sqlalchemy import select, update
+from sqlalchemy.engine import CursorResult
+from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_media_token_permission
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    ScopedCaller,
+    require_media_token_permission,
+    require_notification_send,
+)
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
-from backend.app.models.external_link import ExternalLink
+from backend.app.models.external_link import ExternalLink, ExternalLinkBadge
 from backend.app.models.user import User
 from backend.app.schemas.external_link import (
+    ExternalLinkBadgeSet,
     ExternalLinkCreate,
     ExternalLinkReorder,
     ExternalLinkResponse,
     ExternalLinkUpdate,
 )
+from backend.app.utils.local_time import utcnow_naive
 
 # Directory for storing custom icons
 ICONS_DIR = app_settings.base_dir / "icons"
@@ -30,6 +42,24 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/external-links", tags=["external-links"])
 
+# An app that stopped reporting (stopped, crashed) leaves no old number behind.
+BADGE_FRESH_FOR = timedelta(minutes=10)
+
+
+_DEFAULT_PORTS = {"http": 80, "https": 443}
+
+
+def _same_place(url: str) -> tuple[str, str, int | None, str]:
+    """Scheme, host, port and path of an address. A login in it, the default
+    port written out and a trailing slash don't count."""
+    parts = urlsplit(url.strip())
+    scheme = parts.scheme.lower()
+    try:
+        port = parts.port or _DEFAULT_PORTS.get(scheme)
+    except ValueError:
+        port = None
+    return scheme, (parts.hostname or "").lower(), port, parts.path.rstrip("/")
+
 
 @router.get("/", response_model=list[ExternalLinkResponse])
 async def list_external_links(
@@ -39,7 +69,52 @@ async def list_external_links(
     """List all external links ordered by sort_order."""
     result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
     links = result.scalars().all()
-    return links
+    fresh_since = utcnow_naive() - BADGE_FRESH_FOR
+    badges = {
+        b.link_id: b.count
+        for b in (await db.execute(select(ExternalLinkBadge).where(ExternalLinkBadge.updated_at >= fresh_since)))
+        .scalars()
+        .all()
+    }
+    return [
+        ExternalLinkResponse.model_validate(link).model_copy(update={"badge_count": badges.get(link.id)})
+        for link in links
+    ]
+
+
+@router.put("/badge")
+async def set_badge(
+    data: ExternalLinkBadgeSet,
+    db: AsyncSession = Depends(get_db),
+    _: ScopedCaller = Depends(require_notification_send()),
+):
+    """Set the count on the sidebar links pointing at an app, e.g. Bambuddy
+    Orders' To do. For the app itself: an API key needs the "Send
+    notifications" permission, like for its messages. The count shows while
+    the app keeps reporting it."""
+    wanted = _same_place(data.url)
+    links = [
+        link for link in (await db.execute(select(ExternalLink))).scalars().all() if _same_place(link.url) == wanted
+    ]
+    if not links:
+        raise HTTPException(status_code=404, detail="No sidebar link points at this address")
+    now = utcnow_naive()
+    for link in links:
+        values = {"count": data.count, "updated_at": now}
+        changed = cast(
+            CursorResult,
+            await db.execute(update(ExternalLinkBadge).where(ExternalLinkBadge.link_id == link.id).values(**values)),
+        )
+        if changed.rowcount:
+            continue
+        try:
+            async with db.begin_nested():
+                db.add(ExternalLinkBadge(link_id=link.id, **values))
+        except IntegrityError:
+            # Another report made the row in the meantime: update that one.
+            await db.execute(update(ExternalLinkBadge).where(ExternalLinkBadge.link_id == link.id).values(**values))
+    await db.commit()
+    return {"links": len(links)}
 
 
 @router.post("/", response_model=ExternalLinkResponse)

+ 5 - 3
backend/app/core/auth.py

@@ -642,9 +642,11 @@ def require_energy_cost_update():
 
 
 def require_notification_send():
-    """Dependency for ``POST /notifications/app-message``: another application
-    sending a message through the channels that accept app messages. API keys
-    need ``can_send_notifications``; users need ``NOTIFICATIONS_UPDATE``."""
+    """Dependency for ``POST /notifications/app-message`` and ``PUT
+    /external-links/badge``: another application sending a message through the
+    channels that accept app messages, or setting the count on its sidebar
+    link. API keys need ``can_send_notifications``; users need
+    ``NOTIFICATIONS_UPDATE``."""
     return require_api_key_scope(
         "can_send_notifications", "send_notifications", Permission.NOTIFICATIONS_UPDATE, owner_needs_permission=True
     )

+ 3 - 0
backend/app/core/sqlite_fk.py

@@ -92,6 +92,9 @@ APPLIED: frozenset[tuple[str, str]] = frozenset(
         ("slicer_pipelines", "target_printer_id"),
         # A deleted printer's spools would stay "assigned" to it (SA-1).
         ("spool_assignment", "printer_id"),
+        # external_links: the next link given the id would show the count an
+        # app reported for the deleted one.
+        ("external_link_badges", "link_id"),
         # smart_plugs: a printer delete now deletes its plugs; their energy
         # history would pass to the next plug given the id.
         ("smart_plug_energy_snapshots", "plug_id"),

+ 3 - 2
backend/app/models/api_key.py

@@ -56,8 +56,9 @@ class APIKey(Base):
     # could rewrite SMTP/LDAP/MQTT credentials).
     can_update_energy_cost: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
     # Send a message through the notification channels that accept app
-    # messages (POST /notifications/app-message). Nothing else: no reading or
-    # changing the channels themselves.
+    # messages (POST /notifications/app-message), and set the count on the
+    # sidebar link pointing at the app (PUT /external-links/badge). Nothing
+    # else: no reading or changing the channels or the links themselves.
     can_send_notifications: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # Optional scope limits

+ 14 - 1
backend/app/models/external_link.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, Integer, String, func
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
@@ -20,3 +20,16 @@ class ExternalLink(Base):
     sort_order: Mapped[int] = mapped_column(Integer, default=0)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+
+class ExternalLinkBadge(Base):
+    """A count on an external sidebar link, set by the app the link points to
+    (Bambuddy Orders' To do, say). Kept apart from the link so that setting it
+    every minute leaves the link's own ``updated_at`` alone."""
+
+    __tablename__ = "external_link_badges"
+
+    link_id: Mapped[int] = mapped_column(ForeignKey("external_links.id", ondelete="CASCADE"), primary_key=True)
+    count: Mapped[int] = mapped_column(Integer, default=0)
+    # When the app last reported; an old count isn't shown.
+    updated_at: Mapped[datetime] = mapped_column(DateTime)

+ 1 - 1
backend/app/schemas/api_key.py

@@ -21,7 +21,7 @@ class APIKeyCreate(BaseModel):
     can_manage_projects: bool = True  # Create/update/delete projects + membership (add archives) (#1893)
     can_access_cloud: bool = False  # Read /cloud/* on the creator's behalf — default off (#1182)
     can_update_energy_cost: bool = False  # POST /settings/electricity-price only (#1356)
-    can_send_notifications: bool = False  # POST /notifications/app-message only
+    can_send_notifications: bool = False  # POST /notifications/app-message, PUT /external-links/badge only
     printer_ids: list[int] | None = None  # null = all printers
     expires_at: datetime | None = None
 

+ 16 - 0
backend/app/schemas/external_link.py

@@ -56,10 +56,26 @@ class ExternalLinkResponse(ExternalLinkBase):
     sort_order: int
     created_at: datetime
     updated_at: datetime
+    # Set by the app the link points to, while it keeps reporting; None otherwise.
+    badge_count: int | None = None
 
     model_config = {"from_attributes": True}
 
 
+class ExternalLinkBadgeSet(BaseModel):
+    """An app's count for the sidebar link pointing at it."""
+
+    url: str = Field(..., min_length=1, max_length=500, description="The app's address, as the link has it")
+    count: int = Field(..., ge=0, le=1_000_000)
+
+    @field_validator("url")
+    @classmethod
+    def validate_url(cls, v: str) -> str:
+        if not v.startswith(("http://", "https://")):
+            raise ValueError("URL must start with http:// or https://")
+        return v
+
+
 class ExternalLinkReorder(BaseModel):
     """Schema for reordering external links."""
 

+ 237 - 0
backend/tests/integration/test_external_link_badge.py

@@ -0,0 +1,237 @@
+"""A count on an external sidebar link, set by the app it points to.
+
+The contract these tests pin:
+
+  ``PUT /external-links/badge`` with ``{url, count}`` sets the count on every
+  sidebar link pointing at that address (same origin and path, trailing slash
+  ignored), e.g. Bambuddy Orders with its To do count. Callers need what
+  ``POST /notifications/app-message`` needs: an API key with the "Send
+  notifications" permission, or a user with ``notifications:update``. The
+  list of links carries the count while it is fresh (set within the last ten
+  minutes), so an app that stopped reporting doesn't leave an old number.
+  Setting it doesn't touch the link's own ``updated_at``.
+"""
+
+from datetime import timedelta
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import generate_api_key
+from backend.app.models.api_key import APIKey
+from backend.app.models.external_link import ExternalLink, ExternalLinkBadge
+from backend.app.models.user import User
+from backend.app.utils.local_time import utcnow_naive
+
+URL = "/api/v1/external-links/badge"
+
+
+async def _admin_token(client: AsyncClient) -> str:
+    await client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": "badgeadmin",
+            "admin_password": "AdminPass1!",  # pragma: allowlist secret
+        },
+    )
+    login = await client.post(
+        "/api/v1/auth/login",
+        json={"username": "badgeadmin", "password": "AdminPass1!"},  # pragma: allowlist secret
+    )
+    return login.json()["access_token"]
+
+
+async def _key(db: AsyncSession, *, allowed: bool) -> str:
+    owner = (await db.execute(select(User).where(User.username == "badgeadmin"))).scalar_one()
+    full_key, key_hash, key_prefix = generate_api_key()
+    db.add(
+        APIKey(
+            name="Bambuddy Orders",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            user_id=owner.id,
+            can_send_notifications=allowed,
+        )
+    )
+    await db.commit()
+    return full_key
+
+
+async def _link(db: AsyncSession, url: str, name: str = "Orders") -> ExternalLink:
+    link = ExternalLink(name=name, url=url, icon="Package", sort_order=0)
+    db.add(link)
+    await db.commit()
+    await db.refresh(link)
+    return link
+
+
+async def _listed(client: AsyncClient, token: str) -> dict[str, int | None]:
+    response = await client.get("/api/v1/external-links/", headers={"Authorization": f"Bearer {token}"})
+    assert response.status_code == 200, response.text
+    return {link["name"]: link["badge_count"] for link in response.json()}
+
+
+class TestBadge:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_app_sets_the_count_on_the_link_pointing_at_it(self, async_client: AsyncClient, db_session):
+        token = await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        await _link(db_session, "http://orders.local:8090/")
+        await _link(db_session, "http://other.local:8090/", name="Other")
+
+        response = await async_client.put(
+            URL, json={"url": "http://orders.local:8090", "count": 7}, headers={"X-API-Key": key}
+        )
+        assert response.status_code == 200, response.text
+        assert response.json() == {"links": 1}
+        assert await _listed(async_client, token) == {"Orders": 7, "Other": None}
+
+        await async_client.put(URL, json={"url": "http://orders.local:8090/", "count": 0}, headers={"X-API-Key": key})
+        assert (await _listed(async_client, token))["Orders"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_path_counts_too(self, async_client: AsyncClient, db_session):
+        token = await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        await _link(db_session, "https://shop.example/orders/")
+        await _link(db_session, "https://shop.example/print", name="Print")
+
+        await async_client.put(URL, json={"url": "https://shop.example/orders", "count": 2}, headers={"X-API-Key": key})
+        assert await _listed(async_client, token) == {"Orders": 2, "Print": None}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_link_pointing_there_is_a_404(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        await _link(db_session, "http://orders.local:8090/")
+        response = await async_client.put(
+            URL, json={"url": "http://orders.local:9999", "count": 1}, headers={"X-API-Key": key}
+        )
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_old_count_is_not_shown(self, async_client: AsyncClient, db_session):
+        token = await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        await _link(db_session, "http://orders.local:8090")
+        await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
+        await db_session.execute(update(ExternalLinkBadge).values(updated_at=utcnow_naive() - timedelta(minutes=11)))
+        await db_session.commit()
+        assert (await _listed(async_client, token))["Orders"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_link_itself_is_not_changed(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        link = await _link(db_session, "http://orders.local:8090")
+        before = link.updated_at
+        await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
+        stored = await db_session.execute(select(ExternalLink.updated_at).where(ExternalLink.id == link.id))
+        assert stored.scalar_one() == before
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_the_link_drops_its_count(self, async_client: AsyncClient, db_session):
+        token = await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        link = await _link(db_session, "http://orders.local:8090")
+        await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
+        response = await async_client.delete(
+            f"/api/v1/external-links/{link.id}", headers={"Authorization": f"Bearer {token}"}
+        )
+        assert response.status_code == 200
+        assert (await db_session.execute(select(ExternalLinkBadge))).scalars().all() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_needs_the_send_notifications_permission(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        key = await _key(db_session, allowed=False)
+        await _link(db_session, "http://orders.local:8090")
+        body = {"url": "http://orders.local:8090", "count": 1}
+        assert (await async_client.put(URL, json=body, headers={"X-API-Key": key})).status_code == 403
+        assert (await async_client.put(URL, json=body)).status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_refuses_nonsense(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        for body in ({"url": "ftp://orders.local", "count": 1}, {"url": "http://orders.local", "count": -1}):
+            assert (await async_client.put(URL, json=body, headers={"X-API-Key": key})).status_code == 422
+
+
+class TestMatching:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_same_address_written_differently_still_matches(self, async_client: AsyncClient, db_session):
+        token = await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        await _link(db_session, "http://orders.local", name="Default port")
+        await _link(db_session, "https://user:pass@shop.example/", name="With login")  # pragma: allowlist secret
+        await _link(db_session, "http://ORDERS.local:80/", name="Spelled out")
+
+        response = await async_client.put(
+            URL, json={"url": "http://orders.local:80", "count": 4}, headers={"X-API-Key": key}
+        )
+        assert response.json() == {"links": 2}
+        await async_client.put(URL, json={"url": "https://shop.example:443", "count": 5}, headers={"X-API-Key": key})
+        assert await _listed(async_client, token) == {"Default port": 4, "With login": 5, "Spelled out": 4}
+
+
+class TestWhoMaySet:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_signed_in_user_needs_notifications_update(self, async_client: AsyncClient, db_session):
+        token = await _admin_token(async_client)
+        await _link(db_session, "http://orders.local:8090")
+        response = await async_client.put(
+            URL, json={"url": "http://orders.local:8090", "count": 2}, headers={"Authorization": f"Bearer {token}"}
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_key_can_do_no_more_than_its_owner(self, async_client: AsyncClient, db_session):
+        from backend.app.models.group import Group
+
+        await _admin_token(async_client)
+        viewers = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
+        owner = User(username="viewer", password_hash="x", role="user", is_active=True)
+        owner.groups = [viewers]
+        db_session.add(owner)
+        await db_session.commit()
+        full_key, key_hash, key_prefix = generate_api_key()
+        db_session.add(
+            APIKey(name="k", key_hash=key_hash, key_prefix=key_prefix, user_id=owner.id, can_send_notifications=True)
+        )
+        await db_session.commit()
+        await _link(db_session, "http://orders.local:8090")
+        response = await async_client.put(
+            URL, json={"url": "http://orders.local:8090", "count": 2}, headers={"X-API-Key": full_key}
+        )
+        assert response.status_code == 403
+
+
+class TestLinkGone:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_link_removed_any_way_takes_its_count_along(self, async_client: AsyncClient, db_session):
+        """Not only through the delete route: SQLite applies no ON DELETE by itself."""
+        token = await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        link = await _link(db_session, "http://orders.local:8090")
+        await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
+        await db_session.delete(await db_session.get(ExternalLink, link.id))
+        await db_session.commit()
+        assert (await db_session.execute(select(ExternalLinkBadge))).scalars().all() == []
+        await _link(db_session, "http://other.local:8090", name="Next")
+        assert await _listed(async_client, token) == {"Next": None}

+ 1 - 0
backend/tests/unit/test_not_null_update_schemas.py

@@ -23,6 +23,7 @@ NOT_A_ROW_UPDATE = {
     "LinkTagRequest": "links a tag through its own rules",
     "ShoppingListItemStatusUpdate": "one required field",
     "ExternalLinkReorder": "a list of ids",
+    "ExternalLinkBadgeSet": "an app's count for its links, every field required",
     "SlicerSettingUpdate": "a Bambu Cloud setting, not a database row",
     "SpoolmanInventoryUpdate": "a Spoolman spool, not a database row",
     "SpoolWeightUpdate": "a Spoolman spool, not a database row",

+ 1 - 0
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -675,6 +675,7 @@ NOT_A_FETCH_TARGET = {
     ("SystemConfigRequest", "backend_url"),
     ("ExternalLinkCreate", "url"),  # sidebar link, rendered in the UI, never requested
     ("ExternalLinkUpdate", "url"),
+    ("ExternalLinkBadgeSet", "url"),  # compared with the sidebar links' addresses, never requested
     ("AppMessage", "url"),  # an app's link, appended to the notification text; never requested
     ("FileUpdate", "external_url"),  # library file link (#3077), rendered in the UI, never fetched
     ("MaintenanceTypeCreate", "wiki_url"),  # documentation link surfaced in the UI/notifications

+ 37 - 0
frontend/src/__tests__/components/Layout.test.tsx

@@ -99,6 +99,43 @@ describe('Layout', () => {
     });
   });
 
+  describe('external link count', () => {
+    // A connected app (Bambuddy Orders) reports its To do count for the link
+    // pointing at it; the sidebar shows it like the queue's badge.
+    const link = (id: number, name: string, badge_count: number | null, open_in_new_tab = false) => ({
+      id,
+      name,
+      url: `http://app${id}.local:8090`,
+      icon: 'Package',
+      open_in_new_tab,
+      custom_icon: null,
+      sort_order: id,
+      created_at: '2026-10-21T00:00:00Z',
+      updated_at: '2026-10-21T00:00:00Z',
+      badge_count,
+    });
+
+    it('shows the count an app reports, capped at 99+', async () => {
+      server.use(
+        http.get('/api/v1/external-links/', () =>
+          HttpResponse.json([
+            link(1, 'Orders', 7),
+            link(2, 'Shop', 150, true),
+            link(3, 'Wiki', 0),
+            link(4, 'Docs', null),
+          ]),
+        ),
+      );
+      render(<Layout />);
+
+      const orders = await screen.findByTestId('external-link-badge-1');
+      expect(orders).toHaveTextContent('7');
+      expect(screen.getByTestId('external-link-badge-2')).toHaveTextContent('99+');
+      expect(screen.queryByTestId('external-link-badge-3')).not.toBeInTheDocument();
+      expect(screen.queryByTestId('external-link-badge-4')).not.toBeInTheDocument();
+    });
+  });
+
   describe('navigation', () => {
     it('has navigation items', async () => {
       render(<Layout />);

+ 2 - 0
frontend/src/api/client.ts

@@ -4299,6 +4299,8 @@ export interface ExternalLink {
   sort_order: number;
   created_at: string;
   updated_at: string;
+  /** Count reported by the app the link points to, while it keeps reporting. */
+  badge_count?: number | null;
 }
 
 export interface ExternalLinkCreate {

+ 27 - 18
frontend/src/components/Layout.tsx

@@ -241,6 +241,8 @@ export function Layout() {
   const { data: externalLinks } = useQuery({
     queryKey: ['external-links'],
     queryFn: api.getExternalLinks,
+    // Like the queue badge: apps behind the links report counts.
+    refetchInterval: 30_000,
   });
 
   // Fetch smart plugs to check for switchbar items
@@ -665,6 +667,29 @@ export function Layout() {
                 if (!link) return null;
 
                 const LinkIcon = link.custom_icon ? null : getIconByName(link.icon);
+                // A count the app behind the link reports (Bambuddy Orders' To do).
+                const linkBadge = link.badge_count ?? 0;
+                const linkIcon = (
+                  <div className="relative">
+                    {link.custom_icon ? (
+                      <img
+                        src={api.getExternalLinkIconUrl(link.id)}
+                        alt=""
+                        className="w-5 h-5 flex-shrink-0"
+                      />
+                    ) : (
+                      LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
+                    )}
+                    {linkBadge > 0 && (
+                      <span
+                        data-testid={`external-link-badge-${link.id}`}
+                        className="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center text-[10px] font-bold rounded-full bg-yellow-500 text-black"
+                      >
+                        {linkBadge > 99 ? '99+' : linkBadge}
+                      </span>
+                    )}
+                  </div>
+                );
                 return (
                   <li key={id}>
                     {link.open_in_new_tab ? (
@@ -675,15 +700,7 @@ export function Layout() {
                         className={`flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`}
                         title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
                       >
-                        {link.custom_icon ? (
-                          <img
-                            src={api.getExternalLinkIconUrl(link.id)}
-                            alt=""
-                            className="w-5 h-5 flex-shrink-0"
-                          />
-                        ) : (
-                          LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
-                        )}
+                        {linkIcon}
                         {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
                       </a>
                     ) : (
@@ -698,15 +715,7 @@ export function Layout() {
                         }
                         title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
                       >
-                        {link.custom_icon ? (
-                          <img
-                            src={api.getExternalLinkIconUrl(link.id)}
-                            alt=""
-                            className="w-5 h-5 flex-shrink-0"
-                          />
-                        ) : (
-                          LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
-                        )}
+                        {linkIcon}
                         {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
                       </NavLink>
                     )}

+ 1 - 1
frontend/src/i18n/locales/de.ts

@@ -2422,7 +2422,7 @@ export default {
     updateEnergyCostDescription: 'Erlaubt diesem Schlüssel, einen neuen Strompreis pro kWh an /settings/electricity-price zu senden. Nützlich für Home-Assistant-Automatisierungen mit dynamischen Tarifen (Tibber, Octopus usw.). Dies ist das einzige Einstellungsfeld, das per API-Schlüssel schreibbar ist.',
     energyCostBadge: 'Energie',
     sendNotifications: 'Benachrichtigungen senden',
-    sendNotificationsDescription: 'Erlaubt diesem Schlüssel, Nachrichten über die Benachrichtigungskanäle zu senden, die Nachrichten verbundener Apps annehmen (POST /notifications/app-message). Für Apps wie Bambuddy Orders. Sonst nichts: Der Schlüssel kann die Kanäle weder lesen noch ändern.',
+    sendNotificationsDescription: 'Erlaubt diesem Schlüssel, Nachrichten über die Benachrichtigungskanäle zu senden, die Nachrichten verbundener Apps annehmen (POST /notifications/app-message), und eine Zahl am Seitenleisten-Link anzuzeigen, der auf die App zeigt (PUT /external-links/badge). Für Apps wie Bambuddy Orders. Sonst nichts: Der Schlüssel kann die Kanäle und Links weder lesen noch ändern.',
     sendNotificationsBadge: 'Benachr.',
     legacyKey: 'Alt',
     legacyKeyTooltip: 'Wurde vor der nutzerbezogenen Eigentümerschaft erstellt; neu erstellen, um Cloud-Zugriff zu nutzen',

+ 1 - 1
frontend/src/i18n/locales/en.ts

@@ -2444,7 +2444,7 @@ export default {
     updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
     energyCostBadge: 'Energy',
     sendNotifications: 'Send notifications',
-    sendNotificationsDescription: 'Allow this key to send messages through the notification channels that accept messages from connected apps (POST /notifications/app-message). For apps like Bambuddy Orders. Nothing else: the key can\'t read or change the channels.',
+    sendNotificationsDescription: 'Allow this key to send messages through the notification channels that accept messages from connected apps (POST /notifications/app-message), and to show a count on the sidebar link that points at the app (PUT /external-links/badge). For apps like Bambuddy Orders. Nothing else: the key can\'t read or change the channels or the links.',
     sendNotificationsBadge: 'Notify',
     legacyKey: 'Legacy',
     legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',

+ 1 - 1
frontend/src/i18n/locales/es.ts

@@ -2425,7 +2425,7 @@ export default {
     updateEnergyCostDescription: 'Permite que esta clave envíe por POST un nuevo precio de electricidad por kWh a /settings/electricity-price. Útil para las automatizaciones de tarifa dinámica de Home Assistant (Tibber, Octopus, etc.). Este es el único campo de ajustes que se puede escribir mediante una clave API.',
     energyCostBadge: 'Energía',
     sendNotifications: 'Enviar notificaciones',
-    sendNotificationsDescription: 'Permite a esta clave enviar mensajes a través de los canales de notificación que aceptan mensajes de aplicaciones conectadas (POST /notifications/app-message). Para aplicaciones como Bambuddy Orders. Nada más: la clave no puede leer ni cambiar los canales.',
+    sendNotificationsDescription: 'Permite a esta clave enviar mensajes a través de los canales de notificación que aceptan mensajes de aplicaciones conectadas (POST /notifications/app-message) y mostrar un número en el enlace de la barra lateral que apunta a la aplicación (PUT /external-links/badge). Para aplicaciones como Bambuddy Orders. Nada más: la clave no puede leer ni cambiar los canales ni los enlaces.',
     sendNotificationsBadge: 'Notif.',
     legacyKey: 'Heredada',
     legacyKeyTooltip: 'Creada antes de la propiedad por usuario; vuelva a crearla para usar el acceso a la nube',

+ 1 - 1
frontend/src/i18n/locales/fr.ts

@@ -2377,7 +2377,7 @@ export default {
     updateEnergyCostDescription: 'Autoriser cette clé à POSTer un nouveau tarif électrique par kWh sur /settings/electricity-price. Utile pour les automatisations Home Assistant à tarif dynamique (Tibber, Octopus, etc.). Il s\'agit du seul champ de paramètres modifiable via clé API.',
     energyCostBadge: 'Énergie',
     sendNotifications: 'Envoyer des notifications',
-    sendNotificationsDescription: 'Autorise cette clé à envoyer des messages via les canaux de notification qui acceptent les messages des applications connectées (POST /notifications/app-message). Pour des applications comme Bambuddy Orders. Rien d\'autre : la clé ne peut ni lire ni modifier les canaux.',
+    sendNotificationsDescription: 'Autorise cette clé à envoyer des messages via les canaux de notification qui acceptent les messages des applications connectées (POST /notifications/app-message), et à afficher un nombre sur le lien de la barre latérale qui pointe vers l\'application (PUT /external-links/badge). Pour des applications comme Bambuddy Orders. Rien d\'autre : la clé ne peut ni lire ni modifier les canaux ou les liens.',
     sendNotificationsBadge: 'Notif.',
     legacyKey: 'Hérité',
     legacyKeyTooltip: 'Créé avant la propriété par utilisateur ; recréez pour l\'accès cloud',

+ 1 - 1
frontend/src/i18n/locales/it.ts

@@ -2377,7 +2377,7 @@ export default {
     updateEnergyCostDescription: 'Consenti a questa chiave di inviare in POST un nuovo prezzo elettricità per kWh a /settings/electricity-price. Utile per automazioni Home Assistant a tariffa dinamica (Tibber, Octopus, ecc.). Questo è l\'unico campo di impostazioni scrivibile via API key.',
     energyCostBadge: 'Energia',
     sendNotifications: 'Invia notifiche',
-    sendNotificationsDescription: 'Consente a questa chiave di inviare messaggi tramite i canali di notifica che accettano messaggi dalle app collegate (POST /notifications/app-message). Per app come Bambuddy Orders. Nient\'altro: la chiave non può leggere né modificare i canali.',
+    sendNotificationsDescription: 'Consente a questa chiave di inviare messaggi tramite i canali di notifica che accettano messaggi dalle app collegate (POST /notifications/app-message) e di mostrare un numero sul link della barra laterale che punta all\'app (PUT /external-links/badge). Per app come Bambuddy Orders. Nient\'altro: la chiave non può leggere né modificare i canali o i link.',
     sendNotificationsBadge: 'Notif.',
     legacyKey: 'Legacy',
     legacyKeyTooltip: 'Creato prima della proprietà per utente; ricreare per accesso cloud',

+ 1 - 1
frontend/src/i18n/locales/ja.ts

@@ -2421,7 +2421,7 @@ export default {
     updateEnergyCostDescription: 'このキーが /settings/electricity-price に新しいkWhごとの電気料金をPOSTすることを許可。Home Assistantの動的料金自動化(Tibber、Octopusなど)に便利。これはAPIキーで書き込み可能な唯一の設定フィールドです。',
     energyCostBadge: '電力',
     sendNotifications: '通知の送信',
-    sendNotificationsDescription: 'このキーで、連携アプリからのメッセージを受け付ける通知チャネル経由でメッセージを送信できるようにします(POST /notifications/app-message)。Bambuddy Orders などのアプリ向けです。それ以外の権限はなく、チャネルの読み取りや変更はできません。',
+    sendNotificationsDescription: 'このキーで、連携アプリからのメッセージを受け付ける通知チャネル経由でメッセージを送信し(POST /notifications/app-message)、そのアプリを指すサイドバーのリンクに件数を表示できるようにします(PUT /external-links/badge)。Bambuddy Orders などのアプリ向けです。それ以外の権限はなく、チャネルやリンクの読み取りや変更はできません。',
     sendNotificationsBadge: '通知',
     legacyKey: 'レガシー',
     legacyKeyTooltip: 'ユーザー所有以前に作成 - クラウドアクセスには再作成が必要',

+ 1 - 1
frontend/src/i18n/locales/ko.ts

@@ -2998,7 +2998,7 @@ export default {
     updateEnergyCostDescription: '이 키가 /settings/electricity-price에 새 kWh당 전기 요금을 POST할 수 있도록 허용합니다. Home Assistant 동적 요금 자동화(Tibber, Octopus 등)에 유용합니다. API 키로 쓸 수 있는 유일한 설정 필드입니다.',
     energyCostBadge: '에너지',
     sendNotifications: '알림 보내기',
-    sendNotificationsDescription: '이 키가 연결된 앱의 메시지를 받는 알림 채널을 통해 메시지를 보낼 수 있도록 허용합니다(POST /notifications/app-message). Bambuddy Orders 같은 앱용입니다. 그 외에는 없으며, 키로 채널을 읽거나 변경할 수 없습니다.',
+    sendNotificationsDescription: '이 키가 연결된 앱의 메시지를 받는 알림 채널을 통해 메시지를 보내고(POST /notifications/app-message), 해당 앱을 가리키는 사이드바 링크에 숫자를 표시할 수 있도록 허용합니다(PUT /external-links/badge). Bambuddy Orders 같은 앱용입니다. 그 외에는 없으며, 키로 채널이나 링크를 읽거나 변경할 수 없습니다.',
     sendNotificationsBadge: '알림',
     passwordRequirements: '최소 8자, 대문자, 소문자, 숫자, 특수문자 각 1개 이상 포함',
 

+ 1 - 1
frontend/src/i18n/locales/nl.ts

@@ -2442,7 +2442,7 @@ export default {
     updateEnergyCostDescription: 'Sta toe dat deze sleutel via POST een nieuwe elektriciteitsprijs per kWh naar /settings/electricity-price stuurt. Handig voor Home Assistant-automatiseringen met dynamische tarieven (Tibber, Octopus, enz.). Dit is het enige instellingenveld dat via een API-sleutel schrijfbaar is.',
     energyCostBadge: 'Energie',
     sendNotifications: 'Meldingen versturen',
-    sendNotificationsDescription: 'Staat deze sleutel toe berichten te versturen via de meldingskanalen die berichten van gekoppelde apps accepteren (POST /notifications/app-message). Voor apps zoals Bambuddy Orders. Verder niets: de sleutel kan de kanalen niet lezen of wijzigen.',
+    sendNotificationsDescription: 'Staat deze sleutel toe berichten te versturen via de meldingskanalen die berichten van gekoppelde apps accepteren (POST /notifications/app-message) en een aantal te tonen op de zijbalklink die naar de app wijst (PUT /external-links/badge). Voor apps zoals Bambuddy Orders. Verder niets: de sleutel kan de kanalen en links niet lezen of wijzigen.',
     sendNotificationsBadge: 'Meldingen',
     legacyKey: 'Verouderd',
     legacyKeyTooltip: 'Aangemaakt vóór eigendom per gebruiker; maak opnieuw om cloudtoegang te gebruiken',

+ 1 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -2377,7 +2377,7 @@ export default {
     updateEnergyCostDescription: 'Permitir que esta chave envie POST de um novo preço por kWh para /settings/electricity-price. Útil para automações de tarifa dinâmica do Home Assistant (Tibber, Octopus, etc.). Este é o único campo de configuração gravável via API key.',
     energyCostBadge: 'Energia',
     sendNotifications: 'Enviar notificações',
-    sendNotificationsDescription: 'Permite que esta chave envie mensagens pelos canais de notificação que aceitam mensagens de aplicativos conectados (POST /notifications/app-message). Para aplicativos como o Bambuddy Orders. Nada além disso: a chave não pode ler nem alterar os canais.',
+    sendNotificationsDescription: 'Permite que esta chave envie mensagens pelos canais de notificação que aceitam mensagens de aplicativos conectados (POST /notifications/app-message) e mostre um número no link da barra lateral que aponta para o aplicativo (PUT /external-links/badge). Para aplicativos como o Bambuddy Orders. Nada além disso: a chave não pode ler nem alterar os canais ou os links.',
     sendNotificationsBadge: 'Notif.',
     legacyKey: 'Legado',
     legacyKeyTooltip: 'Criado antes da propriedade por usuário; recrie para acesso à nuvem',

+ 1 - 1
frontend/src/i18n/locales/ru.ts

@@ -2303,7 +2303,7 @@ export default {
     updateEnergyCostDescription: "Разрешить этому ключу отправлять новую цену за кВт⋅ч методом POST в /settings/electricity-price. Полезно для автоматизаций динамического тарифа Home Assistant (Tibber, Octopus и т. п.). Это единственный параметр настроек, доступный для записи через ключ API.",
     energyCostBadge: "Энергия",
     sendNotifications: 'Отправка уведомлений',
-    sendNotificationsDescription: 'Разрешает этому ключу отправлять сообщения через каналы уведомлений, принимающие сообщения подключённых приложений (POST /notifications/app-message). Для приложений вроде Bambuddy Orders. Больше ничего: ключ не может читать или изменять каналы.',
+    sendNotificationsDescription: 'Разрешает этому ключу отправлять сообщения через каналы уведомлений, принимающие сообщения подключённых приложений (POST /notifications/app-message), и показывать число на ссылке боковой панели, ведущей на приложение (PUT /external-links/badge). Для приложений вроде Bambuddy Orders. Больше ничего: ключ не может читать или изменять каналы и ссылки.',
     sendNotificationsBadge: 'Уведомл.',
     legacyKey: "Устаревший",
     legacyKeyTooltip: "Создан до появления привязки к пользователям; пересоздайте для доступа к облаку",

+ 1 - 1
frontend/src/i18n/locales/sv.ts

@@ -2441,7 +2441,7 @@ export default {
     updateEnergyCostDescription: 'Tillåt denna nyckel att POST:a ett nytt pris per kWh till /settings/electricity-price. Användbart för Home Assistants dynamiska tariffautomatiseringar (Tibber, Octopus, etc.). Detta är det enda inställningsfält som kan skrivas via API-nyckel.',
     energyCostBadge: 'Energi',
     sendNotifications: 'Skicka aviseringar',
-    sendNotificationsDescription: 'Tillåter nyckeln att skicka meddelanden via de aviseringskanaler som tar emot meddelanden från anslutna appar (POST /notifications/app-message). För appar som Bambuddy Orders. Inget annat: nyckeln kan inte läsa eller ändra kanalerna.',
+    sendNotificationsDescription: 'Tillåter nyckeln att skicka meddelanden via de aviseringskanaler som tar emot meddelanden från anslutna appar (POST /notifications/app-message) och att visa ett antal på sidofältslänken som pekar på appen (PUT /external-links/badge). För appar som Bambuddy Orders. Inget annat: nyckeln kan inte läsa eller ändra kanalerna eller länkarna.',
     sendNotificationsBadge: 'Avis.',
     legacyKey: 'Äldre nyckel',
     legacyKeyTooltip: 'Skapad före användarägande; skapa igen för att använda molnåtkomst',

+ 1 - 1
frontend/src/i18n/locales/tr.ts

@@ -2426,7 +2426,7 @@ export default {
     updateEnergyCostDescription: "Bu anahtarın /settings/electricity-price üzerine yeni bir kWh başına elektrik fiyatı POST etmesine izin ver. Home Assistant dinamik tarife otomasyonları (Tibber, Octopus, vb.) için kullanışlıdır. Bu, API anahtarıyla yazılabilen tek ayar alanıdır.",
     energyCostBadge: 'Enerji',
     sendNotifications: 'Bildirim gönder',
-    sendNotificationsDescription: 'Bu anahtarın, bağlı uygulamalardan gelen mesajları kabul eden bildirim kanalları üzerinden mesaj göndermesine izin verir (POST /notifications/app-message). Bambuddy Orders gibi uygulamalar için. Başka bir şey değil: anahtar kanalları okuyamaz veya değiştiremez.',
+    sendNotificationsDescription: 'Bu anahtarın, bağlı uygulamalardan gelen mesajları kabul eden bildirim kanalları üzerinden mesaj göndermesine (POST /notifications/app-message) ve uygulamayı gösteren kenar çubuğu bağlantısında bir sayı göstermesine (PUT /external-links/badge) izin verir. Bambuddy Orders gibi uygulamalar için. Başka bir şey değil: anahtar kanalları veya bağlantıları okuyamaz ya da değiştiremez.',
     sendNotificationsBadge: 'Bildirim',
     legacyKey: 'Eski',
     legacyKeyTooltip: 'Kullanıcı başına sahiplikten önce oluşturuldu; bulut erişimini kullanmak için yeniden oluşturun',

+ 1 - 1
frontend/src/i18n/locales/uk.ts

@@ -2440,7 +2440,7 @@ export default {
     updateEnergyCostDescription: "Дозволити цьому ключу надсилати нову ціну електроенергії за кВт·год до /settings/electricity-price. Це корисно для автоматизацій Home Assistant із динамічними тарифами (Tibber, Octopus тощо). Це єдине поле налаштувань, яке можна змінювати за допомогою ключа API.",
     energyCostBadge: "Енергія",
     sendNotifications: 'Надсилання сповіщень',
-    sendNotificationsDescription: 'Дозволяє цьому ключу надсилати повідомлення через канали сповіщень, які приймають повідомлення підключених застосунків (POST /notifications/app-message). Для застосунків на кшталт Bambuddy Orders. Більше нічого: ключ не може читати чи змінювати канали.',
+    sendNotificationsDescription: 'Дозволяє цьому ключу надсилати повідомлення через канали сповіщень, які приймають повідомлення підключених застосунків (POST /notifications/app-message), і показувати число на посиланні бічної панелі, що веде на застосунок (PUT /external-links/badge). Для застосунків на кшталт Bambuddy Orders. Більше нічого: ключ не може читати чи змінювати канали й посилання.',
     sendNotificationsBadge: 'Сповіщ.',
     legacyKey: "Застарілий",
     legacyKeyTooltip: "Створено до отримання права власності на користувача; повторно створити, щоб використовувати доступ до хмари",

+ 1 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -2422,7 +2422,7 @@ export default {
     updateEnergyCostDescription: '允许此密钥向 /settings/electricity-price POST 新的每千瓦时电价。适用于 Home Assistant 动态电价自动化(Tibber、Octopus 等)。这是唯一可通过 API 密钥写入的设置字段。',
     energyCostBadge: '能耗',
     sendNotifications: '发送通知',
-    sendNotificationsDescription: '允许此密钥通过接受已连接应用消息的通知渠道发送消息(POST /notifications/app-message)。适用于 Bambuddy Orders 等应用。仅此而已:该密钥无法读取或更改渠道。',
+    sendNotificationsDescription: '允许此密钥通过接受已连接应用消息的通知渠道发送消息(POST /notifications/app-message),并在指向该应用的侧边栏链接上显示数字(PUT /external-links/badge)。适用于 Bambuddy Orders 等应用。仅此而已:该密钥无法读取或更改渠道和链接。',
     sendNotificationsBadge: '通知',
     legacyKey: '传统',
     legacyKeyTooltip: '在按用户所有权之前创建;需重建以使用云端访问',

+ 1 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -2422,7 +2422,7 @@ export default {
     updateEnergyCostDescription: '允許此金鑰向 /settings/electricity-price POST 新的每千瓦時電價。適用於 Home Assistant 動態電價自動化(Tibber、Octopus 等)。這是唯一可透過 API 金鑰寫入的設定欄位。',
     energyCostBadge: '能耗',
     sendNotifications: '傳送通知',
-    sendNotificationsDescription: '允許此金鑰透過接受已連線應用程式訊息的通知管道傳送訊息(POST /notifications/app-message)。適用於 Bambuddy Orders 等應用程式。僅此而已:此金鑰無法讀取或變更管道。',
+    sendNotificationsDescription: '允許此金鑰透過接受已連線應用程式訊息的通知管道傳送訊息(POST /notifications/app-message),並在指向該應用程式的側邊欄連結上顯示數字(PUT /external-links/badge)。適用於 Bambuddy Orders 等應用程式。僅此而已:此金鑰無法讀取或變更管道和連結。',
     sendNotificationsBadge: '通知',
     legacyKey: '舊版',
     legacyKeyTooltip: '在按使用者所有權之前建立;需重建以使用雲端存取',

Plik diff jest za duży
+ 0 - 0
static/assets/ArchivesPage-Cijsih6K.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraPage-Wnku0dqm.js


Plik diff jest za duży
+ 0 - 0
static/assets/FileManagerPage-CLYHqJkv.js


Plik diff jest za duży
+ 0 - 0
static/assets/InventoryPage-BasVhHe0.js


Plik diff jest za duży
+ 0 - 0
static/assets/Layout-ONJVQ_oD.js


Plik diff jest za duży
+ 0 - 0
static/assets/ModelSourcesPage-BvLs6J_U.js


Plik diff jest za duży
+ 0 - 0
static/assets/PhotoGalleryModal-btgMm3DW.js


Plik diff jest za duży
+ 0 - 0
static/assets/PrintersPage-BsUyKUN8.js


Plik diff jest za duży
+ 0 - 0
static/assets/SettingsPage-Beo2lGCx.js


Plik diff jest za duży
+ 0 - 0
static/assets/SliceModal-Cba5GYI7.js


Plik diff jest za duży
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BeM5JwM3.js


Plik diff jest za duży
+ 0 - 0
static/assets/de-D39JI_8d.js


Plik diff jest za duży
+ 0 - 0
static/assets/es-BHL-6pV1.js


Plik diff jest za duży
+ 0 - 0
static/assets/es-QuPepRKs.js


Plik diff jest za duży
+ 0 - 0
static/assets/fr-C_28VCx0.js


Plik diff jest za duży
+ 0 - 0
static/assets/fr-CpEo28WL.js


Plik diff jest za duży
+ 0 - 0
static/assets/index-BNCUzO2G.js


Plik diff jest za duży
+ 0 - 0
static/assets/it-CFRvX4yY.js


Plik diff jest za duży
+ 0 - 0
static/assets/it-Dl6JGckA.js


Plik diff jest za duży
+ 0 - 0
static/assets/ja-vehaWIqv.js


Plik diff jest za duży
+ 0 - 0
static/assets/ko-CbTWUFFi.js


Plik diff jest za duży
+ 0 - 0
static/assets/locationSensorDefaults-BuT1z5wg.js


Plik diff jest za duży
+ 0 - 0
static/assets/nl-CixN_KdW.js


Plik diff jest za duży
+ 0 - 0
static/assets/nl-CyemxYPq.js


Plik diff jest za duży
+ 0 - 0
static/assets/pt-BR-DBfeh188.js


Plik diff jest za duży
+ 0 - 0
static/assets/pt-BR-sDjXgo3_.js


Plik diff jest za duży
+ 0 - 0
static/assets/ru-rVUUdG_R.js


Plik diff jest za duży
+ 0 - 0
static/assets/sv-BbgCZKfM.js


Plik diff jest za duży
+ 0 - 0
static/assets/sv-Cqpp_hoE.js


Plik diff jest za duży
+ 0 - 0
static/assets/tr-BXpm1KWk.js


Plik diff jest za duży
+ 0 - 0
static/assets/tr-Fokmo5P7.js


Plik diff jest za duży
+ 0 - 0
static/assets/uk-DO3--z4v.js


Plik diff jest za duży
+ 0 - 0
static/assets/zh-CN-D7IpC6DP.js


Plik diff jest za duży
+ 0 - 0
static/assets/zh-CN-D8DqooDL.js


Plik diff jest za duży
+ 0 - 0
static/assets/zh-TW-DARWgtcC.js


+ 2 - 2
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C0j9bTzR.js"></script>
+    <script type="module" crossorigin src="/assets/index-BNCUzO2G.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-DSw2TdH4.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-PmRWqOqI.js">
@@ -120,7 +120,7 @@
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-CpiTIx_j.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-jD6ObGRm.js">
     <link rel="modulepreload" crossorigin href="/assets/plateCheck-fqh7_usj.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-BE8fdF9U.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-ONJVQ_oD.js">
     <link rel="stylesheet" crossorigin href="/assets/index-Cs9GKX3n.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików