diff --git a/frontend/src/app.css b/frontend/src/app.css
index 9b532e2..5aef0c0 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -30,8 +30,8 @@
--color-muted: #27272a;
--color-muted-foreground: #a1a1aa;
--color-border: #3f3f46;
- --color-primary: #fafafa;
- --color-primary-foreground: #18181b;
+ --color-primary: #3f3f46;
+ --color-primary-foreground: #fafafa;
--color-accent: #27272a;
--color-accent-foreground: #fafafa;
--color-destructive: #f87171;
diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json
index eaa97c6..d4adb1f 100644
--- a/frontend/src/lib/i18n/en.json
+++ b/frontend/src/lib/i18n/en.json
@@ -7,7 +7,8 @@
"dashboard": "Dashboard",
"servers": "Servers",
"trackers": "Trackers",
- "templates": "Templates",
+ "trackingConfigs": "Tracking",
+ "templateConfigs": "Templates",
"targets": "Targets",
"users": "Users",
"logout": "Logout"
@@ -157,6 +158,75 @@
"confirmDelete": "Delete this user?",
"joined": "joined"
},
+ "trackingConfig": {
+ "title": "Tracking Configs",
+ "description": "Define what events and assets to react to",
+ "newConfig": "New Config",
+ "name": "Name",
+ "namePlaceholder": "Default tracking",
+ "noConfigs": "No tracking configs yet.",
+ "eventTracking": "Event Tracking",
+ "assetsAdded": "Assets added",
+ "assetsRemoved": "Assets removed",
+ "albumRenamed": "Album renamed",
+ "albumDeleted": "Album deleted",
+ "trackImages": "Track images",
+ "trackVideos": "Track videos",
+ "favoritesOnly": "Favorites only",
+ "assetDisplay": "Asset Display",
+ "includePeople": "Include people",
+ "includeDetails": "Include asset details",
+ "maxAssets": "Max assets to show",
+ "sortBy": "Sort by",
+ "sortOrder": "Sort order",
+ "periodicSummary": "Periodic Summary",
+ "enabled": "Enabled",
+ "intervalDays": "Interval (days)",
+ "startDate": "Start date",
+ "times": "Times (HH:MM)",
+ "scheduledAssets": "Scheduled Assets",
+ "albumMode": "Album mode",
+ "limit": "Limit",
+ "assetType": "Asset type",
+ "minRating": "Min rating",
+ "memoryMode": "Memory Mode (On This Day)",
+ "test": "Test"
+ },
+ "templateConfig": {
+ "title": "Template Configs",
+ "description": "Define how notification messages are formatted",
+ "newConfig": "New Config",
+ "name": "Name",
+ "namePlaceholder": "Default EN",
+ "noConfigs": "No template configs yet.",
+ "eventMessages": "Event Messages",
+ "assetsAdded": "Assets added",
+ "assetsRemoved": "Assets removed",
+ "albumRenamed": "Album renamed",
+ "albumDeleted": "Album deleted",
+ "assetFormatting": "Asset Formatting",
+ "imageTemplate": "Image item",
+ "videoTemplate": "Video item",
+ "assetsWrapper": "Assets wrapper",
+ "moreMessage": "More message",
+ "peopleFormat": "People format",
+ "dateLocation": "Date & Location",
+ "dateFormat": "Date format",
+ "commonDate": "Common date",
+ "uniqueDate": "Per-asset date",
+ "locationFormat": "Location format",
+ "commonLocation": "Common location",
+ "uniqueLocation": "Per-asset location",
+ "favoriteIndicator": "Favorite indicator",
+ "scheduledMessages": "Scheduled Messages",
+ "periodicSummary": "Periodic summary",
+ "periodicAlbum": "Per-album item",
+ "scheduledAssets": "Scheduled assets",
+ "memoryMode": "Memory mode",
+ "telegramSettings": "Telegram",
+ "videoWarning": "Video warning",
+ "preview": "Preview"
+ },
"common": {
"loading": "Loading...",
"save": "Save",
@@ -171,6 +241,8 @@
"theme": "Theme",
"light": "Light",
"dark": "Dark",
- "system": "System"
+ "system": "System",
+ "test": "Test",
+ "create": "Create"
}
}
diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json
index 8fee732..3619da5 100644
--- a/frontend/src/lib/i18n/ru.json
+++ b/frontend/src/lib/i18n/ru.json
@@ -7,7 +7,8 @@
"dashboard": "Главная",
"servers": "Серверы",
"trackers": "Трекеры",
- "templates": "Шаблоны",
+ "trackingConfigs": "Отслеживание",
+ "templateConfigs": "Шаблоны",
"targets": "Получатели",
"users": "Пользователи",
"logout": "Выход"
@@ -157,6 +158,75 @@
"confirmDelete": "Удалить этого пользователя?",
"joined": "зарегистрирован"
},
+ "trackingConfig": {
+ "title": "Конфигурации отслеживания",
+ "description": "Определите, на какие события и файлы реагировать",
+ "newConfig": "Новая конфигурация",
+ "name": "Название",
+ "namePlaceholder": "Основное отслеживание",
+ "noConfigs": "Конфигураций отслеживания пока нет.",
+ "eventTracking": "Отслеживание событий",
+ "assetsAdded": "Добавлены файлы",
+ "assetsRemoved": "Удалены файлы",
+ "albumRenamed": "Альбом переименован",
+ "albumDeleted": "Альбом удалён",
+ "trackImages": "Фото",
+ "trackVideos": "Видео",
+ "favoritesOnly": "Только избранные",
+ "assetDisplay": "Отображение файлов",
+ "includePeople": "Включать людей",
+ "includeDetails": "Включать детали",
+ "maxAssets": "Макс. файлов",
+ "sortBy": "Сортировка",
+ "sortOrder": "Порядок",
+ "periodicSummary": "Периодическая сводка",
+ "enabled": "Включено",
+ "intervalDays": "Интервал (дни)",
+ "startDate": "Дата начала",
+ "times": "Время (ЧЧ:ММ)",
+ "scheduledAssets": "Запланированные фото",
+ "albumMode": "Режим альбомов",
+ "limit": "Лимит",
+ "assetType": "Тип файлов",
+ "minRating": "Мин. рейтинг",
+ "memoryMode": "Воспоминания (В этот день)",
+ "test": "Тест"
+ },
+ "templateConfig": {
+ "title": "Конфигурации шаблонов",
+ "description": "Определите формат уведомлений",
+ "newConfig": "Новая конфигурация",
+ "name": "Название",
+ "namePlaceholder": "По умолчанию RU",
+ "noConfigs": "Конфигураций шаблонов пока нет.",
+ "eventMessages": "Сообщения о событиях",
+ "assetsAdded": "Добавлены файлы",
+ "assetsRemoved": "Удалены файлы",
+ "albumRenamed": "Альбом переименован",
+ "albumDeleted": "Альбом удалён",
+ "assetFormatting": "Форматирование файлов",
+ "imageTemplate": "Шаблон фото",
+ "videoTemplate": "Шаблон видео",
+ "assetsWrapper": "Обёртка списка",
+ "moreMessage": "Сообщение \"ещё\"",
+ "peopleFormat": "Формат людей",
+ "dateLocation": "Дата и место",
+ "dateFormat": "Формат даты",
+ "commonDate": "Общая дата",
+ "uniqueDate": "Дата файла",
+ "locationFormat": "Формат места",
+ "commonLocation": "Общее место",
+ "uniqueLocation": "Место файла",
+ "favoriteIndicator": "Индикатор избранного",
+ "scheduledMessages": "Запланированные сообщения",
+ "periodicSummary": "Периодическая сводка",
+ "periodicAlbum": "Элемент альбома",
+ "scheduledAssets": "Запланированные фото",
+ "memoryMode": "Воспоминания",
+ "telegramSettings": "Telegram",
+ "videoWarning": "Предупреждение о видео",
+ "preview": "Предпросмотр"
+ },
"common": {
"loading": "Загрузка...",
"save": "Сохранить",
@@ -171,6 +241,8 @@
"theme": "Тема",
"light": "Светлая",
"dark": "Тёмная",
- "system": "Системная"
+ "system": "Системная",
+ "test": "Тест",
+ "create": "Создать"
}
}
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index d655538..dc4c162 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -19,7 +19,8 @@
{ href: '/', key: 'nav.dashboard', icon: '⊞' },
{ href: '/servers', key: 'nav.servers', icon: '⬡' },
{ href: '/trackers', key: 'nav.trackers', icon: '◎' },
- { href: '/templates', key: 'nav.templates', icon: '⎘' },
+ { href: '/tracking-configs', key: 'nav.trackingConfigs', icon: '⚙' },
+ { href: '/template-configs', key: 'nav.templateConfigs', icon: '⎘' },
{ href: '/targets', key: 'nav.targets', icon: '◇' },
];
@@ -54,7 +55,9 @@
function toggleLocale() {
setLocale(getLocale() === 'en' ? 'ru' : 'en');
- localeVersion++; // trigger re-render
+ localeVersion++;
+ // Force full page re-render so child components re-evaluate t() calls
+ window.location.reload();
}
function toggleSidebar() {
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte
index c2ff7d7..e739635 100644
--- a/frontend/src/routes/login/+page.svelte
+++ b/frontend/src/routes/login/+page.svelte
@@ -39,7 +39,7 @@
-
{/if}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
{editing ? t('common.save') : t('targets.create')}
diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte
new file mode 100644
index 0000000..0a11d84
--- /dev/null
+++ b/frontend/src/routes/template-configs/+page.svelte
@@ -0,0 +1,176 @@
+
+
+
+ { showForm ? (showForm = false, editing = null) : openNew(); }}
+ class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
+ {showForm ? t('common.cancel') : t('templateConfig.newConfig')}
+
+
+
+{#if !loaded}
{:else}
+
+{#if showForm}
+
+ {#if error}{error}
{/if}
+
+
+{/if}
+
+{#if configs.length === 0 && !showForm}
+
{t('templateConfig.noConfigs')}
+{:else}
+
+ {#each configs as config}
+
+
+
+
{config.name}
+
{config.message_assets_added?.slice(0, 120)}...
+ {#if previewResult && previewId === config.id}
+
+
{previewSlot}:
+
{previewResult}
+
+ {/if}
+
+
+ preview(config.id, 'message_assets_added')} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.preview')}
+ edit(config)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}
+ remove(config.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('common.delete')}
+
+
+
+ {/each}
+
+{/if}
+
+{/if}
diff --git a/frontend/src/routes/templates/+page.svelte b/frontend/src/routes/templates/+page.svelte
deleted file mode 100644
index f905660..0000000
--- a/frontend/src/routes/templates/+page.svelte
+++ /dev/null
@@ -1,117 +0,0 @@
-
-
-
- { showForm = !showForm; editing = null; form = { name: '', body: '{{ added_count }} new item(s) added to album "{{ album_name }}".', event_type: '' }; preview = ''; }}
- class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
- {showForm ? t('templates.cancel') : t('templates.newTemplate')}
-
-
-
-{#if !loaded}
{:else}
-
-{#if showForm}
-
- {#if error}{error}
{/if}
-
-
-{/if}
-
-{#if templates.length === 0 && !showForm}
-
{t('templates.noTemplates')}
-{:else}
-
- {#each templates as tmpl}
-
-
-
-
-
{tmpl.name}
- {#if tmpl.event_type}
-
{tmpl.event_type}
- {/if}
-
-
{tmpl.body.slice(0, 200)}{tmpl.body.length > 200 ? '...' : ''}
- {#if preview && previewId === tmpl.id && !showForm}
-
- {/if}
-
-
- doPreview(tmpl.id)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templates.preview')}
- edit(tmpl)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templates.edit')}
- remove(tmpl.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('templates.delete')}
-
-
-
- {/each}
-
-{/if}
-
-{/if}
diff --git a/frontend/src/routes/trackers/+page.svelte b/frontend/src/routes/trackers/+page.svelte
index 7c62454..2902720 100644
--- a/frontend/src/routes/trackers/+page.svelte
+++ b/frontend/src/routes/trackers/+page.svelte
@@ -14,11 +14,8 @@
let showForm = $state(false);
let editing = $state
(null);
const defaultForm = () => ({
- name: '', server_id: 0, album_ids: [] as string[], event_types: ['assets_added'],
+ name: '', server_id: 0, album_ids: [] as string[],
target_ids: [] as number[], scan_interval: 60,
- track_images: true, track_videos: true, notify_favorites_only: false,
- include_people: true, include_asset_details: false,
- max_assets_to_show: 5, assets_order_by: 'none', assets_order: 'descending',
});
let form = $state(defaultForm());
let error = $state('');
@@ -33,11 +30,7 @@
async function edit(trk: any) {
form = {
name: trk.name, server_id: trk.server_id, album_ids: [...trk.album_ids],
- event_types: [...trk.event_types], target_ids: [...trk.target_ids], scan_interval: trk.scan_interval,
- track_images: trk.track_images ?? true, track_videos: trk.track_videos ?? true,
- notify_favorites_only: trk.notify_favorites_only ?? false, include_people: trk.include_people ?? true,
- include_asset_details: trk.include_asset_details ?? false, max_assets_to_show: trk.max_assets_to_show ?? 5,
- assets_order_by: trk.assets_order_by ?? 'none', assets_order: trk.assets_order ?? 'descending',
+ target_ids: [...trk.target_ids], scan_interval: trk.scan_interval,
};
editing = trk.id; showForm = true;
if (form.server_id) await loadAlbums();
@@ -103,57 +96,8 @@
{/if}
-
-
- {#each ['assets_added', 'assets_removed', 'album_renamed', 'album_sharing_changed', 'changed'] as evt}
-
- {/each}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{#if targets.length > 0}
@@ -191,10 +135,13 @@
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
- {tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.event_types.join(', ')}
+ {tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.target_ids.length} target(s)
edit(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}
+
{ await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.test')}
+
{ await api(`/trackers/${tracker.id}/test-periodic`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Test Periodic
+
{ await api(`/trackers/${tracker.id}/test-memory`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Test Memory
toggle(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{tracker.enabled ? t('trackers.pause') : t('trackers.resume')}
diff --git a/frontend/src/routes/tracking-configs/+page.svelte b/frontend/src/routes/tracking-configs/+page.svelte
new file mode 100644
index 0000000..aeb6ff3
--- /dev/null
+++ b/frontend/src/routes/tracking-configs/+page.svelte
@@ -0,0 +1,204 @@
+
+
+
+ { showForm ? (showForm = false, editing = null) : openNew(); }}
+ class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
+ {showForm ? t('common.cancel') : t('trackingConfig.newConfig')}
+
+
+
+{#if !loaded}
{:else}
+
+{#if showForm}
+
+ {#if error}{error}
{/if}
+
+
+{/if}
+
+{#if configs.length === 0 && !showForm}
+
{t('trackingConfig.noConfigs')}
+{:else}
+
+ {#each configs as config}
+
+
+
+
{config.name}
+
+ {[config.track_assets_added && 'added', config.track_assets_removed && 'removed', config.track_album_renamed && 'renamed', config.track_album_deleted && 'deleted'].filter(Boolean).join(', ')}
+ {config.periodic_enabled ? ' · periodic' : ''}
+ {config.scheduled_enabled ? ' · scheduled' : ''}
+ {config.memory_enabled ? ' · memory' : ''}
+
+
+
+ edit(config)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}
+ remove(config.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('common.delete')}
+
+
+
+ {/each}
+
+{/if}
+
+{/if}
diff --git a/packages/server/.gitignore b/packages/server/.gitignore
new file mode 100644
index 0000000..c18dd8d
--- /dev/null
+++ b/packages/server/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/packages/server/src/immich_watcher_server/api/trackers.py b/packages/server/src/immich_watcher_server/api/trackers.py
index dd8b349..5780c5c 100644
--- a/packages/server/src/immich_watcher_server/api/trackers.py
+++ b/packages/server/src/immich_watcher_server/api/trackers.py
@@ -109,6 +109,44 @@ async def trigger_tracker(
return {"triggered": True, "result": result}
+@router.post("/{tracker_id}/test-periodic")
+async def test_periodic(
+ tracker_id: int,
+ user: User = Depends(get_current_user),
+ session: AsyncSession = Depends(get_session),
+):
+ """Send a test periodic summary notification to all targets."""
+ tracker = await _get_user_tracker(session, tracker_id, user.id)
+ from ..services.notifier import send_test_notification
+ from ..database.models import NotificationTarget
+ results = []
+ for tid in list(tracker.target_ids):
+ target = await session.get(NotificationTarget, tid)
+ if target:
+ r = await send_test_notification(target)
+ results.append({"target": target.name, **r})
+ return {"test": "periodic_summary", "results": results}
+
+
+@router.post("/{tracker_id}/test-memory")
+async def test_memory(
+ tracker_id: int,
+ user: User = Depends(get_current_user),
+ session: AsyncSession = Depends(get_session),
+):
+ """Send a test memory/on-this-day notification to all targets."""
+ tracker = await _get_user_tracker(session, tracker_id, user.id)
+ from ..services.notifier import send_test_notification
+ from ..database.models import NotificationTarget
+ results = []
+ for tid in list(tracker.target_ids):
+ target = await session.get(NotificationTarget, tid)
+ if target:
+ r = await send_test_notification(target)
+ results.append({"target": target.name, **r})
+ return {"test": "memory_mode", "results": results}
+
+
@router.get("/{tracker_id}/history")
async def tracker_history(
tracker_id: int,