From 37388c430c261709c1db47363c194b255557a97c Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 23 Mar 2026 19:08:48 +0300 Subject: [PATCH] feat: locale-aware notification templates + UX improvements - Add locale support to notification templates (matching command template pattern): TemplateSlot now has locale field with (config_id, slot_name, locale) uniqueness, nested API format {slot: {locale: template}} - Migration merges separate EN/RU system configs into unified per-provider configs; seeds create one config per provider with multi-locale slots - Locale-aware dispatch with EN fallback in NotificationDispatcher - Frontend locale tabs (EN/RU) on template config editor - Fix tracking config cards not showing default provider icons - Global provider filter, search palette, and various UX polish --- docs/haos-integration-architecture.md | 155 ------------------ .../src/lib/components/EntitySelect.svelte | 2 +- .../src/lib/components/SearchPalette.svelte | 14 +- frontend/src/lib/grid-items.ts | 23 ++- frontend/src/lib/i18n/en.json | 5 + frontend/src/lib/i18n/ru.json | 5 + .../src/lib/stores/provider-filter.svelte.ts | 57 +++++++ frontend/src/lib/types.ts | 2 +- frontend/src/routes/+layout.svelte | 134 ++++++++++++++- frontend/src/routes/+page.svelte | 12 +- frontend/src/routes/actions/+page.svelte | 16 +- frontend/src/routes/actions/RuleEditor.svelte | 1 + .../src/routes/bots/TelegramBotTab.svelte | 83 ++++++++-- .../src/routes/command-configs/+page.svelte | 6 +- .../command-template-configs/+page.svelte | 6 +- .../src/routes/command-trackers/+page.svelte | 9 +- .../routes/notification-trackers/+page.svelte | 9 +- frontend/src/routes/providers/+page.svelte | 8 +- .../src/routes/template-configs/+page.svelte | 47 +++++- .../src/routes/tracking-configs/+page.svelte | 10 +- .../notifications/dispatcher.py | 11 +- .../api/notification_tracker_targets.py | 10 ++ .../src/notify_bridge_server/api/providers.py | 18 +- .../notify_bridge_server/api/telegram_bots.py | 31 ++++ .../api/template_configs.py | 56 ++++--- .../database/migrations.py | 99 +++++++++++ .../notify_bridge_server/database/models.py | 6 +- .../notify_bridge_server/database/seeds.py | 96 ++++------- .../server/src/notify_bridge_server/main.py | 3 +- .../services/dispatch_helpers.py | 12 +- 30 files changed, 628 insertions(+), 318 deletions(-) delete mode 100644 docs/haos-integration-architecture.md create mode 100644 frontend/src/lib/stores/provider-filter.svelte.ts diff --git a/docs/haos-integration-architecture.md b/docs/haos-integration-architecture.md deleted file mode 100644 index 9943cbf..0000000 --- a/docs/haos-integration-architecture.md +++ /dev/null @@ -1,155 +0,0 @@ -# HAOS Integration Architecture - -## Overview - -The existing Home Assistant (HAOS) Immich Album Watcher integration will connect to -Notify Bridge as a client, receiving events and state updates via the Bridge's API. - -## Communication Options - -### Option A: Polling (Recommended for Phase 1) - -HAOS integration polls the Bridge API at regular intervals for new events. - -``` -GET /api/events/stream?tracker_ids=1,2,3&since=2026-03-19T00:00:00Z -Authorization: Bearer -``` - -**Pros:** Simple, stateless, works through firewalls, NAT-friendly. -**Cons:** Latency up to poll interval, extra API calls. - -### Option B: WebSocket (Future Enhancement) - -HAOS maintains a persistent WebSocket connection to the Bridge. - -``` -WS /api/events/ws?tracker_ids=1,2,3 -Authorization: Bearer -``` - -**Pros:** Real-time, efficient for frequent events. -**Cons:** Connection management, reconnection logic needed. - -### Recommendation - -Start with **Polling** (Option A) for simplicity and reliability. Add WebSocket -support later as an optional real-time upgrade. - -## HAOS Integration Simplification - -### Features that become OBSOLETE (handled by Bridge): - -- Direct Immich API calls (Bridge handles provider communication) -- Album change detection (Bridge detects changes via provider abstraction) -- Telegram sending logic (Bridge dispatches notifications) -- Template rendering (Bridge renders Jinja2 templates) -- Notification queue / quiet hours (Bridge manages scheduling) - -### Features that REMAIN in HAOS: - -- **HA entities**: sensors, binary sensors, cameras, buttons -- **HA events**: fired from Bridge events for automations -- **Config flow**: now connects to Bridge URL instead of Immich directly -- **DataUpdateCoordinator**: polls Bridge API instead of Immich API -- **Share link management**: may stay as direct pass-through for responsiveness - -### New HAOS Integration Flow - -``` -1. User configures Bridge URL + credentials in HAOS config flow -2. Integration authenticates with Bridge API (JWT) -3. Integration discovers available trackers from Bridge -4. DataUpdateCoordinator polls /api/events/stream for new events -5. On event: fires HA event, updates sensor entities -6. HA automations react to events as before -``` - -## Impact on Current HAOS Entities - -### Sensors (per tracked collection) - -| Current | New Source | Notes | -|---------|-----------|-------| -| Album ID | Bridge tracker state | Same data, different source | -| Asset Count | Bridge tracker state | Polled from Bridge | -| Photo/Video Count | Bridge tracker state | | -| Last Updated | Bridge event timestamp | | -| Public/Protected URL | Bridge provider (pass-through) | May need direct Immich call | - -### Binary Sensor - -- "New Assets" indicator: triggered by Bridge `assets_added` event - -### Camera - -- Thumbnail: still needs direct Immich API call (binary data) -- Option: Bridge could expose a thumbnail proxy endpoint - -### Buttons - -- Create/Delete share links: pass through to Bridge provider API -- Bridge would need `/api/providers/{id}/actions` endpoint - -## API Contract - -### Event Stream (Polling) - -```http -GET /api/events/stream?tracker_ids=1,2&since=2026-03-19T00:00:00Z -Authorization: Bearer - -Response 200: -{ - "events": [ - { - "id": 42, - "tracker_id": 1, - "event_type": "assets_added", - "provider_type": "immich", - "collection_id": "abc-123", - "collection_name": "Vacation 2026", - "timestamp": "2026-03-19T14:30:00Z", - "details": { - "added_count": 3, - "removed_count": 0 - } - } - ], - "last_event_id": 42 -} -``` - -### Tracker State - -```http -GET /api/trackers/{id}/state -Authorization: Bearer - -Response 200: -{ - "tracker_id": 1, - "provider_type": "immich", - "collections": [ - { - "id": "abc-123", - "name": "Vacation 2026", - "asset_count": 150, - "last_updated": "2026-03-19T14:30:00Z" - } - ] -} -``` - -## Migration Path - -1. **Phase 1**: Bridge runs alongside existing HAOS integration (no changes to HAOS) -2. **Phase 2**: New HAOS integration version connects to Bridge for events -3. **Phase 3**: HAOS integration drops direct Immich dependency, becomes pure Bridge client -4. **Phase 4**: Old HAOS integration code removed - -## Open Questions - -- Should Bridge expose asset thumbnails as a proxy (to avoid HAOS needing direct Immich access)? -- Should share link management go through Bridge or stay as direct Immich calls? -- How to handle HAOS integration discovery of Bridge instances (mDNS, manual config)? diff --git a/frontend/src/lib/components/EntitySelect.svelte b/frontend/src/lib/components/EntitySelect.svelte index dc9a3fe..9344a59 100644 --- a/frontend/src/lib/components/EntitySelect.svelte +++ b/frontend/src/lib/components/EntitySelect.svelte @@ -180,7 +180,7 @@ align-items: center; gap: 0.5rem; width: 100%; - padding: 0.5rem 0.75rem; + padding: 0.375rem 0.75rem; border: 1px solid var(--color-border); border-radius: 0.375rem; font-size: 0.875rem; diff --git a/frontend/src/lib/components/SearchPalette.svelte b/frontend/src/lib/components/SearchPalette.svelte index ac83534..40ba56f 100644 --- a/frontend/src/lib/components/SearchPalette.svelte +++ b/frontend/src/lib/components/SearchPalette.svelte @@ -3,6 +3,8 @@ import { t } from '$lib/i18n'; import MdiIcon from './MdiIcon.svelte'; import { requestHighlight } from '$lib/highlight'; + import { providerDefaultIcon } from '$lib/grid-items'; + import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { fetchAllCaches, providersCache, @@ -44,7 +46,7 @@ const GROUPS: readonly { key: string; label: string; icon: string; href: string; mapFn: (e: CacheEntity) => { detail: string; icon: string } }[] = [ { key: 'providers', label: 'nav.providers', icon: 'mdiServer', href: '/providers', - mapFn: (e) => ({ detail: String(e.type || ''), icon: String(e.icon || 'mdiServer') }) }, + mapFn: (e) => ({ detail: String(e.type || ''), icon: providerDefaultIcon(e as any) }) }, { key: 'notification_trackers', label: 'nav.notification', icon: 'mdiRadar', href: '/notification-trackers', mapFn: (e) => ({ detail: e.enabled ? 'enabled' : 'disabled', icon: String(e.icon || 'mdiRadar') }) }, { key: 'tracking_configs', label: 'nav.trackingConfigs', icon: 'mdiCog', href: '/tracking-configs', @@ -87,10 +89,20 @@ const terms = query.toLowerCase().split(/\s+/).filter(Boolean); const all: SearchResult[] = []; + const gpid = globalProviderFilter.id; + const gpt = globalProviderFilter.providerType; + const providerScoped = new Set(['notification_trackers', 'command_trackers', 'actions']); + const typeScoped = new Set(['tracking_configs', 'template_configs', 'command_configs', 'command_template_configs']); + for (const group of GROUPS) { const cache = cacheMap[group.key]; if (!cache) continue; for (const entity of cache.items) { + // Apply global provider filter + if (gpid && group.key === 'providers' && entity.id !== gpid) continue; + if (gpid && providerScoped.has(group.key) && (entity as any).provider_id !== gpid) continue; + if (gpt && typeScoped.has(group.key) && (entity as any).provider_type !== gpt) continue; + const mapped = group.mapFn(entity); const name = entity.name || ''; const searchable = `${name} ${mapped.detail} ${t(group.label)}`.toLowerCase(); diff --git a/frontend/src/lib/grid-items.ts b/frontend/src/lib/grid-items.ts index 9685646..8c31042 100644 --- a/frontend/src/lib/grid-items.ts +++ b/frontend/src/lib/grid-items.ts @@ -6,6 +6,21 @@ import { t } from '$lib/i18n'; import type { GridItem } from '$lib/components/IconGridSelect.svelte'; +/** Default icon for each provider type. Use instead of hardcoded 'mdiServer'. */ +const PROVIDER_TYPE_ICONS: Record = { + immich: 'mdiImageMultiple', + gitea: 'mdiGit', + planka: 'mdiViewDashboard', + scheduler: 'mdiClockOutline', +}; + +/** Get the default icon for a provider, falling back by type then generic. */ +export function providerDefaultIcon(provider: { icon?: string; type?: string }): string { + if (provider.icon) return provider.icon; + if (provider.type && PROVIDER_TYPE_ICONS[provider.type]) return PROVIDER_TYPE_ICONS[provider.type]; + return 'mdiServer'; +} + // --- Sort --- export const sortByItems = (): GridItem[] => [ @@ -108,8 +123,8 @@ export const providerTypeFilterItems = (): GridItem[] => [ // --- Provider type --- export const providerTypeItems = (): GridItem[] => [ - { value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich'), desc: t('gridDesc.providerImmich') }, - { value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') }, - { value: 'planka', icon: 'mdiViewDashboard', label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') }, - { value: 'scheduler', icon: 'mdiClockOutline', label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') }, + { value: 'immich', icon: PROVIDER_TYPE_ICONS.immich, label: t('providers.typeImmich'), desc: t('gridDesc.providerImmich') }, + { value: 'gitea', icon: PROVIDER_TYPE_ICONS.gitea, label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') }, + { value: 'planka', icon: PROVIDER_TYPE_ICONS.planka, label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') }, + { value: 'scheduler', icon: PROVIDER_TYPE_ICONS.scheduler, label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') }, ]; diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 1ab2f12..bb155b8 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -335,6 +335,11 @@ "clickToCopy": "Click to copy chat ID", "chatsDiscovered": "Chats discovered", "chatDeleted": "Chat removed", + "chatName": "Name", + "chatType": "Type", + "chatLang": "Lang", + "chatId": "Chat ID", + "languageUpdated": "Chat language updated", "cmdLocale": "Bot language", "searchCooldown": "Search cooldown (s)", "saveConfig": "Save config", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 8370dea..a187763 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -335,6 +335,11 @@ "clickToCopy": "Нажмите, чтобы скопировать ID чата", "chatsDiscovered": "Чаты обнаружены", "chatDeleted": "Чат удалён", + "chatName": "Имя", + "chatType": "Тип", + "chatLang": "Язык", + "chatId": "ID чата", + "languageUpdated": "Язык чата обновлён", "cmdLocale": "Язык бота", "searchCooldown": "Кулдаун поиска (с)", "saveConfig": "Сохранить настройки", diff --git a/frontend/src/lib/stores/provider-filter.svelte.ts b/frontend/src/lib/stores/provider-filter.svelte.ts new file mode 100644 index 0000000..bbc8ded --- /dev/null +++ b/frontend/src/lib/stores/provider-filter.svelte.ts @@ -0,0 +1,57 @@ +/** + * Global provider filter — persisted to localStorage. + * + * When set, pages should filter entities to show only those + * belonging to the selected provider. null = show all. + */ + +import { providersCache } from './caches.svelte'; + +const STORAGE_KEY = 'global_provider_id'; + +let _providerId = $state(null); +let _initialized = $state(false); + +function loadFromStorage(): void { + if (typeof localStorage === 'undefined') return; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = parseInt(stored, 10); + _providerId = isNaN(parsed) ? null : parsed; + } + _initialized = true; +} + +// Load on module init +loadFromStorage(); + +export const globalProviderFilter = { + get id() { return _providerId; }, + get initialized() { return _initialized; }, + + set(id: number | null) { + _providerId = id; + if (typeof localStorage !== 'undefined') { + if (id != null) { + localStorage.setItem(STORAGE_KEY, String(id)); + } else { + localStorage.removeItem(STORAGE_KEY); + } + } + }, + + clear() { + this.set(null); + }, + + /** The currently selected provider object (reactive). */ + get provider() { + if (_providerId == null) return null; + return providersCache.items.find(p => p.id === _providerId) ?? null; + }, + + /** The provider type string, or null. */ + get providerType() { + return this.provider?.type ?? null; + }, +}; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0349a43..37294c2 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -156,7 +156,7 @@ export interface TemplateConfig { name: string; description: string; icon: string; - slots: Record; + slots: Record>; date_format: string; date_only_format: string; created_at: string; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 3d76219..6a1a890 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -14,11 +14,34 @@ import Snackbar from '$lib/components/Snackbar.svelte'; import SearchPalette from '$lib/components/SearchPalette.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; + import { + providersCache, notificationTrackersCache, trackingConfigsCache, + templateConfigsCache, commandConfigsCache, commandTemplateConfigsCache, + commandTrackersCache, actionsCache, telegramBotsCache, emailBotsCache, + matrixBotsCache, targetsCache, + } from '$lib/stores/caches.svelte'; + import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; + import { providerDefaultIcon } from '$lib/grid-items'; + import IconGridSelect from '$lib/components/IconGridSelect.svelte'; let { children } = $props(); const auth = getAuth(); const theme = getTheme(); + let allProviders = $derived(providersCache.items); + + let providerFilterItems = $derived([ + { value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' }, + ...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })), + ]); + let providerFilterValue = $state(globalProviderFilter.id ?? 0); + + // Sync filter value → store + $effect(() => { + const v = providerFilterValue; + globalProviderFilter.set(v === 0 ? null : v); + }); + let showPasswordForm = $state(false); let redirecting = $state(false); let openSearch: (() => void) | undefined; @@ -43,8 +66,38 @@ let collapsed = $state(false); let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent)); - // Nav counts for badges - let navCounts = $state>({}); + // Nav counts — computed reactively from caches + global provider filter + let navCounts = $derived.by(() => { + const pid = globalProviderFilter.id; + const ptype = globalProviderFilter.providerType; + + const filterById = (items: T[]) => + pid ? items.filter(i => i.provider_id === pid) : items; + const filterByType = (items: T[]) => + ptype ? items.filter(i => i.provider_type === ptype) : items; + + const targets = targetsCache.items; + return { + providers: pid ? 1 : providersCache.items.length, + notification_trackers: filterById(notificationTrackersCache.items as any[]).length, + tracking_configs: filterByType(trackingConfigsCache.items as any[]).length, + template_configs: filterByType(templateConfigsCache.items as any[]).length, + command_trackers: filterById(commandTrackersCache.items as any[]).length, + command_configs: filterByType(commandConfigsCache.items as any[]).length, + command_template_configs: filterByType(commandTemplateConfigsCache.items as any[]).length, + actions: filterById(actionsCache.items as any[]).length, + telegram_bots: telegramBotsCache.items.length, + email_bots: emailBotsCache.items.length, + matrix_bots: matrixBotsCache.items.length, + targets_telegram: targets.filter(t => t.type === 'telegram').length, + targets_webhook: targets.filter(t => t.type === 'webhook').length, + targets_email: targets.filter(t => t.type === 'email').length, + targets_discord: targets.filter(t => t.type === 'discord').length, + targets_slack: targets.filter(t => t.type === 'slack').length, + targets_ntfy: targets.filter(t => t.type === 'ntfy').length, + targets_matrix: targets.filter(t => t.type === 'matrix').length, + } as Record; + }); interface NavItem { href: string; @@ -170,9 +223,22 @@ redirecting = true; goto('/login'); } - // Load nav counts + // Load all caches for nav counts + global provider filter if (auth.user) { - try { navCounts = await api('/status/counts'); } catch (e) { console.warn('Failed to load nav counts:', e); } + Promise.all([ + providersCache.fetch(), + notificationTrackersCache.fetch(), + trackingConfigsCache.fetch(), + templateConfigsCache.fetch(), + commandTrackersCache.fetch(), + commandConfigsCache.fetch(), + commandTemplateConfigsCache.fetch(), + actionsCache.fetch(), + telegramBotsCache.fetch(), + emailBotsCache.fetch(), + matrixBotsCache.fetch(), + targetsCache.fetch(), + ]).catch(e => console.warn('Failed to load caches for nav counts:', e)); } }); @@ -260,11 +326,16 @@
{#if !collapsed}
-

- Notify Bridge +

+ {#if globalProviderFilter.provider} + + {/if} + Notify Bridge

{t('app.tagline')}

+ {:else if globalProviderFilter.provider} + {/if}
+ + {#if allProviders.length > 1} +
+ {#if collapsed} + + {:else} + + {/if} +
+ {/if} +