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
This commit is contained in:
2026-03-23 19:08:48 +03:00
parent 6a559bfcd2
commit 37388c430c
30 changed files with 628 additions and 318 deletions
-155
View File
@@ -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 <jwt>
```
**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 <jwt>
```
**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 <jwt>
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 <jwt>
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)?
@@ -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;
@@ -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();
+19 -4
View File
@@ -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<string, string> = {
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') },
];
+5
View File
@@ -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",
+5
View File
@@ -335,6 +335,11 @@
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
"chatsDiscovered": "Чаты обнаружены",
"chatDeleted": "Чат удалён",
"chatName": "Имя",
"chatType": "Тип",
"chatLang": "Язык",
"chatId": "ID чата",
"languageUpdated": "Язык чата обновлён",
"cmdLocale": "Язык бота",
"searchCooldown": "Кулдаун поиска (с)",
"saveConfig": "Сохранить настройки",
@@ -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<number | null>(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;
},
};
+1 -1
View File
@@ -156,7 +156,7 @@ export interface TemplateConfig {
name: string;
description: string;
icon: string;
slots: Record<string, string>;
slots: Record<string, Record<string, string>>;
date_format: string;
date_only_format: string;
created_at: string;
+128 -6
View File
@@ -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<Record<string, number>>({});
// Nav counts — computed reactively from caches + global provider filter
let navCounts = $derived.by(() => {
const pid = globalProviderFilter.id;
const ptype = globalProviderFilter.providerType;
const filterById = <T extends { provider_id?: number }>(items: T[]) =>
pid ? items.filter(i => i.provider_id === pid) : items;
const filterByType = <T extends { provider_type?: string }>(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<string, number>;
});
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 @@
<div class="flex items-center {collapsed ? 'justify-center p-2.5' : 'justify-between px-5 py-4'}" style="border-bottom: 1px solid var(--color-border);">
{#if !collapsed}
<div class="animate-fade-slide-in">
<h1 class="text-base font-semibold tracking-tight" style="color: var(--color-foreground);">
<span style="color: var(--color-primary);">Notify</span> Bridge
<h1 class="text-base font-semibold tracking-tight flex items-center gap-1.5" style="color: var(--color-foreground);">
{#if globalProviderFilter.provider}
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(globalProviderFilter.provider)} size={18} /></span>
{/if}
<span><span style="color: var(--color-primary);">Notify</span> Bridge</span>
</h1>
<p class="text-[0.7rem] text-[var(--color-muted-foreground)] mt-0.5 tracking-wide uppercase">{t('app.tagline')}</p>
</div>
{:else if globalProviderFilter.provider}
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(globalProviderFilter.provider)} size={18} /></span>
{/if}
<button onclick={toggleSidebar}
class="sidebar-icon-btn flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200"
@@ -286,6 +357,25 @@
</button>
</div>
<!-- Global provider filter -->
{#if allProviders.length > 1}
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
{#if collapsed}
<button onclick={() => {
const ids = [0, ...allProviders.map(p => p.id)];
const idx = ids.indexOf(providerFilterValue);
providerFilterValue = ids[(idx + 1) % ids.length];
}}
class="provider-filter-btn flex items-center justify-center w-full py-1.5 rounded-lg text-sm transition-all duration-200"
title={globalProviderFilter.provider?.name || t('common.allProviders')}>
<MdiIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
</button>
{:else}
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 3)} compact />
{/if}
</div>
{/if}
<!-- Nav -->
<nav class="flex-1 p-2 space-y-0.5 overflow-y-auto">
{#each navEntries as entry}
@@ -467,6 +557,38 @@
.mobile-nav { display: flex !important; }
}
/* Provider filter chips */
.provider-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.4rem;
border-radius: 0.375rem;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.provider-chip:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.provider-chip.active {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
color: var(--color-primary);
}
.provider-filter-btn {
color: var(--color-muted-foreground);
background: transparent;
}
.provider-filter-btn:hover {
color: var(--color-primary);
background: var(--color-muted);
}
/* Sidebar icon button (toggle, logout) */
.sidebar-icon-btn {
color: var(--color-muted-foreground);
+7 -5
View File
@@ -10,14 +10,15 @@
import EventChart from '$lib/components/EventChart.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { eventTypeFilterItems, sortFilterItems } from '$lib/grid-items';
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import type { DashboardStatus } from '$lib/types';
let status = $state<DashboardStatus | null>(null);
let providers = $derived(providersCache.items);
const providerFilterItems = $derived([
{ value: '', label: t('dashboard.allProviders'), icon: 'mdiFilterOff' },
...providers.map(p => ({ value: p.id, label: p.name, icon: p.icon || 'mdiServer', desc: p.type })),
...providers.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })),
]);
let chartDays = $state<{ date: string; [eventType: string]: string | number }[]>([]);
let loaded = $state(false);
@@ -31,6 +32,7 @@
// Event filters
let filterEventType = $state('');
let filterProviderId = $state('');
let effectiveProviderId = $derived(globalProviderFilter.id ? String(globalProviderFilter.id) : filterProviderId);
let filterSearch = $state('');
let filterSort = $state('newest');
let eventsLimit = $state(calcPageSize());
@@ -66,7 +68,7 @@
function buildFilterParams(): URLSearchParams {
const params = new URLSearchParams();
if (filterEventType) params.set('event_type', filterEventType);
if (filterProviderId) params.set('provider_id', filterProviderId);
if (effectiveProviderId) params.set('provider_id', effectiveProviderId);
if (filterSearch) params.set('search', filterSearch);
return params;
}
@@ -99,7 +101,7 @@
// Auto-apply when filter values change (via IconGridSelect bind:value)
let _prevFilterKey = '';
$effect(() => {
const key = `${filterEventType}|${filterProviderId}|${filterSort}`;
const key = `${filterEventType}|${effectiveProviderId}|${filterSort}`;
if (loaded && key !== _prevFilterKey && _prevFilterKey !== '') {
applyFilters();
}
@@ -260,7 +262,7 @@
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="w-40"><IconGridSelect items={eventTypeFilterItems()} bind:value={filterEventType} columns={3} compact /></div>
<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>
{#if !globalProviderFilter.id}<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>{/if}
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
</div>
+12 -4
View File
@@ -13,8 +13,11 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { providerDefaultIcon } from '$lib/grid-items';
import RuleEditor from './RuleEditor.svelte';
import ExecutionHistory from './ExecutionHistory.svelte';
import type { Action, ActionRule } from '$lib/types';
@@ -23,7 +26,8 @@
let providers = $derived(providersCache.items);
let filterText = $state('');
let actions = $derived(allActions.filter((a: Action) =>
!filterText || a.name.toLowerCase().includes(filterText.toLowerCase()) || a.action_type.toLowerCase().includes(filterText.toLowerCase())
(!filterText || a.name.toLowerCase().includes(filterText.toLowerCase()) || a.action_type.toLowerCase().includes(filterText.toLowerCase())) &&
(!globalProviderFilter.id || a.provider_id === globalProviderFilter.id)
));
let showForm = $state(false);
@@ -49,7 +53,7 @@
}));
let providerItems = $derived(actionProviders.map((p: any) => ({
value: p.id, label: p.name, icon: p.icon || 'mdiServer', desc: p.type,
value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type,
})));
// Action types for selected provider
@@ -136,8 +140,12 @@
executing = { ...executing, [id]: false };
}
function getProvider(providerId: number) {
return providers.find((p: any) => p.id === providerId);
}
function getProviderName(providerId: number): string {
return providers.find((p: any) => p.id === providerId)?.name || '?';
return getProvider(providerId)?.name || '?';
}
function formatSchedule(action: Action): string {
@@ -297,7 +305,7 @@
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{action.action_type}</span>
</div>
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)]">
<span>{getProviderName(action.provider_id)}</span>
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
<span>{formatSchedule(action)}</span>
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
{#if action.last_run_status}
@@ -112,6 +112,7 @@
name: '',
rule_config: {
criteria: { person_ids: [], person_names: [], query: '', asset_type: 'all', date_from: '', date_to: '', favorite_only: false },
target_album_ids: [] as string[], target_album_names: [] as string[],
target_album_id: '', target_album_name: '',
create_album_if_missing: false, create_album_name: '',
},
+67 -16
View File
@@ -10,6 +10,7 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
import type { TelegramBot, TelegramChat } from '$lib/types';
@@ -104,6 +105,40 @@
} catch (err: any) { snackError(err.message); }
}
const LANG_ITEMS = [
{ value: '', label: '—', icon: 'mdiTranslate', desc: 'Auto' },
{ value: 'en', label: 'EN', icon: 'mdiAlphaECircle', desc: 'English' },
{ value: 'ru', label: 'RU', icon: 'mdiAlphaRCircle', desc: 'Русский' },
{ value: 'uk', label: 'UK', icon: 'mdiAlphaUCircle', desc: 'Українська' },
{ value: 'de', label: 'DE', icon: 'mdiAlphaDCircle', desc: 'Deutsch' },
{ value: 'fr', label: 'FR', icon: 'mdiAlphaFCircle', desc: 'Français' },
{ value: 'es', label: 'ES', icon: 'mdiAlphaECircle', desc: 'Español' },
{ value: 'it', label: 'IT', icon: 'mdiAlphaICircle', desc: 'Italiano' },
{ value: 'pt', label: 'PT', icon: 'mdiAlphaPCircle', desc: 'Português' },
{ value: 'zh', label: 'ZH', icon: 'mdiAlphaZCircle', desc: '中文' },
{ value: 'ja', label: 'JA', icon: 'mdiAlphaJCircle', desc: '日本語' },
{ value: 'ko', label: 'KO', icon: 'mdiAlphaKCircle', desc: '한국어' },
{ value: 'pl', label: 'PL', icon: 'mdiAlphaPCircle', desc: 'Polski' },
{ value: 'nl', label: 'NL', icon: 'mdiAlphaNCircle', desc: 'Nederlands' },
{ value: 'tr', label: 'TR', icon: 'mdiAlphaTCircle', desc: 'Türkçe' },
{ value: 'ar', label: 'AR', icon: 'mdiAlphaACircle', desc: 'العربية' },
{ value: 'hi', label: 'HI', icon: 'mdiAlphaHCircle', desc: 'हिन्दी' },
];
async function updateChatLanguage(botId: number, chat: TelegramChat, lang: string) {
try {
await api(`/telegram-bots/${botId}/chats/${chat.id}`, {
method: 'PUT',
body: JSON.stringify({ language_code: lang }),
});
// Update local state immutably
chats[botId] = (chats[botId] || []).map(c =>
c.id === chat.id ? { ...c, language_code: lang } : c
);
snackSuccess(t('telegramBot.languageUpdated'));
} catch (err: any) { snackError(err.message); }
}
async function loadListenerStatus(botId: number) {
botListenerLoading = { ...botListenerLoading, [botId]: true };
try {
@@ -302,28 +337,43 @@
{:else if (chats[bot.id] || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
{:else}
<div class="space-y-1">
{#each chats[bot.id] as chat}
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] cursor-pointer"
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
title={t('telegramBot.clickToCopy')}
role="button" tabindex="0">
<div class="flex items-center gap-2">
<span class="font-medium">{chat.title || chat.username || 'Unknown'}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
{#if chat.language_code}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chat.language_code.toUpperCase()}</span>{/if}
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
</div>
<div class="flex items-center gap-1">
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 100px 130px 60px; align-items:center; gap:0.5rem;"}
<!-- Header -->
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
<span>{t('telegramBot.chatName')}</span>
<span style="text-align:center">{t('telegramBot.chatType')}</span>
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
<span style="text-align:center">{t('telegramBot.chatId')}</span>
<span></span>
</div>
<!-- Rows -->
{#each chats[bot.id] as chat}
<div style={gridStyle}
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
title={t('telegramBot.clickToCopy')}
role="button" tabindex="0">
<span class="font-medium truncate">{chat.title || chat.username || 'Unknown'}</span>
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
<div style="justify-self:center" onclick={(e: MouseEvent) => e.stopPropagation()}>
<EntitySelect
items={LANG_ITEMS}
value={chat.language_code || ''}
size="sm"
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
/>
</div>
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
<div style="justify-self:end" class="flex items-center gap-1">
<IconButton icon="mdiSend" title="Test message" size={14}
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
</div>
</div>
{/each}
</div>
</div>
{/each}
{/if}
<button onclick={() => discoverChats(bot.id)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
@@ -435,3 +485,4 @@
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
@@ -17,6 +17,7 @@
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import type { CommandConfig } from '$lib/types';
function templateName(id: number | null): string {
@@ -28,9 +29,10 @@
let allCmdConfigs = $derived(commandConfigsCache.items);
let filterText = $state('');
let filterType = $state('');
let effectiveType = $derived(globalProviderFilter.providerType || filterType);
let configs = $derived(allCmdConfigs.filter(c =>
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!filterType || c.provider_type === filterType)
(!effectiveType || c.provider_type === effectiveType)
));
let cmdTemplateConfigs = $derived(commandTemplateConfigsCache.items);
const templateItems = $derived(cmdTemplateConfigs
@@ -240,9 +242,11 @@
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{#if !globalProviderFilter.id}
<div class="w-48">
<IconGridSelect items={providerTypeFilterItems()} bind:value={filterType} columns={2} compact />
</div>
{/if}
</div>
{/if}
@@ -18,6 +18,7 @@
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
interface CmdTemplateConfig {
id: number;
@@ -40,9 +41,10 @@
let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]);
let filterText = $state('');
let filterType = $state('');
let effectiveType = $derived(globalProviderFilter.providerType || filterType);
let configs = $derived(allCmdTplConfigs.filter(c =>
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!filterType || c.provider_type === filterType)
(!effectiveType || c.provider_type === effectiveType)
));
let loaded = $state(false);
let showForm = $state(false);
@@ -336,9 +338,11 @@
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{#if !globalProviderFilter.id}
<div class="w-48">
<IconGridSelect items={providerTypeFilterItems()} bind:value={filterType} columns={2} compact />
</div>
{/if}
</div>
{/if}
@@ -16,19 +16,22 @@
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { providerDefaultIcon } from '$lib/grid-items';
import type { ServiceProvider, TelegramBot } from '$lib/types';
let allCmdTrackers = $state<any[]>([]);
let filterText = $state('');
let filterProviderId = $state(0);
let effectiveProviderId = $derived(globalProviderFilter.id || filterProviderId);
let trackers = $derived(allCmdTrackers.filter((t: any) =>
(!filterText || t.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!filterProviderId || t.provider_id === filterProviderId)
(!effectiveProviderId || t.provider_id === effectiveProviderId)
));
let providers = $derived(providersCache.items);
let commandConfigs = $derived(commandConfigsCache.items);
let telegramBots = $derived(telegramBotsCache.items);
const providerItems = $derived(providers.map(p => ({ value: p.id, label: p.name, icon: p.icon || 'mdiServer', desc: p.type })));
const providerItems = $derived(providers.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })));
const configItems = $derived(filteredConfigs().map((c: any) => ({ value: c.id, label: c.name, icon: c.icon || 'mdiCog', desc: c.provider_type })));
const botItems = $derived(telegramBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiRobot', desc: b.bot_username ? `@${b.bot_username}` : '' })));
let loaded = $state(false);
@@ -222,9 +225,11 @@
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{#if !globalProviderFilter.id}
<div class="w-48">
<EntitySelect items={[{value: 0, label: t('common.allProviders'), icon: 'mdiFilterOff'}, ...providerItems]} bind:value={filterProviderId} placeholder={t('common.allProviders')} />
</div>
{/if}
</div>
{/if}
@@ -14,6 +14,8 @@
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
import TrackerForm from './TrackerForm.svelte';
@@ -26,12 +28,13 @@
let allNotificationTrackers = $state<Tracker[]>([]);
let filterText = $state('');
let filterProviderId = $state(0);
let effectiveProviderId = $derived(globalProviderFilter.id || filterProviderId);
let notificationTrackers = $derived(allNotificationTrackers.filter(t =>
(!filterText || t.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!filterProviderId || t.provider_id === filterProviderId)
(!effectiveProviderId || t.provider_id === effectiveProviderId)
));
let providers = $derived(providersCache.items);
const providerItems = $derived(providers.map(p => ({ value: p.id, label: p.name, icon: p.icon || 'mdiServer', desc: p.type })));
const providerItems = $derived(providers.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })));
let targets = $derived(targetsCache.items);
let trackingConfigs = $derived(trackingConfigsCache.items);
let templateConfigs = $derived(templateConfigsCache.items);
@@ -379,9 +382,11 @@
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{#if !globalProviderFilter.id}
<div class="w-48">
<EntitySelect items={[{value: 0, label: t('common.allProviders'), icon: 'mdiFilterOff'}, ...providerItems]} bind:value={filterProviderId} placeholder={t('common.allProviders')} />
</div>
{/if}
</div>
{/if}
+5 -3
View File
@@ -13,7 +13,8 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { providerTypeItems } from '$lib/grid-items';
import { providerTypeItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { ServiceProvider } from '$lib/types';
@@ -21,7 +22,8 @@
let allProviders = $derived(providersCache.items);
let filterText = $state('');
let providers = $derived(allProviders.filter(p =>
!filterText || p.name.toLowerCase().includes(filterText.toLowerCase()) || p.type.toLowerCase().includes(filterText.toLowerCase())
(!filterText || p.name.toLowerCase().includes(filterText.toLowerCase()) || p.type.toLowerCase().includes(filterText.toLowerCase())) &&
(!globalProviderFilter.id || p.id === globalProviderFilter.id)
));
let showForm = $state(false);
let editing = $state<number | null>(null);
@@ -249,7 +251,7 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={provider.icon || 'mdiServer'} size={20} /></span>
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{provider.name}</p>
@@ -19,14 +19,16 @@
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import type { TemplateConfig } from '$lib/types';
let allTemplateConfigs = $derived(templateConfigsCache.items);
let filterText = $state('');
let filterType = $state('');
let effectiveType = $derived(globalProviderFilter.providerType || filterType);
let configs = $derived(allTemplateConfigs.filter(c =>
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!filterType || c.provider_type === filterType)
(!effectiveType || c.provider_type === effectiveType)
));
let loaded = $state(false);
let varsRef = $state<Record<string, any>>({});
@@ -42,6 +44,21 @@
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
let dateFormatPreview = $state<Record<string, string | null>>({});
const LOCALES = ['en', 'ru'] as const;
let activeLocale = $state<string>('en');
/** Get slot template for current locale, with fallback. */
function getSlotValue(slotName: string): string {
return form.slots[slotName]?.[activeLocale] || '';
}
/** Set slot template for current locale (immutable update). */
function setSlotValue(slotName: string, value: string) {
form.slots = {
...form.slots,
[slotName]: { ...(form.slots[slotName] || {}), [activeLocale]: value }
};
}
function refreshDateFormatPreview() {
clearTimeout(validateTimers['_dateFmt']);
validateTimers['_dateFmt'] = setTimeout(async () => {
@@ -97,7 +114,7 @@
for (const group of templateSlots) {
for (const slot of group.slots) {
if (slot.isDateFormat) continue;
const template = form.slots[slot.key] || '';
const template = getSlotValue(slot.key);
if (template) {
validateSlot(slot.key, template, true);
}
@@ -108,7 +125,7 @@
const defaultForm = () => ({
provider_type: 'immich', name: '', description: '', icon: '',
slots: {} as Record<string, string>,
slots: {} as Record<string, Record<string, string>>,
date_format: '%d.%m.%Y, %H:%M UTC',
date_only_format: '%d.%m.%Y',
});
@@ -152,18 +169,18 @@
finally { loaded = true; highlightFromUrl(); }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; refreshDateFormatPreview(); }
function openNew() { form = defaultForm(); editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; refreshDateFormatPreview(); }
function edit(c: TemplateConfig) {
form = {
provider_type: c.provider_type,
name: c.name,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
slots: Object.fromEntries(Object.entries(c.slots).map(([k, v]) => [k, { ...v }])),
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y',
};
editing = c.id; showForm = true;
editing = c.id; showForm = true; activeLocale = 'en';
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
setTimeout(() => refreshAllPreviews(), 100);
}
@@ -184,12 +201,13 @@
name: `${c.name} (Copy)`,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
slots: Object.fromEntries(Object.entries(c.slots).map(([k, v]) => [k, { ...v }])),
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y',
};
editing = null;
showForm = true;
activeLocale = 'en';
slotPreview = {};
slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
@@ -290,6 +308,17 @@
<IconGridSelect items={previewTargetTypeItems()} bind:value={previewTargetType} columns={2} />
</div>
<!-- Locale tabs -->
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
{#each LOCALES as loc}
<button type="button"
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}>
{loc.toUpperCase()}
</button>
{/each}
</div>
{#each templateSlots.filter(g => g.slots.length > 0) as group}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t(`templateConfig.${group.group}`)}{#if group.group === 'eventMessages'}<Hint text={t('hints.eventMessages')} />{:else if group.group === 'scheduledMessages'}<Hint text={t('hints.scheduledMessages')} />{/if}</legend>
@@ -315,7 +344,7 @@
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('templateConfig.invalidFormat')}</p>
{/if}
{:else}
<JinjaEditor value={form.slots[slot.key] || ''} onchange={(v: string) => { form.slots[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} variables={varsRef[slot.key] || undefined} />
<JinjaEditor value={getSlotValue(slot.key)} onchange={(v: string) => { setSlotValue(slot.key, v); validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} variables={varsRef[slot.key] || undefined} />
{#if slotErrors[slot.key]}
{#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: #d97706;">{t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
@@ -347,9 +376,11 @@
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{#if !globalProviderFilter.id}
<div class="w-48">
<IconGridSelect items={providerTypeFilterItems()} bind:value={filterType} columns={2} compact />
</div>
{/if}
</div>
{/if}
@@ -14,17 +14,19 @@
import Hint from '$lib/components/Hint.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { providerTypeItems, providerTypeFilterItems, sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems } from '$lib/grid-items';
import { providerTypeItems, providerTypeFilterItems, sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems, providerDefaultIcon } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import type { TrackingConfig } from '$lib/types';
let allConfigs = $derived(trackingConfigsCache.items);
let filterText = $state('');
let filterType = $state('');
let effectiveType = $derived(globalProviderFilter.providerType || filterType);
let configs = $derived(allConfigs.filter(c =>
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!filterType || c.provider_type === filterType)
(!effectiveType || c.provider_type === effectiveType)
));
let loaded = $state(false);
let showForm = $state(false);
@@ -262,9 +264,11 @@
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{#if !globalProviderFilter.id}
<div class="w-48">
<IconGridSelect items={providerTypeFilterItems()} bind:value={filterType} columns={2} compact />
</div>
{/if}
</div>
{/if}
@@ -283,7 +287,7 @@
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
{#if config.icon}<MdiIcon name={config.icon} />{/if}
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{config.provider_type}</span>
</div>
@@ -27,7 +27,8 @@ class TargetConfig:
type: str # "telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix"
config: dict[str, Any] # target-level config (bot_token, settings, etc.)
template_slots: dict[str, str] | None = None # event_type -> template string
template_slots: dict[str, dict[str, str]] | None = None # event_type -> {locale -> template}
locale: str = "en" # preferred locale for template resolution
date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y"
provider_api_key: str | None = None # API key for downloading assets from provider
@@ -72,12 +73,12 @@ class NotificationDispatcher:
self, event: ServiceEvent, target: TargetConfig
) -> dict[str, Any]:
"""Send event to a single target (potentially multiple receivers)."""
# Select template
# Select template with locale fallback
template_str = DEFAULT_TEMPLATE
if target.template_slots:
slot = target.template_slots.get(event.event_type.value)
if slot:
template_str = slot
locale_map = target.template_slots.get(event.event_type.value)
if locale_map:
template_str = locale_map.get(target.locale) or locale_map.get("en") or template_str
# Build context and render ONCE
ctx = build_template_context(
@@ -226,9 +226,19 @@ async def test_notification_tracker_target(
select(TemplateSlot).where(
TemplateSlot.config_id == template_config.id,
TemplateSlot.slot_name == slot_name,
TemplateSlot.locale == "en",
)
)
slot = slot_result.first()
if not slot:
# Fallback: any locale
slot_result2 = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == template_config.id,
TemplateSlot.slot_name == slot_name,
)
)
slot = slot_result2.first()
template_str = slot.template if slot else ""
# Load provider and tracker data eagerly before aiohttp context
@@ -369,8 +369,22 @@ async def list_people(
provider.config.get("url", ""),
provider.config.get("api_key", ""),
)
people_dict = await client.get_people()
return [{"id": pid, "name": name} for pid, name in people_dict.items()]
try:
async with http_session.get(
f"{client.url}/api/people",
headers={"x-api-key": client.api_key},
ssl=False,
) as response:
if response.status == 200:
data = await response.json()
people_list = data.get("people", data) if isinstance(data, dict) else data
return [
{"id": p["id"], "name": p.get("name", "")}
for p in people_list
if p.get("name")
]
except Exception as e:
_LOGGER.error("Failed to fetch people: %s", e)
return []
@@ -296,6 +296,37 @@ async def test_chat(
return await client.send_message(chat_id, message)
class ChatUpdate(BaseModel):
language_code: str | None = None
title: str | None = None
@router.put("/{bot_id}/chats/{chat_db_id}")
async def update_chat(
bot_id: int,
chat_db_id: int,
body: ChatUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a chat's language_code or title."""
await _get_user_bot(session, bot_id, user.id)
chat = await session.get(TelegramChat, chat_db_id)
if not chat or chat.bot_id != bot_id:
raise HTTPException(status_code=404, detail="Chat not found")
updates = body.model_dump(exclude_unset=True)
for key, value in updates.items():
setattr(chat, key, value)
session.add(chat)
await session.commit()
await session.refresh(chat)
return {
"id": chat.id, "bot_id": chat.bot_id, "chat_id": chat.chat_id,
"title": chat.title, "type": chat.chat_type, "username": chat.username,
"language_code": chat.language_code, "discovered_at": chat.discovered_at.isoformat() if chat.discovered_at else None,
}
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_chat(
bot_id: int,
@@ -32,7 +32,7 @@ class TemplateConfigCreate(BaseModel):
icon: str | None = None
date_format: str | None = None
date_only_format: str | None = None
slots: dict[str, str] = {} # slot_name -> template text
slots: dict[str, dict[str, str]] = {} # slot_name -> {locale -> template text}
class TemplateConfigUpdate(BaseModel):
@@ -41,42 +41,48 @@ class TemplateConfigUpdate(BaseModel):
icon: str | None = None
date_format: str | None = None
date_only_format: str | None = None
slots: dict[str, str] | None = None # partial update: only provided slots change
slots: dict[str, dict[str, str]] | None = None # partial update
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, str]:
"""Load all template slots for a config as a dict."""
async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, dict[str, str]]:
"""Load all template slots for a config as {slot_name: {locale: template}}."""
result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == config_id)
)
return {s.slot_name: s.template for s in result.all()}
slots: dict[str, dict[str, str]] = {}
for s in result.all():
slots.setdefault(s.slot_name, {})[s.locale] = s.template
return slots
async def _save_slots(
session: AsyncSession, config_id: int, slots: dict[str, str]
session: AsyncSession, config_id: int, slots: dict[str, dict[str, str]]
) -> None:
"""Create or update template slots for a config."""
for slot_name, template_text in slots.items():
result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config_id,
TemplateSlot.slot_name == slot_name,
"""Create or update template slots for a config (locale-aware)."""
for slot_name, locale_map in slots.items():
for locale, template_text in locale_map.items():
result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config_id,
TemplateSlot.slot_name == slot_name,
TemplateSlot.locale == locale,
)
)
)
existing = result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config_id,
slot_name=slot_name,
template=template_text,
))
existing = result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config_id,
slot_name=slot_name,
locale=locale,
template=template_text,
))
async def _response(session: AsyncSession, c: TemplateConfig) -> dict[str, Any]:
@@ -322,13 +328,15 @@ async def delete_config(
async def preview_config(
config_id: int,
slot: str = "message_assets_added",
locale: str = "en",
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Render a specific template slot with sample data."""
config = await _get(session, config_id, user.id)
slots = await _load_slots(session, config.id)
template_body = slots.get(slot, "")
locale_map = slots.get(slot, {})
template_body = locale_map.get(locale) or locale_map.get("en", "")
if not template_body:
raise HTTPException(status_code=400, detail=f"Slot '{slot}' has no template")
try:
@@ -1005,3 +1005,102 @@ async def migrate_command_slot_locale(engine: AsyncEngine) -> None:
"Merged system command template configs (EN=%d, RU=%d) into single config %d",
en_id, ru_id, en_id,
)
async def migrate_notification_slot_locale(engine: AsyncEngine) -> None:
"""Add locale column to template_slot and merge system EN/RU configs per provider.
1. Recreate template_slot with locale column and new unique constraint
2. Backfill locale from parent config's locale (or 'en')
3. For each provider: merge "Default X (RU)" slots into "Default X (EN)" with locale='ru'
4. Rename merged configs, update references, delete orphan RU configs
"""
async with engine.begin() as conn:
if not await _has_table(conn, "template_slot"):
return
# Skip if locale column already exists (idempotent)
if await _has_column(conn, "template_slot", "locale"):
return
logger.info("Adding locale column to template_slot and merging system configs")
# Step 1: Recreate table with locale column and new unique constraint
await conn.execute(text(
"CREATE TABLE template_slot_new ("
" id INTEGER PRIMARY KEY,"
" config_id INTEGER NOT NULL REFERENCES template_config(id),"
" slot_name TEXT NOT NULL,"
" locale TEXT NOT NULL DEFAULT 'en',"
" template TEXT DEFAULT '',"
" UNIQUE(config_id, slot_name, locale)"
")"
))
# Step 2: Copy existing data, deriving locale from parent config
await conn.execute(text(
"INSERT INTO template_slot_new (id, config_id, slot_name, locale, template) "
"SELECT s.id, s.config_id, s.slot_name, "
" CASE WHEN c.locale != '' THEN c.locale ELSE 'en' END, "
" s.template "
"FROM template_slot s "
"LEFT JOIN template_config c ON s.config_id = c.id"
))
await conn.execute(text("DROP TABLE template_slot"))
await conn.execute(text(
"ALTER TABLE template_slot_new RENAME TO template_slot"
))
# Step 3: Merge system EN/RU configs per provider type
providers = (await conn.execute(text(
"SELECT DISTINCT provider_type FROM template_config WHERE user_id = 0"
))).fetchall()
for (provider_type,) in providers:
en_row = (await conn.execute(text(
"SELECT id FROM template_config "
"WHERE user_id = 0 AND provider_type = :pt "
" AND (locale = 'en' OR name LIKE '%(EN)%') "
"LIMIT 1"
), {"pt": provider_type})).fetchone()
ru_row = (await conn.execute(text(
"SELECT id FROM template_config "
"WHERE user_id = 0 AND provider_type = :pt "
" AND (locale = 'ru' OR name LIKE '%(RU)%') "
"LIMIT 1"
), {"pt": provider_type})).fetchone()
if en_row and ru_row and en_row[0] != ru_row[0]:
en_id, ru_id = en_row[0], ru_row[0]
# Move RU slots to the EN config (they already have locale='ru')
await conn.execute(text(
"UPDATE template_slot SET config_id = :en_id "
"WHERE config_id = :ru_id"
), {"en_id": en_id, "ru_id": ru_id})
# Update notification_tracker_target references from RU to EN
if await _has_table(conn, "notification_tracker_target"):
await conn.execute(text(
"UPDATE notification_tracker_target SET template_config_id = :en_id "
"WHERE template_config_id = :ru_id"
), {"en_id": en_id, "ru_id": ru_id})
# Delete the orphan RU config
await conn.execute(text(
"DELETE FROM template_config WHERE id = :ru_id"
), {"ru_id": ru_id})
# Rename the merged config (strip locale suffix)
label = provider_type.capitalize()
await conn.execute(text(
"UPDATE template_config SET name = :name, "
"description = :desc, locale = '' "
"WHERE id = :en_id"
), {"name": f"Default {label}", "desc": f"Default {label} templates", "en_id": en_id})
logger.info(
"Merged system notification template configs for %s (EN=%d, RU=%d) into %d",
provider_type, en_id, ru_id, en_id,
)
@@ -213,14 +213,15 @@ class TemplateConfig(SQLModel, table=True):
class TemplateSlot(SQLModel, table=True):
"""One Jinja2 template for a specific slot within a TemplateConfig.
"""One Jinja2 template for a specific slot and locale within a TemplateConfig.
Slot names are provider-specific (e.g. 'message_assets_added' for Immich).
Each (config, slot, locale) triple holds a separate template.
"""
__tablename__ = "template_slot"
__table_args__ = (
UniqueConstraint("config_id", "slot_name", name="uq_template_slot"),
UniqueConstraint("config_id", "slot_name", "locale", name="uq_template_slot_locale"),
)
id: int | None = Field(default=None, primary_key=True)
@@ -231,6 +232,7 @@ class TemplateSlot(SQLModel, table=True):
)
slot_name: str
locale: str = Field(default="en")
template: str = Field(default="", sa_column=Column(Text, default=""))
@@ -30,7 +30,11 @@ async def _seed_provider_template(
provider_type: str,
label: str,
) -> None:
"""Seed templates for a single provider type across all locales."""
"""Seed templates for a single provider type across all locales.
Creates a single TemplateConfig per provider with locale-aware slots
(each slot has an EN and RU version stored as separate rows).
"""
from notify_bridge_core.templates.defaults import load_default_templates
result = await session.exec(
@@ -40,80 +44,42 @@ async def _seed_provider_template(
)
)
configs = result.all()
existing_locales = {
(c.locale if c.locale else ("ru" if "(RU)" in c.name else "en")): c
for c in configs
}
if not configs:
config = TemplateConfig(
user_id=0,
provider_type=provider_type,
name=f"Default {label}",
description=f"Default {label} templates",
)
session.add(config)
await session.flush()
else:
config = configs[0]
for locale in ("en", "ru"):
slots = load_default_templates(locale, provider_type=provider_type)
if not slots:
continue
if locale not in existing_locales:
now = datetime.now(timezone.utc).isoformat()
name = f"Default {label} ({locale.upper()})"
desc = f"Default {label} templates ({locale.upper()})"
# Get column names to build INSERT with defaults for legacy cols
col_info = (await session.execute(
text("PRAGMA table_info(template_config)")
)).fetchall()
col_names = [c[1] for c in col_info if c[1] != "id"]
values: dict[str, object] = {}
for col in col_names:
if col == "user_id":
values[col] = 0
elif col == "provider_type":
values[col] = provider_type
elif col == "name":
values[col] = name
elif col == "description":
values[col] = desc
elif col == "created_at":
values[col] = now
elif col == "date_format":
values[col] = "%d.%m.%Y, %H:%M UTC"
elif col == "date_only_format":
values[col] = "%d.%m.%Y"
elif col == "locale":
values[col] = locale
else:
values[col] = "" # empty string for legacy columns
cols_str = ", ".join(values.keys())
placeholders = ", ".join(f":{k}" for k in values.keys())
await session.execute(
text(f"INSERT INTO template_config ({cols_str}) VALUES ({placeholders})"),
values,
for slot_name, template_text in slots.items():
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config.id,
TemplateSlot.slot_name == slot_name,
TemplateSlot.locale == locale,
)
)
config_id = (await session.execute(
text("SELECT last_insert_rowid()")
)).scalar()
for slot_name, template_text in slots.items():
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config_id,
config_id=config.id,
slot_name=slot_name,
locale=locale,
template=template_text,
))
else:
config = existing_locales[locale]
for slot_name, template_text in slots.items():
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config.id,
TemplateSlot.slot_name == slot_name,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config.id,
slot_name=slot_name,
template=template_text,
))
async def _seed_provider_command_template(
@@ -47,7 +47,7 @@ async def lifespan(app: FastAPI):
await init_db()
# Run data migrations (idempotent)
from .database.engine import get_engine
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers, migrate_template_locale, migrate_receivers_from_config, migrate_command_slot_locale
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers, migrate_template_locale, migrate_receivers_from_config, migrate_command_slot_locale, migrate_notification_slot_locale
engine = get_engine()
await migrate_schema(engine)
await migrate_tracker_targets(engine)
@@ -57,6 +57,7 @@ async def lifespan(app: FastAPI):
await migrate_template_locale(engine)
await migrate_receivers_from_config(engine)
await migrate_command_slot_locale(engine)
await migrate_notification_slot_locale(engine)
from .database.seeds import seed_all
await seed_all()
# Configure webhook secret from DB setting (falls back to env var)
@@ -129,18 +129,18 @@ async def load_link_data(
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
template_config = None
template_slots: dict[str, str] | None = None
template_slots: dict[str, dict[str, str]] | None = None
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
if template_config:
slot_result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == template_config.id)
)
raw_slots = {s.slot_name: s.template for s in slot_result.all()}
template_slots = {}
for slot_name, tmpl_text in raw_slots.items():
event_key = slot_name.removeprefix("message_") if slot_name.startswith("message_") else slot_name
template_slots[event_key] = tmpl_text
raw_slots: dict[str, dict[str, str]] = {}
for s in slot_result.all():
event_key = s.slot_name.removeprefix("message_") if s.slot_name.startswith("message_") else s.slot_name
raw_slots.setdefault(event_key, {})[s.locale] = s.template
template_slots = raw_slots
target_config = dict(target.config)
# Inject chat_action for Telegram targets