Browse Source

feat(notifications): per-event ntfy priority headers (#990)

  ntfy supports a Priority header (1=min, 2=low, 3=default, 4=high, 5=urgent)
  that controls escalation on the receiving device, but every event was being
  sent at the server default — so a "50% complete" ping looked identical to
  "print failed" or "printer offline". Add a per-event priority dropdown
  section in the Add/Edit Notification modal (visible only for ntfy, listing
  only enabled events); the backend reads config.event_priorities and emits
  the matching Priority header on POST and PUT (image-attachment) paths.
  Unmapped events fall through to the ntfy server default. Out-of-range
  and non-numeric values are dropped, not clamped, so a misconfigured value
  never silently sends at the wrong urgency. Test sends omit the header by
  design so the test path can't accidentally page someone at urgent priority.

  Backward compatible: existing providers without event_priorities behave
  exactly as before. NtfyConfig.event_priorities is optional; the route
  stores config as a JSON blob so no migration is needed.

  i18n: full translations across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/
  zh-TW). README, CHANGELOG, and the wiki notifications page updated.

  Tests: 6 backend (Priority set on mapped, omitted on unmapped/missing/
  no-priorities, ignored for bad values, propagated through attachment
  path), 6 frontend (section visible only for ntfy, lists only enabled
  events, save round-trip, edit pre-fill, toggle drops row, non-ntfy
  never writes the key).
maziggy 1 month ago
parent
commit
35edc036bd

File diff suppressed because it is too large
+ 2 - 0
CHANGELOG.md


+ 1 - 1
README.md

@@ -193,7 +193,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 
 ### 🔔 Notifications
 - WhatsApp, Telegram, Discord
-- Email, Pushover, ntfy
+- Email, Pushover, ntfy (with per-event priority — Min / Low / Default / High / Urgent)
 - Home Assistant persistent notifications
 - Custom webhooks
 - Quiet hours & daily digest

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

@@ -212,6 +212,14 @@ class NtfyConfig(BaseModel):
     server: str = Field(default="https://ntfy.sh", description="ntfy server URL")
     topic: str = Field(..., description="Topic name to publish to")
     auth_token: str | None = Field(default=None, description="Optional authentication token")
+    event_priorities: dict[str, int] | None = Field(
+        default=None,
+        description=(
+            "Per-event priority override. Keys are event names (e.g. 'on_print_failed'); "
+            "values are ntfy priorities 1-5 (1=min, 2=low, 3=default, 4=high, 5=urgent). "
+            "Events without an entry use ntfy's server-side default."
+        ),
+    )
 
 
 class PushoverConfig(BaseModel):

+ 20 - 2
backend/app/services/notification_service.py

@@ -206,7 +206,12 @@ class NotificationService:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
     async def _send_ntfy(
-        self, config: dict, title: str, message: str, image_data: bytes | None = None
+        self,
+        config: dict,
+        title: str,
+        message: str,
+        image_data: bytes | None = None,
+        event_type: str | None = None,
     ) -> tuple[bool, str]:
         """Send notification via ntfy."""
         server = config.get("server", "https://ntfy.sh").rstrip("/")
@@ -223,6 +228,19 @@ class NotificationService:
         # bypasses the ASCII check — ntfy handles UTF-8 headers correctly.
         headers: dict[str, str | bytes] = {"Title": title.encode("utf-8")}
 
+        # Per-event Priority header (#990). Only set when the user has
+        # explicitly mapped this event to a 1-5 value; otherwise fall through
+        # to the ntfy server's default so existing setups stay unchanged.
+        event_priorities = config.get("event_priorities") or {}
+        if event_type and isinstance(event_priorities, dict):
+            raw = event_priorities.get(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)
+
         if auth_token:
             headers["Authorization"] = f"Bearer {auth_token}"
 
@@ -603,7 +621,7 @@ class NotificationService:
             if provider.provider_type == "callmebot":
                 return await self._send_callmebot(config, f"{title}\n{message}")
             elif provider.provider_type == "ntfy":
-                return await self._send_ntfy(config, title, message, image_data=image_data)
+                return await self._send_ntfy(config, title, message, image_data=image_data, event_type=event_type)
             elif provider.provider_type == "pushover":
                 return await self._send_pushover(config, title, message, image_data=image_data)
             elif provider.provider_type == "telegram":

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

@@ -670,6 +670,119 @@ class TestNotificationProviderTypes:
             assert "image" not in payload
 
 
+class TestNtfyPriority:
+    """Per-event ntfy Priority header (#990)."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @staticmethod
+    def _mock_client(service):
+        """Patch _get_client and return the mock client + 200 response."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+        mock_client.put = AsyncMock(return_value=mock_response)
+        return mock_client
+
+    @pytest.mark.asyncio
+    async def test_priority_header_set_for_mapped_event(self, service):
+        """Mapped event → ntfy Priority header carries the configured value."""
+        config = {
+            "topic": "bambuddy",
+            "event_priorities": {"on_print_failed": 5, "on_print_complete": 2},
+        }
+        mock_client = self._mock_client(service)
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
+            mock_get.return_value = mock_client
+            success, _ = await service._send_ntfy(config, "Title", "Body", event_type="on_print_failed")
+
+        assert success is True
+        headers = mock_client.post.call_args.kwargs["headers"]
+        assert headers.get("Priority") == "5"
+
+    @pytest.mark.asyncio
+    async def test_priority_header_omitted_for_unmapped_event(self, service):
+        """Unmapped event → no Priority header so ntfy uses its server default."""
+        config = {
+            "topic": "bambuddy",
+            "event_priorities": {"on_print_failed": 5},
+        }
+        mock_client = self._mock_client(service)
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
+            mock_get.return_value = mock_client
+            await service._send_ntfy(config, "Title", "Body", event_type="on_print_complete")
+
+        headers = mock_client.post.call_args.kwargs["headers"]
+        assert "Priority" not in headers
+
+    @pytest.mark.asyncio
+    async def test_priority_header_omitted_when_no_priorities_set(self, service):
+        """Existing setups (no event_priorities key) keep current behaviour."""
+        config = {"topic": "bambuddy"}
+        mock_client = self._mock_client(service)
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
+            mock_get.return_value = mock_client
+            await service._send_ntfy(config, "Title", "Body", event_type="on_print_failed")
+
+        headers = mock_client.post.call_args.kwargs["headers"]
+        assert "Priority" not in headers
+
+    @pytest.mark.asyncio
+    async def test_priority_header_omitted_when_event_type_missing(self, service):
+        """Test sends (no event_type) must not emit a Priority header."""
+        config = {
+            "topic": "bambuddy",
+            "event_priorities": {"on_print_failed": 5},
+        }
+        mock_client = self._mock_client(service)
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
+            mock_get.return_value = mock_client
+            await service._send_ntfy(config, "Title", "Body")
+
+        headers = mock_client.post.call_args.kwargs["headers"]
+        assert "Priority" not in headers
+
+    @pytest.mark.asyncio
+    async def test_priority_out_of_range_is_ignored(self, service):
+        """Values outside 1-5 (or non-numeric) are dropped, not clamped."""
+        for bad in (0, 6, 99, -1, "not-a-number", None):
+            config = {
+                "topic": "bambuddy",
+                "event_priorities": {"on_print_failed": bad},
+            }
+            mock_client = self._mock_client(service)
+            with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
+                mock_get.return_value = mock_client
+                await service._send_ntfy(config, "Title", "Body", event_type="on_print_failed")
+
+            headers = mock_client.post.call_args.kwargs["headers"]
+            assert "Priority" not in headers, f"unexpected header for bad value {bad!r}"
+
+    @pytest.mark.asyncio
+    async def test_priority_header_set_on_attachment_path(self, service):
+        """Image-attachment path (PUT) must also carry the Priority header."""
+        config = {
+            "topic": "bambuddy",
+            "event_priorities": {"on_first_layer_complete": 4},
+        }
+        mock_client = self._mock_client(service)
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
+            mock_get.return_value = mock_client
+            await service._send_ntfy(
+                config,
+                "Title",
+                "Body",
+                image_data=b"\xff\xd8\xff\xe0fake-jpeg",
+                event_type="on_first_layer_complete",
+            )
+
+        headers = mock_client.put.call_args.kwargs["headers"]
+        assert headers.get("Priority") == "4"
+
+
 class TestHomeAssistantProvider:
     """Tests for Home Assistant notification provider."""
 

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

@@ -0,0 +1,221 @@
+/**
+ * Frontend tests for the AddNotificationModal — focused on the per-event
+ * ntfy Priority section (#990).
+ *
+ * Coverage:
+ * - Priority section renders only for ntfy provider type.
+ * - Section lists ONLY events the user has enabled, not the whole catalogue.
+ * - Save round-trips event_priorities into config.
+ * - Editing an existing ntfy provider pre-fills priorities from config.
+ * - Switching off a toggle drops the matching row from the priority section.
+ * - For non-ntfy providers, event_priorities never appears in the saved config.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { AddNotificationModal } from '../../components/AddNotificationModal';
+import type { NotificationProvider } from '../../api/client';
+
+afterEach(() => {
+  server.resetHandlers();
+  vi.restoreAllMocks();
+});
+
+function buildProvider(overrides: Partial<NotificationProvider> = {}): NotificationProvider {
+  return {
+    id: 1,
+    name: 'My ntfy',
+    provider_type: 'ntfy',
+    enabled: true,
+    config: { server: 'https://ntfy.sh', topic: 'bambuddy' },
+    on_print_start: false,
+    on_print_complete: true,
+    on_print_failed: true,
+    on_print_stopped: true,
+    on_print_progress: false,
+    on_print_missing_spool_assignment: false,
+    on_printer_offline: false,
+    on_printer_error: false,
+    on_filament_low: false,
+    on_maintenance_due: false,
+    on_ams_humidity_high: false,
+    on_ams_temperature_high: false,
+    on_ams_ht_humidity_high: false,
+    on_ams_ht_temperature_high: false,
+    on_plate_not_empty: true,
+    on_bed_cooled: false,
+    on_first_layer_complete: false,
+    on_queue_job_added: false,
+    on_queue_job_assigned: false,
+    on_queue_job_started: false,
+    on_queue_job_waiting: true,
+    on_queue_job_skipped: true,
+    on_queue_job_failed: true,
+    on_queue_completed: false,
+    quiet_hours_enabled: false,
+    quiet_hours_start: null,
+    quiet_hours_end: null,
+    daily_digest_enabled: false,
+    daily_digest_time: null,
+    printer_id: null,
+    last_success: null,
+    last_error: null,
+    last_error_at: null,
+    created_at: '2026-04-25T00:00:00Z',
+    updated_at: '2026-04-25T00:00:00Z',
+    ...overrides,
+  };
+}
+
+describe('AddNotificationModal — ntfy Priority (#990)', () => {
+  it('renders the ntfy Priority section listing only enabled events', async () => {
+    render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
+
+    // Section header present, then scope every label query to it — the same
+    // labels also appear in the toggle grid above.
+    const sectionHeader = await screen.findByText(/ntfy priority/i);
+    const sectionRoot = sectionHeader.closest('div')!;
+
+    // Defaults from buildProvider(): complete + failed + stopped enabled;
+    // start + progress + offline disabled. The priority list mirrors that.
+    expect(within(sectionRoot).getByText('Complete')).toBeInTheDocument();
+    expect(within(sectionRoot).getByText('Failed')).toBeInTheDocument();
+    expect(within(sectionRoot).getByText('Stopped')).toBeInTheDocument();
+
+    // Disabled events must not appear in the priority block.
+    expect(within(sectionRoot).queryByText('Start')).not.toBeInTheDocument();
+    expect(within(sectionRoot).queryByText('Progress')).not.toBeInTheDocument();
+    expect(within(sectionRoot).queryByText('Offline')).not.toBeInTheDocument();
+  });
+
+  it('does not render the Priority section for non-ntfy providers', async () => {
+    render(
+      <AddNotificationModal
+        provider={buildProvider({ provider_type: 'telegram', config: { bot_token: 'x', chat_id: 'y' } })}
+        onClose={() => undefined}
+      />,
+    );
+
+    // Wait for the modal to settle.
+    await screen.findByDisplayValue('My ntfy');
+
+    expect(screen.queryByText(/ntfy priority/i)).not.toBeInTheDocument();
+  });
+
+  it('persists event_priorities into config on save', 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={buildProvider()} onClose={onClose} />);
+
+    // Pick "Urgent" (5) for the on_print_failed row.
+    const sectionHeader = await screen.findByText(/ntfy priority/i);
+    const sectionRoot = sectionHeader.closest('div')!;
+    const failedRow = within(sectionRoot).getByText('Failed').closest('div')!;
+    const select = within(failedRow).getByRole('combobox');
+    await user.selectOptions(select, '5');
+
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).not.toBeNull();
+    const payload = captured as { config: Record<string, unknown> };
+    expect(payload.config).toMatchObject({
+      server: 'https://ntfy.sh',
+      topic: 'bambuddy',
+      event_priorities: { on_print_failed: 5 },
+    });
+  });
+
+  it('pre-fills priorities from existing provider.config.event_priorities', async () => {
+    const provider = buildProvider({
+      config: {
+        server: 'https://ntfy.sh',
+        topic: 'bambuddy',
+        event_priorities: { on_print_failed: 5, on_print_complete: 2 },
+      },
+    });
+
+    render(<AddNotificationModal provider={provider} onClose={() => undefined} />);
+
+    const sectionHeader = await screen.findByText(/ntfy priority/i);
+    const sectionRoot = sectionHeader.closest('div')!;
+
+    const failedRow = within(sectionRoot).getByText('Failed').closest('div')!;
+    expect((within(failedRow).getByRole('combobox') as HTMLSelectElement).value).toBe('5');
+
+    const completeRow = within(sectionRoot).getByText('Complete').closest('div')!;
+    expect((within(completeRow).getByRole('combobox') as HTMLSelectElement).value).toBe('2');
+
+    // Stopped is enabled but has no override → defaults to 3.
+    const stoppedRow = within(sectionRoot).getByText('Stopped').closest('div')!;
+    expect((within(stoppedRow).getByRole('combobox') as HTMLSelectElement).value).toBe('3');
+  });
+
+  it('drops events from the priority section when their toggle is disabled', async () => {
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
+
+    const sectionHeader = await screen.findByText(/ntfy priority/i);
+    const sectionRoot = sectionHeader.closest('div')!;
+
+    // Stopped is initially enabled → row visible.
+    expect(within(sectionRoot).getByText('Stopped')).toBeInTheDocument();
+
+    // Find the Stopped toggle in the events grid (a separate area). Its label
+    // appears in the priority section AND the toggle grid; we need the toggle
+    // one. The toggle is a sibling of the label inside an event-row div.
+    const allStoppedNodes = screen.getAllByText('Stopped');
+    // The first occurrence is in the Print Events grid; the second is in the
+    // Priority section. Click the toggle next to the first one.
+    const togglesGridStopped = allStoppedNodes[0];
+    const toggleRow = togglesGridStopped.closest('div')!;
+    const toggle = within(toggleRow).getByRole('switch');
+    await user.click(toggle);
+
+    // Row drops out of the priority section.
+    await waitFor(() => {
+      const stillSection = screen.getByText(/ntfy priority/i).closest('div')!;
+      expect(within(stillSection).queryByText('Stopped')).not.toBeInTheDocument();
+    });
+  });
+
+  it('omits event_priorities for non-ntfy providers on save', async () => {
+    let captured: unknown = null;
+    server.use(
+      http.post('*/api/v1/notifications/', async ({ request }) => {
+        captured = await request.json();
+        return HttpResponse.json({ id: 99 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal onClose={onClose} />);
+
+    // Default new-provider type is email. Fill required fields and save.
+    await user.type(screen.getByPlaceholderText(/My Notifications/i), 'Test');
+    await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'smtp.example.com');
+    const fromInputs = screen.getAllByPlaceholderText('your@email.com');
+    await user.type(fromInputs[fromInputs.length - 1], 'me@example.com');
+    await user.type(screen.getByPlaceholderText('recipient@email.com'), 'them@example.com');
+
+    await user.click(screen.getByRole('button', { name: /^add$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    const payload = captured as { provider_type: string; config: Record<string, unknown> };
+    expect(payload.provider_type).toBe('email');
+    expect(payload.config).not.toHaveProperty('event_priorities');
+  });
+});

+ 77 - 3
frontend/src/components/AddNotificationModal.tsx

@@ -43,11 +43,32 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
   const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
 
-  // Provider-specific config
+  // Provider-specific config (scalar fields only — event_priorities is split out
+  // into its own state because it's an object, not a string).
   const [config, setConfig] = useState<Record<string, string>>(
-    provider?.config ? Object.fromEntries(Object.entries(provider.config).map(([k, v]) => [k, String(v)])) : {}
+    provider?.config
+      ? Object.fromEntries(
+          Object.entries(provider.config)
+            .filter(([k]) => k !== 'event_priorities')
+            .map(([k, v]) => [k, String(v)]),
+        )
+      : {},
   );
 
+  // 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.
+  const initialEventPriorities = (() => {
+    const raw = provider?.config?.event_priorities;
+    if (!raw || typeof raw !== 'object') return {} as Record<string, number>;
+    const out: Record<string, number> = {};
+    for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
+      const n = Number(v);
+      if (Number.isInteger(n) && n >= 1 && n <= 5) out[k] = n;
+    }
+    return out;
+  })();
+  const [eventPriorities, setEventPriorities] = useState<Record<string, number>>(initialEventPriorities);
+
   const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
   const [error, setError] = useState<string | null>(null);
 
@@ -120,10 +141,15 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       }
     }
 
+    const finalConfig: Record<string, unknown> =
+      providerType === 'ntfy' && Object.keys(eventPriorities).length > 0
+        ? { ...config, event_priorities: eventPriorities }
+        : config;
+
     const data = {
       name: name.trim(),
       provider_type: providerType,
-      config,
+      config: finalConfig,
       printer_id: printerId,
       quiet_hours_enabled: quietHoursEnabled,
       quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
@@ -527,6 +553,54 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                 </div>
               </div>
             </div>
+
+            {/* Per-event ntfy priority (#990) */}
+            {providerType === 'ntfy' && (() => {
+              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') });
+              if (onPrintFailed) enabledEvents.push({ key: 'on_print_failed', label: t('notifications.failed') });
+              if (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') });
+              if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') });
+              if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
+              if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
+              if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
+              if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
+              if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
+              if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });
+
+              if (enabledEvents.length === 0) return null;
+
+              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')}
+                  </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">
+                        <span className="text-sm text-white">{ev.label}</span>
+                        <select
+                          value={eventPriorities[ev.key] ?? 3}
+                          onChange={(e) => {
+                            const next = Number(e.target.value);
+                            setEventPriorities((prev) => ({ ...prev, [ev.key]: next }));
+                          }}
+                          className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-sm text-white focus:border-bambu-green focus:outline-none"
+                        >
+                          <option value={1}>{t('notifications.eventPriority.min')}</option>
+                          <option value={2}>{t('notifications.eventPriority.low')}</option>
+                          <option value={3}>{t('notifications.eventPriority.default')}</option>
+                          <option value={4}>{t('notifications.eventPriority.high')}</option>
+                          <option value={5}>{t('notifications.eventPriority.urgent')}</option>
+                        </select>
+                      </div>
+                    ))}
+                  </div>
+                </div>
+              );
+            })()}
           </div>
 
           {/* Actions */}

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

@@ -4355,6 +4355,16 @@ export default {
     notificationEvents: 'Benachrichtigungsereignisse',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(nach Druckabschluss)',
+    // Per-event ntfy priority (#990)
+    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.',
+      min: 'Min',
+      low: 'Niedrig',
+      default: 'Standard',
+      high: 'Hoch',
+      urgent: 'Dringend',
+    },
     cancel: 'Abbrechen',
     save: 'Speichern',
     add: 'Hinzufügen',

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

@@ -4363,6 +4363,16 @@ export default {
     notificationEvents: 'Notification Events',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(after print completes)',
+    // Per-event ntfy priority (#990)
+    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.',
+      min: 'Min',
+      low: 'Low',
+      default: 'Default',
+      high: 'High',
+      urgent: 'Urgent',
+    },
     cancel: 'Cancel',
     save: 'Save',
     add: 'Add',

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

@@ -4282,6 +4282,16 @@ export default {
     notificationEvents: 'Événements de notification',
     progressPercent: '(25 %, 50 %, 75 %)',
     bedCooledAfterPrint: '(après la fin de l\'impression)',
+    // Per-event ntfy priority (#990)
+    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.',
+      min: 'Min',
+      low: 'Basse',
+      default: 'Par défaut',
+      high: 'Haute',
+      urgent: 'Urgente',
+    },
     cancel: 'Annuler',
     save: 'Enregistrer',
     add: 'Ajouter',

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

@@ -4281,6 +4281,16 @@ export default {
     notificationEvents: 'Eventi di notifica',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(dopo il completamento della stampa)',
+    // Per-event ntfy priority (#990)
+    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.',
+      min: 'Min',
+      low: 'Bassa',
+      default: 'Predefinita',
+      high: 'Alta',
+      urgent: 'Urgente',
+    },
     cancel: 'Annulla',
     save: 'Salva',
     add: 'Aggiungi',

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

@@ -4320,6 +4320,16 @@ export default {
     notificationEvents: '通知イベント',
     progressPercent: '(25%、50%、75%)',
     bedCooledAfterPrint: '(印刷完了後)',
+    // Per-event ntfy priority (#990)
+    eventPriority: {
+      sectionTitle: 'ntfy 優先度',
+      helpNtfy: '有効な各イベントに優先度を選択してください。ntfy はこれを使って通知の挙動(音、表示、プッシュ動作)を切り替えます。ここで設定していないレベルは ntfy サーバーのデフォルトを使用します。',
+      min: '最小',
+      low: '低',
+      default: 'デフォルト',
+      high: '高',
+      urgent: '緊急',
+    },
     cancel: 'キャンセル',
     save: '保存',
     add: '追加',

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

@@ -4295,6 +4295,16 @@ export default {
     notificationEvents: 'Eventos de Notificação',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(após conclusão da impressão)',
+    // Per-event ntfy priority (#990)
+    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.',
+      min: 'Mín',
+      low: 'Baixa',
+      default: 'Padrão',
+      high: 'Alta',
+      urgent: 'Urgente',
+    },
     cancel: 'Cancelar',
     save: 'Salvar',
     add: 'Adicionar',

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

@@ -4347,6 +4347,16 @@ export default {
     notificationEvents: '通知事件',
     progressPercent: '(25%、50%、75%)',
     bedCooledAfterPrint: '(打印完成后)',
+    // Per-event ntfy priority (#990)
+    eventPriority: {
+      sectionTitle: 'ntfy 优先级',
+      helpNtfy: '为每个已启用的事件选择优先级。ntfy 使用它来升级提醒(声音、可见性、推送行为)。此处未设置的级别将使用 ntfy 服务器默认值。',
+      min: '最低',
+      low: '低',
+      default: '默认',
+      high: '高',
+      urgent: '紧急',
+    },
     cancel: '取消',
     save: '保存',
     add: '添加',

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

@@ -4347,6 +4347,16 @@ export default {
     notificationEvents: '通知事件',
     progressPercent: '(25%、50%、75%)',
     bedCooledAfterPrint: '(列印完成後)',
+    // Per-event ntfy priority (#990)
+    eventPriority: {
+      sectionTitle: 'ntfy 優先級',
+      helpNtfy: '為每個已啟用的事件選擇優先級。ntfy 使用它來升級提醒(聲音、可見性、推播行為)。此處未設定的級別將使用 ntfy 伺服器預設值。',
+      min: '最低',
+      low: '低',
+      default: '預設',
+      high: '高',
+      urgent: '緊急',
+    },
     cancel: '取消',
     save: '儲存',
     add: '新增',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index--ZvtiDKM.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-Jv9LQKHt.js"></script>
+    <script type="module" crossorigin src="/assets/index--ZvtiDKM.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-telVPl_h.css">
   </head>
   <body>

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