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:
@@ -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();
|
||||
|
||||
@@ -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') },
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: '',
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user