Browse Source

Add Gotify as a notification provider (#2743)

Posts to the server's /message endpoint with the app token in the
X-Gotify-Key header. Each event's priority level is sent to Gotify as
0, 2, 5, 8 or 10, set in the same per-event priority section ntfy uses.
Events without a level go out at 5. The snapshot goes out as a
bigImageUrl, and the outcome confirmation opens its dialog on tap.

-----

List notification providers alphabetically

The Provider Type list in the Add Notification Provider dialog is now
sorted by the name shown on screen, in the current UI language, so the
order holds in every locale.
maziggy 1 day ago
parent
commit
f471fe7b27

+ 4 - 0
CHANGELOG.md

@@ -5,6 +5,9 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **Gotify notification provider (#2743, requested by @MrCrunshy)** — Notifications can now go to your own Gotify server. Add a provider of type Gotify with the server URL and an application token; the token travels in a header, never in the URL.
+  - **Per-event priority:** like ntfy, each enabled event can be set to Min, Low, Default, High or Urgent, sent to Gotify as 0, 2, 5, 8 and 10. Events left at Default go out at 5, which plays a sound in the Gotify Android app.
+  - **Photo and tap action:** with External URL set, the camera snapshot shows as a large picture in the Android app, and tapping the outcome-confirmation notification opens the outcome dialog in Bambuddy.
 - **The Cam Wall's tiles can be resized with the S / M / L / XL selector (#2735, requested by @cmerkle)** — The size selector on the Printers page used to grey out on the Cam Wall, and the tiles always sat up to four in a row, so a setup with two printers got two small tiles side by side.
   - **On the Printers page:** while the Cam Wall is showing, the selector sizes the tiles: S fits up to five per row on a wide screen, M four (as before, and still the default), L two, and XL one tile across the full width. Tiles keep their 16:9 shape.
   - **Remembered on its own:** the Cam Wall keeps its own size, so changing it leaves the card size alone, and the other way round.
@@ -108,6 +111,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
 
 ### Changed
+- **Notification providers are listed alphabetically** — The Provider Type list in the Add Notification Provider dialog is now sorted by name, in whichever language the app is set to, instead of a fixed order that was hard to scan.
 - **The Streaming Overlay builder remembers your choices** — Settings → Camera → Streaming Overlay kept its choices only while the page was open, so the fields to show, printer, size, frame rate, layout, artwork, transparency, camera feed and progress colours were back at their defaults after a reload or a visit to another page. They are now remembered in the browser you used, with a "Settings saved" toast once your edits settle. The token is still never stored. A new **Reset choices** link puts everything back to the defaults and keeps the token and the uploaded logo.
 - **The camera settings stay short on large farms** — Settings → Camera listed every printer with its external camera switch and fields, so a farm of 120 printers got a card many screens long. The **External Cameras** list now holds only the printers that have one: **Add external camera** picks a printer from a list you can search by name or location, each row shows the camera type and address and folds its fields away, and a bin icon switches the printer back to its built-in camera.
 - **Camera settings have their own tab, and the settings menu is sorted alphabetically** — Camera settings were split across two tabs: external cameras under General, and camera tokens and the Streaming Overlay builder under API Keys. A new **Camera** tab now holds all three: **External Cameras**, **Camera API Tokens** and **Streaming Overlay**. Old links to the tokens or the overlay builder, including `/camera-tokens`, open the Camera tab. API Keys keeps API keys, webhooks, Connected Apps and the API browser, and is no longer shown to a user who may see none of them; before, such a user only went there for their camera tokens, so an API Keys link now opens the Camera tab for them. Settings search finds the Streaming Overlay builder. The settings menu now lists **General** first and the other tabs alphabetically in the interface language. In Connected Apps, the **Add app** button no longer runs past the edge of the card on mid-sized screens, and the Brazilian Portuguese camera button on the printer card reads "Câmera" again instead of "âmera". Wiki updated. Covered by frontend tests.

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

@@ -20,6 +20,7 @@ class ProviderType(StrEnum):
     WEBHOOK = "webhook"
     HOMEASSISTANT = "homeassistant"
     BARK = "bark"
+    GOTIFY = "gotify"
 
 
 class NotificationProviderBase(BaseModel):
@@ -374,6 +375,21 @@ class NtfyConfig(BaseModel):
     )
 
 
+class GotifyConfig(BaseModel):
+    """Gotify configuration (#2743)."""
+
+    server: str = Field(..., description="Gotify server URL")
+    app_token: str = Field(..., description="Token of the Gotify application to post as")
+    event_priorities: dict[str, int] | None = Field(
+        default=None,
+        description=(
+            "Per-event priority override, keyed like NtfyConfig.event_priorities. Values are "
+            "levels 1-5 (min, low, default, high, urgent), sent to Gotify as 0, 2, 5, 8 and 10. "
+            "Events without an entry are sent at 5."
+        ),
+    )
+
+
 class PushoverConfig(BaseModel):
     """Pushover configuration."""
 

+ 99 - 11
backend/app/services/notification_service.py

@@ -110,6 +110,32 @@ def _assert_safe_provider_url(url: str, *, label: str) -> str | None:
     return None
 
 
+def _event_priority_level(config: dict, event_type: str | None) -> int | None:
+    """The 1-5 priority level mapped to this event in config.event_priorities.
+
+    1=min, 2=low, 3=default, 4=high, 5=urgent, as the dialog writes them for
+    ntfy (#990) and Gotify (#2743). None when the event has no valid entry.
+    """
+    event_priorities = config.get("event_priorities") or {}
+    if not event_type or not isinstance(event_priorities, dict):
+        return None
+    raw = event_priorities.get(event_type)
+    if raw is None and not event_type.startswith("on_"):
+        raw = event_priorities.get(f"on_{event_type}")
+    try:
+        level = int(raw) if raw is not None else None
+    except (TypeError, ValueError):
+        return None
+    if level is not None and 1 <= level <= 5:
+        return level
+    return None
+
+
+# Gotify priorities run 0-10; its Android app stays silent below 4 and pops
+# the notification up from 8. The dialog's five levels map onto those bands.
+_GOTIFY_PRIORITY_BY_LEVEL = {1: 0, 2: 2, 3: 5, 4: 8, 5: 10}
+
+
 def _opaque_http_failure(response: httpx.Response, *, label: str) -> str:
     """Failure message for a provider whose destination host the user supplies.
 
@@ -369,6 +395,9 @@ class NotificationService:
             elif provider_type == "bark":
                 photo_url = await self._get_or_build_photo_url(db, image_data, "test") if attach_photo else None
                 return await self._send_bark(config, title, message, image_url=photo_url)
+            elif provider_type == "gotify":
+                photo_url = await self._get_or_build_photo_url(db, image_data, "test") if attach_photo else None
+                return await self._send_gotify(config, title, message, event_type="test", image_url=photo_url)
             else:
                 return False, f"Unknown provider type: {provider_type}"
         except Exception as e:
@@ -455,6 +484,58 @@ class NotificationService:
             return True, "Message sent successfully"
         return False, _opaque_http_failure(response, label="Bark server")
 
+    async def _send_gotify(
+        self,
+        config: dict,
+        title: str,
+        message: str,
+        event_type: str | None = None,
+        url: str | None = None,
+        image_url: str | None = None,
+    ) -> tuple[bool, str]:
+        """Send notification via a self-hosted Gotify server (#2743).
+
+        POSTs JSON to {server}/message with the application token in the
+        X-Gotify-Key header, so the token never ends up in a URL or a log line.
+        Gotify takes no uploads: a photo goes in as a URL its Android app
+        fetches itself, and ``url`` opens on tap.
+        """
+        server = (config.get("server") or "").strip().rstrip("/")
+        # Gotify's own docs show the full endpoint, so a pasted ".../message"
+        # must not turn into ".../message/message".
+        server = server.removesuffix("/message")
+        app_token = (config.get("app_token") or "").strip()
+
+        if not server or not app_token:
+            return False, "Server URL and app token are required"
+
+        url_error = _assert_safe_provider_url(server, label="Gotify server URL")
+        if url_error:
+            return False, url_error
+
+        # Events without a mapped level go out at Gotify's middle band (5), the
+        # "Default" the dialog shows for them -- sound on, no pop-up.
+        level = _event_priority_level(config, event_type) or 3
+        payload: dict[str, Any] = {
+            "title": title,
+            "message": message,
+            "priority": _GOTIFY_PRIORITY_BY_LEVEL[level],
+        }
+        notification_extras: dict[str, Any] = {}
+        if url:
+            notification_extras["click"] = {"url": url}
+        if image_url:
+            notification_extras["bigImageUrl"] = image_url
+        if notification_extras:
+            payload["extras"] = {"client::notification": notification_extras}
+
+        client = await self._get_client()
+        response = await client.post(f"{server}/message", json=payload, headers={"X-Gotify-Key": app_token})
+
+        if response.status_code == 200:
+            return True, "Message sent successfully"
+        return False, _opaque_http_failure(response, label="Gotify server")
+
     async def _send_ntfy(
         self,
         config: dict,
@@ -498,17 +579,9 @@ class NotificationService:
         # lookup used to miss for every real notification and hit only in tests
         # that called this method with the prefixed name (issue #3139). Both
         # spellings are accepted, which also leaves stored configs untouched.
-        event_priorities = config.get("event_priorities") or {}
-        if event_type and isinstance(event_priorities, dict):
-            raw = event_priorities.get(event_type)
-            if raw is None and not event_type.startswith("on_"):
-                raw = event_priorities.get(f"on_{event_type}")
-            try:
-                priority = int(raw) if raw is not None else None
-            except (TypeError, ValueError):
-                priority = None
-            if priority is not None and 1 <= priority <= 5:
-                headers["Priority"] = str(priority)
+        priority = _event_priority_level(config, event_type)
+        if priority is not None:
+            headers["Priority"] = str(priority)
 
         if auth_token:
             headers["Authorization"] = f"Bearer {auth_token}"
@@ -1396,6 +1469,21 @@ class NotificationService:
                     else None
                 )
                 return await self._send_bark(config, title, message, url=bark_url, image_url=photo_url)
+            elif provider.provider_type == "gotify":
+                # Outcome confirmation (#1898): like Bark, Gotify opens one URL
+                # on tap -- deep-link into the confirmation dialog.
+                gotify_url = None
+                _gotify_confirm = (variables or {}).get("confirm_url")
+                if event_type == "print_confirm_request" and _gotify_confirm and _gotify_confirm.startswith("http"):
+                    gotify_url = _gotify_confirm
+                photo_url = (
+                    await self._get_or_build_photo_url(db, image_data, event_type, photo_cache)
+                    if provider.attach_photo
+                    else None
+                )
+                return await self._send_gotify(
+                    config, title, message, event_type=event_type, url=gotify_url, image_url=photo_url
+                )
             else:
                 return False, f"Unknown provider type: {provider.provider_type}"
         except Exception as e:

+ 16 - 0
backend/tests/unit/services/test_notification_photo_attachments.py

@@ -166,6 +166,7 @@ class TestAttachPhotoOptOut:
         ("provider_type", "config", "sender"),
         [
             ("bark", {"device_key": "abc"}, "_send_bark"),
+            ("gotify", {"server": "https://g.example", "app_token": "t"}, "_send_gotify"),
             ("homeassistant", {"service": "notify.mobile_app_x"}, "_send_homeassistant"),
             ("webhook", {"webhook_url": "http://hook.local", "payload_format": "slack"}, "_send_webhook"),
         ],
@@ -241,6 +242,21 @@ class TestAttachPhotoOptOut:
 
         assert mock_send.call_args.kwargs.get("image_url") == "https://bambuddy.example/photo.jpg"
 
+    @pytest.mark.asyncio
+    async def test_gotify_gets_photo_url_when_enabled(self, service):
+        """Gotify takes no uploads, so its photo has to be a URL its app fetches (#2743)."""
+        provider = _provider("gotify", {"server": "https://g.example", "app_token": "t"})
+
+        with (
+            patch.object(service, "_send_gotify", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_get_or_build_photo_url", new_callable=AsyncMock) as mock_build,
+        ):
+            mock_send.return_value = (True, "OK")
+            mock_build.return_value = "https://bambuddy.example/photo.jpg"
+            await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
+
+        assert mock_send.call_args.kwargs.get("image_url") == "https://bambuddy.example/photo.jpg"
+
     @pytest.mark.asyncio
     async def test_generic_webhook_never_builds_a_photo_url(self, service):
         """The generic format carries base64 bytes, so it must not pay for a disk write."""

+ 157 - 0
backend/tests/unit/services/test_notification_service.py

@@ -1365,6 +1365,163 @@ class TestBarkProvider:
         mock_send.assert_called_once()
 
 
+class TestGotifyProvider:
+    """Gotify (self-hosted push) provider (#2743)."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    def _client_returning(self, status_code: int, text: str = ""):
+        mock_response = MagicMock()
+        mock_response.status_code = status_code
+        mock_response.text = text
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+        return mock_client
+
+    async def _send(self, service, mock_client, config, **kwargs):
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+            return await service._send_gotify(config, "Title", "Body", **kwargs)
+
+    @pytest.mark.asyncio
+    async def test_posts_message_with_token_in_header(self, service):
+        """The token goes in X-Gotify-Key, never in the URL, and an event
+        without a mapped level goes out at Gotify's middle priority."""
+        mock_client = self._client_returning(200)
+
+        success, _ = await self._send(
+            service, mock_client, {"server": "https://gotify.example.com/", "app_token": "Atoken"}
+        )
+
+        assert success is True
+        call_args = mock_client.post.call_args
+        assert call_args[0][0] == "https://gotify.example.com/message"
+        assert call_args.kwargs["headers"] == {"X-Gotify-Key": "Atoken"}
+        assert call_args.kwargs["json"] == {"title": "Title", "message": "Body", "priority": 5}
+
+    @pytest.mark.asyncio
+    async def test_a_pasted_endpoint_url_is_not_doubled(self, service):
+        mock_client = self._client_returning(200)
+
+        await self._send(service, mock_client, {"server": "https://g.example/gotify/message/", "app_token": "t"})
+
+        assert mock_client.post.call_args[0][0] == "https://g.example/gotify/message"
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("level", "priority"),
+        [(1, 0), (2, 2), (3, 5), (4, 8), (5, 10)],
+    )
+    async def test_event_level_maps_to_gotify_priority(self, service, level, priority):
+        """The dialog stores the toggle column name; senders get the bare event."""
+        mock_client = self._client_returning(200)
+        config = {"server": "https://g.example", "app_token": "t", "event_priorities": {"on_print_failed": level}}
+
+        await self._send(service, mock_client, config, event_type="print_failed")
+
+        assert mock_client.post.call_args.kwargs["json"]["priority"] == priority
+
+    @pytest.mark.asyncio
+    async def test_invalid_level_falls_back_to_default(self, service):
+        mock_client = self._client_returning(200)
+        config = {"server": "https://g.example", "app_token": "t", "event_priorities": {"print_failed": 9}}
+
+        await self._send(service, mock_client, config, event_type="print_failed")
+
+        assert mock_client.post.call_args.kwargs["json"]["priority"] == 5
+
+    @pytest.mark.asyncio
+    async def test_tap_url_and_photo_go_in_notification_extras(self, service):
+        mock_client = self._client_returning(200)
+
+        await self._send(
+            service,
+            mock_client,
+            {"server": "https://g.example", "app_token": "t"},
+            url="https://bambuddy.example/archives?confirm=1",
+            image_url="https://bambuddy.example/api/v1/notifications/photos/a.jpg",
+        )
+
+        assert mock_client.post.call_args.kwargs["json"]["extras"] == {
+            "client::notification": {
+                "click": {"url": "https://bambuddy.example/archives?confirm=1"},
+                "bigImageUrl": "https://bambuddy.example/api/v1/notifications/photos/a.jpg",
+            }
+        }
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("config", [{"server": "https://g.example"}, {"app_token": "t"}, {}])
+    async def test_missing_server_or_token(self, service, config):
+        mock_client = self._client_returning(200)
+
+        success, message = await self._send(service, mock_client, config)
+
+        assert success is False
+        assert "required" in message
+        mock_client.post.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_http_error_does_not_echo_the_body(self, service):
+        mock_client = self._client_returning(401, text='{"error":"Unauthorized","errorDescription":"secret"}')
+
+        success, message = await self._send(service, mock_client, {"server": "https://g.example", "app_token": "bad"})
+
+        assert success is False
+        assert "HTTP 401" in message
+        assert "secret" not in message
+
+    @pytest.mark.asyncio
+    async def test_send_to_provider_dispatches_gotify(self, service):
+        provider = MagicMock()
+        provider.provider_type = "gotify"
+        provider.config = json.dumps({"server": "https://g.example", "app_token": "t"})
+        provider.quiet_hours_enabled = False
+
+        with patch.object(service, "_send_gotify", new_callable=AsyncMock) as mock_send:
+            mock_send.return_value = (True, "OK")
+            success, _ = await service._send_to_provider(
+                provider, "Title", "Message", db=AsyncMock(), event_type="print_failed"
+            )
+
+        assert success is True
+        assert mock_send.call_args.kwargs["event_type"] == "print_failed"
+        assert mock_send.call_args.kwargs["url"] is None
+
+    @pytest.mark.asyncio
+    async def test_confirm_request_opens_the_confirm_url_on_tap(self, service):
+        provider = MagicMock()
+        provider.provider_type = "gotify"
+        provider.config = json.dumps({"server": "https://g.example", "app_token": "t"})
+        provider.quiet_hours_enabled = False
+        provider.attach_photo = False
+
+        with patch.object(service, "_send_gotify", new_callable=AsyncMock) as mock_send:
+            mock_send.return_value = (True, "OK")
+            await service._send_to_provider(
+                provider,
+                "Title",
+                "Message",
+                db=AsyncMock(),
+                event_type="print_confirm_request",
+                variables={"confirm_url": "https://bambuddy.example/archives?confirm=7"},
+            )
+
+        assert mock_send.call_args.kwargs["url"] == "https://bambuddy.example/archives?confirm=7"
+
+    @pytest.mark.asyncio
+    async def test_test_notification_reaches_gotify(self, service):
+        with patch.object(service, "_send_gotify", new_callable=AsyncMock) as mock_send:
+            mock_send.return_value = (True, "OK")
+            success, _ = await service.send_test_notification(
+                "gotify", {"server": "https://g.example", "app_token": "t"}, attach_photo=False
+            )
+
+        assert success is True
+        assert mock_send.call_args.kwargs["event_type"] == "test"
+
+
 class TestNotificationVariableFallbacks:
     """Tests for notification variable fallback values."""
 

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

@@ -311,7 +311,7 @@ def test_opaque_failure_logs_the_body_for_the_operator(caplog):
 
 @pytest.mark.parametrize(
     "provider_label",
-    ["ntfy server", "Bark server", "webhook endpoint", "Home Assistant endpoint"],
+    ["ntfy server", "Bark server", "Gotify server", "webhook endpoint", "Home Assistant endpoint"],
 )
 def test_user_supplied_host_providers_use_the_opaque_path(provider_label: str):
     """Guards the mapping itself: each user-supplied-host provider must route
@@ -369,6 +369,7 @@ def test_provider_url_guard_permits_self_hosted_servers(url: str):
     [
         ("ntfy", {"server": "http://169.254.169.254", "topic": "t"}),
         ("bark", {"server": "http://169.254.169.254", "device_key": "k"}),
+        ("gotify", {"server": "http://169.254.169.254", "app_token": "t"}),
         ("webhook", {"webhook_url": "http://169.254.169.254/latest/meta-data/"}),
     ],
 )

+ 1 - 1
frontend/scripts/check-i18n-parity.mjs

@@ -125,7 +125,7 @@ function isAlwaysAllowedIdentical(value) {
   if (/^https?:\/\//.test(value)) return true;          // URL
   if (/^ON,\s+true,\s+1$/.test(value)) return true;     // literal example "ON, true, 1"
   // Brand / technical names that ship verbatim everywhere.
-  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Manyfold|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|SHA-256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Pushover|Bark|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
+  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Manyfold|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|SHA-256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Pushover|Bark|Gotify|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
   return false;
 }
 

+ 69 - 0
frontend/src/__tests__/components/AddNotificationModal.test.tsx

@@ -224,6 +224,75 @@ describe('AddNotificationModal — ntfy Priority (#990)', () => {
   });
 });
 
+describe('AddNotificationModal — Gotify (#2743)', () => {
+  const gotifyProvider = () =>
+    buildProvider({
+      name: 'My Gotify',
+      provider_type: 'gotify',
+      config: { server: 'https://gotify.example.com', app_token: 'Atoken' },
+    });
+
+  it('shows the server and token fields, with the token masked', async () => {
+    render(<AddNotificationModal provider={gotifyProvider()} onClose={() => undefined} />);
+
+    expect(await screen.findByDisplayValue('https://gotify.example.com')).toBeInTheDocument();
+    const token = screen.getByDisplayValue('Atoken');
+    expect(token).toHaveAttribute('type', 'password');
+  });
+
+  it('renders its own priority section and saves event_priorities', async () => {
+    let captured: unknown = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = await request.json();
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={gotifyProvider()} onClose={onClose} />);
+
+    const sectionHeader = await screen.findByText(/gotify priority/i);
+    expect(screen.queryByText(/ntfy priority/i)).not.toBeInTheDocument();
+    const sectionRoot = sectionHeader.closest('div')!;
+    const failedRow = within(sectionRoot).getByText('Failed').closest('div')!;
+    await user.selectOptions(within(failedRow).getByRole('combobox'), '4');
+
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    const payload = captured as { config: Record<string, unknown> };
+    expect(payload.config).toMatchObject({
+      server: 'https://gotify.example.com',
+      app_token: 'Atoken',
+      event_priorities: { on_print_failed: 4 },
+    });
+  });
+});
+
+describe('AddNotificationModal — provider type list', () => {
+  it('lists the providers alphabetically by their label', async () => {
+    render(<AddNotificationModal onClose={() => undefined} />);
+
+    const select = (await screen.findByDisplayValue('Email')) as HTMLSelectElement;
+    const labels = Array.from(select.options).map((o) => o.textContent);
+
+    expect(labels).toEqual([
+      'Bark',
+      'CallMeBot/WhatsApp',
+      'Discord',
+      'Email',
+      'Gotify',
+      'Home Assistant',
+      'ntfy',
+      'Pushover',
+      'Telegram',
+      'Webhook',
+    ]);
+  });
+});
+
 describe('AddNotificationModal — plate clear required (#2525)', () => {
   it('renders the toggle off by default', async () => {
     render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);

+ 1 - 1
frontend/src/api/client.ts

@@ -3146,7 +3146,7 @@ export interface Filament {
 }
 
 // Notification Provider types
-export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook' | 'homeassistant' | 'bark';
+export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook' | 'homeassistant' | 'bark' | 'gotify';
 // How a Telegram provider collects the outcome verdict (#3046)
 export type TelegramVerdictMode = 'buttons' | 'reactions' | 'both';
 

+ 28 - 14
frontend/src/components/AddNotificationModal.tsx

@@ -12,10 +12,10 @@ interface AddNotificationModalProps {
   onClose: () => void;
 }
 
-const PROVIDER_VALUES: ProviderType[] = ['email', 'telegram', 'discord', 'ntfy', 'pushover', 'bark', 'callmebot', 'webhook', 'homeassistant'];
+const PROVIDER_VALUES: ProviderType[] = ['email', 'telegram', 'discord', 'ntfy', 'pushover', 'bark', 'gotify', 'callmebot', 'webhook', 'homeassistant'];
 
 export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {
-  const { t } = useTranslation();
+  const { t, i18n } = useTranslation();
   const queryClient = useQueryClient();
   const isEditing = !!provider;
 
@@ -73,8 +73,8 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       : {},
   );
 
-  // Per-event ntfy priority (#990). Map of event key → 1-5. Persisted into
-  // config.event_priorities on save; only sent when the provider is ntfy.
+  // Per-event priority for ntfy (#990) and Gotify (#2743). Map of event key →
+  // 1-5. Persisted into config.event_priorities on save for those two only.
   const initialEventPriorities = (() => {
     const raw = provider?.config?.event_priorities;
     if (!raw || typeof raw !== 'object') return {} as Record<string, number>;
@@ -184,7 +184,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
     }
 
     const finalConfig: Record<string, unknown> =
-      providerType === 'ntfy' && Object.keys(eventPriorities).length > 0
+      (providerType === 'ntfy' || providerType === 'gotify') && Object.keys(eventPriorities).length > 0
         ? { ...config, event_priorities: eventPriorities }
         : config;
 
@@ -345,6 +345,11 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
             { value: 'passive', label: 'Passive (no sound)' },
           ]},
         ];
+      case 'gotify':
+        return [
+          { key: 'server', label: 'Server URL', placeholder: 'https://gotify.example.com', type: 'text', required: true },
+          { key: 'app_token', label: 'App Token', placeholder: 'Token of a Gotify application', type: 'password', required: true },
+        ];
       default:
         return [];
     }
@@ -411,11 +416,14 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               disabled={isEditing}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50"
             >
-              {PROVIDER_VALUES.map((value) => (
-                <option key={value} value={value}>
-                  {t(`notifications.providerTypes.${value}`, value)}
-                </option>
-              ))}
+              {/* Sorted by the label shown, so the order holds in every language */}
+              {PROVIDER_VALUES.map((value) => ({ value, label: t(`notifications.providerTypes.${value}`, value) }))
+                .sort((a, b) => a.label.localeCompare(b.label, i18n.language, { sensitivity: 'base' }))
+                .map(({ value, label }) => (
+                  <option key={value} value={value}>
+                    {label}
+                  </option>
+                ))}
             </select>
             <p className="text-xs text-bambu-gray mt-1">
               {t(`notifications.providerDescriptions.${providerType}`, '')}
@@ -773,8 +781,8 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               </div>
             </div>
 
-            {/* Per-event ntfy priority (#990) */}
-            {providerType === 'ntfy' && (() => {
+            {/* Per-event priority: ntfy (#990), Gotify (#2743) */}
+            {(providerType === 'ntfy' || providerType === 'gotify') && (() => {
               const enabledEvents: Array<{ key: string; label: string }> = [];
               if (onPrintStart) enabledEvents.push({ key: 'on_print_start', label: t('notifications.start') });
               if (onPrintComplete) enabledEvents.push({ key: 'on_print_complete', label: t('notifications.complete') });
@@ -801,9 +809,15 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               return (
                 <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
                   <p className="text-xs text-bambu-gray uppercase tracking-wide mb-1">
-                    {t('notifications.eventPriority.sectionTitle')}
+                    {providerType === 'gotify'
+                      ? t('notifications.eventPriority.sectionTitleGotify')
+                      : t('notifications.eventPriority.sectionTitle')}
+                  </p>
+                  <p className="text-xs text-bambu-gray mb-2">
+                    {providerType === 'gotify'
+                      ? t('notifications.eventPriority.helpGotify')
+                      : t('notifications.eventPriority.helpNtfy')}
                   </p>
-                  <p className="text-xs text-bambu-gray mb-2">{t('notifications.eventPriority.helpNtfy')}</p>
                   <div className="space-y-2">
                     {enabledEvents.map((ev) => (
                       <div key={ev.key} className="flex items-center justify-between gap-3">

+ 4 - 0
frontend/src/i18n/locales/de.ts

@@ -6568,6 +6568,7 @@ export default {
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
       bark: 'Bark',
+      gotify: 'Gotify',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -6580,6 +6581,7 @@ export default {
       webhook: 'Generischer HTTP-POST an beliebige URL',
       homeassistant: 'Dauerhafte Benachrichtigungen im Home Assistant Dashboard',
       bark: 'iOS-Push-Benachrichtigungen über Bark (selbst hostbar)',
+      gotify: 'Push-Benachrichtigungen über deinen eigenen Gotify-Server',
     },
     // NotificationProviderCard
     lastSuccess: 'Zuletzt: {{date}}',
@@ -6723,6 +6725,8 @@ export default {
     eventPriority: {
       sectionTitle: 'ntfy-Priorität',
       helpNtfy: 'Wähle eine Priorität pro aktiviertem Ereignis. ntfy nutzt diese, um Hinweise (Ton, Sichtbarkeit, Push-Verhalten) zu eskalieren. Hier nicht gesetzte Stufen verwenden den ntfy-Server-Standard.',
+      sectionTitleGotify: 'Gotify-Priorität',
+      helpGotify: 'Wähle eine Priorität pro aktiviertem Ereignis. Bambuddy sendet Min., Niedrig, Standard, Hoch und Dringend als 0, 2, 5, 8 und 10 an Gotify. In der Gotify-Android-App spielen Standard und höher einen Ton ab, Hoch und Dringend erscheinen zusätzlich als Pop-up.',
       min: 'Min.',
       low: 'Niedrig',
       default: 'Standard',

+ 4 - 0
frontend/src/i18n/locales/en.ts

@@ -6621,6 +6621,7 @@ export default {
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
       bark: 'Bark',
+      gotify: 'Gotify',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -6633,6 +6634,7 @@ export default {
       webhook: 'Generic HTTP POST to any URL',
       homeassistant: 'Persistent notifications in Home Assistant dashboard',
       bark: 'iOS push notifications via Bark (self-hostable)',
+      gotify: 'Push notifications via your own Gotify server',
     },
     // NotificationProviderCard
     lastSuccess: 'Last: {{date}}',
@@ -6776,6 +6778,8 @@ export default {
     eventPriority: {
       sectionTitle: 'ntfy Priority',
       helpNtfy: 'Pick a priority for each enabled event. ntfy uses these to escalate alerts (sound, visibility, push behavior). Levels not set here use the ntfy server default.',
+      sectionTitleGotify: 'Gotify Priority',
+      helpGotify: 'Pick a priority for each enabled event. Bambuddy sends Min, Low, Default, High and Urgent to Gotify as 0, 2, 5, 8 and 10. In the Gotify Android app, Default and above play a sound, and High and Urgent pop up.',
       min: 'Min',
       low: 'Low',
       default: 'Default',

+ 4 - 0
frontend/src/i18n/locales/es.ts

@@ -6576,6 +6576,7 @@ export default {
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
       bark: 'Bark',
+      gotify: 'Gotify',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -6588,6 +6589,7 @@ export default {
       webhook: 'POST HTTP genérico a cualquier URL',
       homeassistant: 'Notificaciones persistentes en el panel de Home Assistant',
       bark: 'Notificaciones push de iOS mediante Bark (autoalojable)',
+      gotify: 'Notificaciones push a través de tu propio servidor Gotify',
     },
     // NotificationProviderCard
     lastSuccess: 'Última: {{date}}',
@@ -6731,6 +6733,8 @@ export default {
     eventPriority: {
       sectionTitle: 'Prioridad de ntfy',
       helpNtfy: 'Elija una prioridad para cada evento activado. ntfy las usa para escalar las alertas (sonido, visibilidad, comportamiento de las notificaciones push). Los niveles no establecidos aquí usan el valor predeterminado del servidor ntfy.',
+      sectionTitleGotify: 'Prioridad de Gotify',
+      helpGotify: 'Elija una prioridad para cada evento activado. Bambuddy envía Mínima, Baja, Predeterminada, Alta y Urgente a Gotify como 0, 2, 5, 8 y 10. En la app de Gotify para Android, Predeterminada y superiores emiten un sonido, y Alta y Urgente aparecen como ventana emergente.',
       min: 'Mínima',
       low: 'Baja',
       default: 'Predeterminada',

+ 4 - 0
frontend/src/i18n/locales/fr.ts

@@ -6556,6 +6556,7 @@ export default {
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
       bark: 'Bark',
+      gotify: 'Gotify',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -6568,6 +6569,7 @@ export default {
       webhook: 'POST HTTP générique vers n\'importe quelle URL',
       homeassistant: 'Notifications persistantes dans le tableau de bord Home Assistant',
       bark: 'Notifications push iOS via Bark (auto-hébergeable)',
+      gotify: 'Notifications push via votre propre serveur Gotify',
     },
     // NotificationProviderCard
     lastSuccess: 'Dernier : {{date}}',
@@ -6711,6 +6713,8 @@ export default {
     eventPriority: {
       sectionTitle: 'Priorité ntfy',
       helpNtfy: 'Choisissez une priorité pour chaque événement activé. ntfy l\'utilise pour faire remonter les alertes (son, visibilité, comportement push). Les niveaux non définis ici utilisent la valeur par défaut du serveur ntfy.',
+      sectionTitleGotify: 'Priorité Gotify',
+      helpGotify: 'Choisissez une priorité pour chaque événement activé. Bambuddy envoie Min., Basse, Par défaut, Haute et Urgente à Gotify sous la forme 0, 2, 5, 8 et 10. Dans l\'application Android de Gotify, Par défaut et au-dessus émettent un son, Haute et Urgente s\'affichent en surimpression.',
       min: 'Min.',
       low: 'Basse',
       default: 'Par défaut',

+ 4 - 0
frontend/src/i18n/locales/it.ts

@@ -6555,6 +6555,7 @@ export default {
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
       bark: 'Bark',
+      gotify: 'Gotify',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -6567,6 +6568,7 @@ export default {
       webhook: 'POST HTTP generico verso qualsiasi URL',
       homeassistant: 'Notifiche persistenti nella dashboard di Home Assistant',
       bark: 'Notifiche push iOS tramite Bark (auto-ospitabile)',
+      gotify: 'Notifiche push tramite il tuo server Gotify',
     },
     // NotificationProviderCard
     lastSuccess: 'Ultimo: {{date}}',
@@ -6710,6 +6712,8 @@ export default {
     eventPriority: {
       sectionTitle: 'Priorità ntfy',
       helpNtfy: 'Scegli una priorità per ogni evento abilitato. ntfy le usa per intensificare gli avvisi (suono, visibilità, comportamento push). I livelli non impostati qui usano l\'impostazione predefinita del server ntfy.',
+      sectionTitleGotify: 'Priorità Gotify',
+      helpGotify: 'Scegli una priorità per ogni evento abilitato. Bambuddy invia Min., Bassa, Predefinita, Alta e Urgente a Gotify come 0, 2, 5, 8 e 10. Nell\'app Android di Gotify, Predefinita e superiori emettono un suono, Alta e Urgente compaiono anche come pop-up.',
       min: 'Min.',
       low: 'Bassa',
       default: 'Predefinita',

+ 4 - 0
frontend/src/i18n/locales/ja.ts

@@ -6569,6 +6569,7 @@ export default {
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
       bark: 'Bark',
+      gotify: 'Gotify',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -6581,6 +6582,7 @@ export default {
       webhook: '任意のURLへの汎用HTTP POST',
       homeassistant: 'Home Assistantダッシュボードの永続通知',
       bark: 'Bark経由のiOSプッシュ通知(セルフホスト可能)',
+      gotify: '自分の Gotify サーバー経由のプッシュ通知',
     },
     // NotificationProviderCard
     lastSuccess: '最終: {{date}}',
@@ -6724,6 +6726,8 @@ export default {
     eventPriority: {
       sectionTitle: 'ntfy 優先度',
       helpNtfy: '有効な各イベントに優先度を選択してください。ntfy はこれを使って通知の挙動(音、表示、プッシュ動作)を切り替えます。ここで設定していないレベルは ntfy サーバーのデフォルトを使用します。',
+      sectionTitleGotify: 'Gotify 優先度',
+      helpGotify: '有効な各イベントに優先度を選択してください。Bambuddy は最小・低・デフォルト・高・緊急を Gotify に 0・2・5・8・10 として送信します。Gotify の Android アプリでは、デフォルト以上で音が鳴り、高と緊急はポップアップ表示されます。',
       min: '最小',
       low: '低',
       default: 'デフォルト',

+ 6 - 2
frontend/src/i18n/locales/ko.ts

@@ -6289,7 +6289,8 @@ export default {
       discord: 'Discord',
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
-      bark: 'Bark'
+      bark: 'Bark',
+      gotify: 'Gotify'
     },
     providerDescriptions: {
       email: 'SMTP 이메일 알림',
@@ -6300,7 +6301,8 @@ export default {
       callmebot: 'CallMeBot을 통한 무료 WhatsApp 알림',
       webhook: '모든 URL에 일반 HTTP POST',
       homeassistant: 'Home Assistant 대시보드의 지속적인 알림',
-      bark: 'Bark를 통한 iOS 푸시 알림(셀프 호스팅 가능)'
+      bark: 'Bark를 통한 iOS 푸시 알림(셀프 호스팅 가능)',
+      gotify: '직접 운영하는 Gotify 서버를 통한 푸시 알림'
     },
     lastSuccess: '마지막: {{date}}',
     error: '오류',
@@ -6433,6 +6435,8 @@ export default {
     eventPriority: {
       sectionTitle: 'ntfy 우선순위',
       helpNtfy: '각 활성화된 이벤트에 대한 우선순위를 선택하세요. ntfy는 이를 사용하여 알림을 에스컬레이션합니다(소리, 가시성, 푸시 동작). 여기서 설정되지 않은 수준은 ntfy 서버 기본값을 사용합니다.',
+      sectionTitleGotify: 'Gotify 우선순위',
+      helpGotify: '각 활성화된 이벤트에 대한 우선순위를 선택하세요. Bambuddy는 최소, 낮음, 기본값, 높음, 긴급을 Gotify에 0, 2, 5, 8, 10으로 보냅니다. Gotify Android 앱에서는 기본값 이상이면 소리가 나고, 높음과 긴급은 팝업으로 표시됩니다.',
       min: '최소',
       low: '낮음',
       default: '기본값',

+ 4 - 0
frontend/src/i18n/locales/nl.ts

@@ -6619,6 +6619,7 @@ export default {
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
       bark: 'Bark',
+      gotify: 'Gotify',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -6631,6 +6632,7 @@ export default {
       webhook: 'Algemene HTTP POST naar elke URL',
       homeassistant: 'Blijvende meldingen in het Home Assistant-dashboard',
       bark: 'iOS-pushmeldingen via Bark (zelf te hosten)',
+      gotify: 'Pushmeldingen via je eigen Gotify-server',
     },
     // NotificationProviderCard
     lastSuccess: 'Laatste: {{date}}',
@@ -6774,6 +6776,8 @@ export default {
     eventPriority: {
       sectionTitle: 'ntfy-prioriteit',
       helpNtfy: 'Kies een prioriteit voor elke ingeschakelde gebeurtenis. ntfy gebruikt deze om waarschuwingen op te schalen (geluid, zichtbaarheid, pushgedrag). Niveaus die hier niet zijn ingesteld gebruiken de standaardwaarde van de ntfy-server.',
+      sectionTitleGotify: 'Gotify-prioriteit',
+      helpGotify: 'Kies een prioriteit voor elke ingeschakelde gebeurtenis. Bambuddy stuurt Min, Laag, Standaard, Hoog en Urgent naar Gotify als 0, 2, 5, 8 en 10. In de Gotify-app voor Android geven Standaard en hoger een geluid, en verschijnen Hoog en Urgent als pop-up.',
       min: 'Min',
       low: 'Laag',
       default: 'Standaard',

+ 4 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -6555,6 +6555,7 @@ export default {
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
       bark: 'Bark',
+      gotify: 'Gotify',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -6567,6 +6568,7 @@ export default {
       webhook: 'POST HTTP genérico para qualquer URL',
       homeassistant: 'Notificações persistentes no painel do Home Assistant',
       bark: 'Notificações push do iOS via Bark (auto-hospedável)',
+      gotify: 'Notificações push pelo seu próprio servidor Gotify',
     },
     // NotificationProviderCard
     lastSuccess: 'Último: {{date}}',
@@ -6710,6 +6712,8 @@ export default {
     eventPriority: {
       sectionTitle: 'Prioridade ntfy',
       helpNtfy: 'Escolha uma prioridade para cada evento habilitado. O ntfy usa isso para escalar alertas (som, visibilidade, comportamento push). Níveis não definidos aqui usam o padrão do servidor ntfy.',
+      sectionTitleGotify: 'Prioridade Gotify',
+      helpGotify: 'Escolha uma prioridade para cada evento habilitado. O Bambuddy envia Mín, Baixa, Padrão, Alta e Urgente ao Gotify como 0, 2, 5, 8 e 10. No app Android do Gotify, Padrão e acima tocam um som, e Alta e Urgente aparecem como pop-up.',
       min: 'Mín',
       low: 'Baixa',
       default: 'Padrão',

+ 4 - 0
frontend/src/i18n/locales/ru.ts

@@ -6277,6 +6277,7 @@ export default {
       webhook: "Вебхук",
       homeassistant: "Home Assistant",
       bark: "Bark",
+      gotify: "Gotify",
     },
     providerDescriptions: {
       email: "Уведомления по электронной почте через SMTP",
@@ -6288,6 +6289,7 @@ export default {
       webhook: "Универсальный HTTP POST-запрос на любой URL",
       homeassistant: "Постоянные уведомления на панели Home Assistant",
       bark: "iOS push-уведомления через Bark (можно разместить у себя)",
+      gotify: "Push-уведомления через ваш собственный сервер Gotify",
     },
     lastSuccess: "Последняя отправка: {{date}}",
     error: "Ошибка",
@@ -6420,6 +6422,8 @@ export default {
     eventPriority: {
       sectionTitle: "Приоритет ntfy",
       helpNtfy: "Выберите приоритет для каждого включённого события. ntfy использует его для усиления оповещений: звука, видимости и поведения push-уведомлений. Для неуказанных событий используется приоритет по умолчанию сервера ntfy.",
+      sectionTitleGotify: "Приоритет Gotify",
+      helpGotify: "Выберите приоритет для каждого включённого события. Bambuddy отправляет в Gotify уровни «Минимальный», «Низкий», «Обычный», «Высокий» и «Срочный» как 0, 2, 5, 8 и 10. В Android-приложении Gotify уровни от «Обычный» и выше воспроизводят звук, а «Высокий» и «Срочный» показываются во всплывающем окне.",
       min: "Минимальный",
       low: "Низкий",
       default: "Обычный",

+ 4 - 0
frontend/src/i18n/locales/sv.ts

@@ -6617,6 +6617,7 @@ errors: {
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
       bark: 'Bark',
+      gotify: 'Gotify',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -6629,6 +6630,7 @@ errors: {
       webhook: 'Generisk HTTP POST till valfri URL',
       homeassistant: 'Permanenta notiser i Home Assistant-instrumentpanel',
       bark: 'iOS push-notiser via Bark (självhostbar)',
+      gotify: 'Push-notiser via din egen Gotify-server',
     },
     // NotificationProviderCard
     lastSuccess: 'Senast: {{date}}',
@@ -6772,6 +6774,8 @@ errors: {
     eventPriority: {
       sectionTitle: 'ntfy-prioritet',
       helpNtfy: 'Välj en prioritet för varje aktiverad händelse. ntfy använder dessa för att eskalera larm (ljud, synlighet, push-beteende). Nivåer som inte sätts här använder ntfy-serverns standard.',
+      sectionTitleGotify: 'Gotify-prioritet',
+      helpGotify: 'Välj en prioritet för varje aktiverad händelse. Bambuddy skickar Min, Låg, Standard, Hög och Brådskande till Gotify som 0, 2, 5, 8 och 10. I Gotifys Android-app spelar Standard och högre upp ett ljud, och Hög och Brådskande visas som popup.',
       min: 'Min',
       low: 'Låg',
       default: 'Standard',

+ 4 - 0
frontend/src/i18n/locales/tr.ts

@@ -6527,6 +6527,7 @@ export default {
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
       bark: 'Bark',
+      gotify: 'Gotify',
     },
     providerDescriptions: {
       email: 'SMTP e-posta bildirimleri',
@@ -6538,6 +6539,7 @@ export default {
       webhook: 'Herhangi bir URL\'ye genel HTTP POST',
       homeassistant: 'Home Assistant gösterge panelinde kalıcı bildirimler',
       bark: 'Bark ile iOS anlık bildirimleri (kendi sunucunuzda barındırılabilir)',
+      gotify: 'Kendi Gotify sunucunuz üzerinden anlık bildirimler',
     },
     lastSuccess: 'Son: {{date}}',
     error: 'Hata',
@@ -6670,6 +6672,8 @@ export default {
     eventPriority: {
       sectionTitle: 'ntfy Önceliği',
       helpNtfy: 'Her etkin olay için bir öncelik seçin. ntfy uyarıları kademelendirmek için bunları kullanır (ses, görünürlük, push davranışı). Burada ayarlanmamış seviyeler ntfy sunucu varsayılanını kullanır.',
+      sectionTitleGotify: 'Gotify Önceliği',
+      helpGotify: 'Her etkin olay için bir öncelik seçin. Bambuddy Min, Düşük, Varsayılan, Yüksek ve Acil seviyelerini Gotify\'a 0, 2, 5, 8 ve 10 olarak gönderir. Gotify Android uygulamasında Varsayılan ve üzeri ses çalar, Yüksek ve Acil açılır pencere olarak görünür.',
       min: 'Min',
       low: 'Düşük',
       default: 'Varsayılan',

+ 4 - 0
frontend/src/i18n/locales/uk.ts

@@ -6609,6 +6609,7 @@ export default {
       webhook: "Webhook",
       homeassistant: "Home Assistant",
       bark: "Bark",
+      gotify: "Gotify",
     },
     // Provider descriptions
     providerDescriptions: {
@@ -6621,6 +6622,7 @@ export default {
       webhook: "Універсальний HTTP POST-запит на будь-який URL",
       homeassistant: "Постійні сповіщення на інформаційній панелі Home Assistant.",
       bark: "Push-сповіщення iOS через Bark із можливістю самостійного розгортання",
+      gotify: "Push-сповіщення через ваш власний сервер Gotify",
     },
     // NotificationProviderCard
     lastSuccess: "Останнє успішне надсилання: {{date}}",
@@ -6764,6 +6766,8 @@ export default {
     eventPriority: {
       sectionTitle: "Пріоритет ntfy",
       helpNtfy: "Виберіть пріоритет для кожної ввімкненої події. ntfy використовує їх для ескалації сповіщень (звук, видимість, поведінка push). Для рівнів, які тут не встановлено, використовується сервер ntfy за замовчуванням.",
+      sectionTitleGotify: "Пріоритет Gotify",
+      helpGotify: "Виберіть пріоритет для кожної ввімкненої події. Bambuddy надсилає до Gotify рівні «Мін», «Низький», «За замовчуванням», «Високий» і «Терміново» як 0, 2, 5, 8 і 10. У застосунку Gotify для Android рівні від «За замовчуванням» і вище відтворюють звук, а «Високий» і «Терміново» з’являються у спливному вікні.",
       min: "Мін",
       low: "Низький",
       default: "За замовчуванням",

+ 4 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -6554,6 +6554,7 @@ export default {
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
       bark: 'Bark',
+      gotify: 'Gotify',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -6566,6 +6567,7 @@ export default {
       webhook: '通用 HTTP POST 到任意 URL',
       homeassistant: 'Home Assistant 仪表板中的持久通知',
       bark: '通过 Bark 推送 iOS 通知(可自建服务器)',
+      gotify: '通过你自己的 Gotify 服务器推送通知',
     },
     // NotificationProviderCard
     lastSuccess: '上次:{{date}}',
@@ -6709,6 +6711,8 @@ export default {
     eventPriority: {
       sectionTitle: 'ntfy 优先级',
       helpNtfy: '为每个已启用的事件选择优先级。ntfy 使用它来升级提醒(声音、可见性、推送行为)。此处未设置的级别将使用 ntfy 服务器默认值。',
+      sectionTitleGotify: 'Gotify 优先级',
+      helpGotify: '为每个已启用的事件选择优先级。Bambuddy 将最低、低、默认、高和紧急分别以 0、2、5、8、10 发送给 Gotify。在 Gotify Android 应用中,默认及以上会播放提示音,高和紧急会以弹出通知显示。',
       min: '最低',
       low: '低',
       default: '默认',

+ 4 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -6554,6 +6554,7 @@ export default {
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
       bark: 'Bark',
+      gotify: 'Gotify',
     },
     // Provider descriptions
     providerDescriptions: {
@@ -6566,6 +6567,7 @@ export default {
       webhook: '通用 HTTP POST 到任意 URL',
       homeassistant: 'Home Assistant 儀表板中的持久通知',
       bark: '透過 Bark 推送 iOS 通知(可自架伺服器)',
+      gotify: '透過你自己的 Gotify 伺服器推播通知',
     },
     // NotificationProviderCard
     lastSuccess: '上次:{{date}}',
@@ -6709,6 +6711,8 @@ export default {
     eventPriority: {
       sectionTitle: 'ntfy 優先級',
       helpNtfy: '為每個已啟用的事件選擇優先級。ntfy 使用它來升級提醒(聲音、可見性、推播行為)。此處未設定的級別將使用 ntfy 伺服器預設值。',
+      sectionTitleGotify: 'Gotify 優先級',
+      helpGotify: '為每個已啟用的事件選擇優先級。Bambuddy 會將最低、低、預設、高和緊急分別以 0、2、5、8、10 傳送給 Gotify。在 Gotify Android 應用程式中,預設及以上會播放提示音,高和緊急會以彈出通知顯示。',
       min: '最低',
       low: '低',
       default: '預設',

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-R11uy3ZP.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-ChNGaUl4.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-HCix387J.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/index-CdoB5JwO.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-rlMIkeOl.js


+ 1 - 1
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-Da4YLBSD.js"></script>
+    <script type="module" crossorigin src="/assets/index-CdoB5JwO.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-Bb4TsUSQ.css">
   </head>

Some files were not shown because too many files changed in this diff