diff --git a/frontend/src/lib/components/JinjaEditor.svelte b/frontend/src/lib/components/JinjaEditor.svelte index b87ea4c..3031cc3 100644 --- a/frontend/src/lib/components/JinjaEditor.svelte +++ b/frontend/src/lib/components/JinjaEditor.svelte @@ -1,22 +1,44 @@
diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 0563436..e5fe806 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -266,8 +266,48 @@ "telegramSettings": "Telegram", "videoWarning": "Video warning", "preview": "Preview", + "variables": "Variables", + "assetFields": "Asset fields (in {% for asset in added_assets %})", "confirmDelete": "Delete this template config?" }, + "templateVars": { + "message_assets_added": { "description": "Notification when new assets are added to an album" }, + "message_assets_removed": { "description": "Notification when assets are removed from an album" }, + "message_album_renamed": { "description": "Notification when an album is renamed" }, + "message_album_deleted": { "description": "Notification when an album is deleted" }, + "periodic_summary_message": { "description": "Periodic album summary with stats" }, + "scheduled_assets_message": { "description": "Scheduled asset picks from albums" }, + "memory_mode_message": { "description": "\"On This Day\" memories from past years" }, + "album_name": "Album name", + "album_url": "Public share URL (if available)", + "added_count": "Number of assets added", + "removed_count": "Number of assets removed", + "change_type": "Type of change", + "people": "Detected people names (use {{ people | join(', ') }})", + "added_assets": "List of added asset objects (use {% for asset in added_assets %})", + "removed_assets": "List of removed asset IDs", + "shared": "Whether album is shared (true/false)", + "video_warning": "Video size warning text", + "old_name": "Previous album name", + "new_name": "New album name", + "albums": "List of album objects (use {% for album in albums %})", + "assets": "List of asset objects (use {% for asset in assets %})", + "date": "Current date/time", + "asset_filename": "Original filename", + "asset_type": "IMAGE or VIDEO", + "asset_created_at": "Creation date/time (ISO 8601)", + "asset_owner": "Owner display name", + "asset_description": "User or EXIF description", + "asset_url": "Public viewer URL", + "asset_download_url": "Direct download URL", + "asset_photo_url": "Preview image URL (images only)", + "asset_playback_url": "Video playback URL (videos only)", + "asset_is_favorite": "Whether asset is favorited", + "asset_rating": "Star rating (1-5 or null)", + "asset_city": "City name", + "asset_state": "State/region name", + "asset_country": "Country name" + }, "hints": { "periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.", "scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.", @@ -318,6 +358,8 @@ "newPassword": "New password", "passwordChanged": "Password changed successfully", "expand": "Expand", - "collapse": "Collapse" + "collapse": "Collapse", + "syntaxError": "Syntax error", + "line": "line" } } diff --git a/frontend/src/lib/i18n/index.svelte.ts b/frontend/src/lib/i18n/index.svelte.ts index 918209b..4c208b4 100644 --- a/frontend/src/lib/i18n/index.svelte.ts +++ b/frontend/src/lib/i18n/index.svelte.ts @@ -45,9 +45,10 @@ export function initLocale() { * Falls back to English if key not found in current locale. * Reactive: re-evaluates when currentLocale changes. */ -export function t(key: string): string { +export function t(key: string, fallback?: string): string { return resolve(translations[currentLocale], key) ?? resolve(translations.en, key) + ?? fallback ?? key; } diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 6c203f5..b088ca2 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -266,8 +266,48 @@ "telegramSettings": "Telegram", "videoWarning": "Предупреждение о видео", "preview": "Предпросмотр", + "variables": "Переменные", + "assetFields": "Поля файла (в {% for asset in added_assets %})", "confirmDelete": "Удалить эту конфигурацию шаблона?" }, + "templateVars": { + "message_assets_added": { "description": "Уведомление о добавлении файлов в альбом" }, + "message_assets_removed": { "description": "Уведомление об удалении файлов из альбома" }, + "message_album_renamed": { "description": "Уведомление о переименовании альбома" }, + "message_album_deleted": { "description": "Уведомление об удалении альбома" }, + "periodic_summary_message": { "description": "Периодическая сводка альбомов со статистикой" }, + "scheduled_assets_message": { "description": "Запланированная подборка фото из альбомов" }, + "memory_mode_message": { "description": "«В этот день» — фото из прошлых лет" }, + "album_name": "Название альбома", + "album_url": "Публичная ссылка (если есть)", + "added_count": "Количество добавленных файлов", + "removed_count": "Количество удалённых файлов", + "change_type": "Тип изменения", + "people": "Обнаруженные люди ({{ people | join(', ') }})", + "added_assets": "Список добавленных файлов ({% for asset in added_assets %})", + "removed_assets": "Список ID удалённых файлов", + "shared": "Общий альбом (true/false)", + "video_warning": "Предупреждение о размере видео", + "old_name": "Прежнее название альбома", + "new_name": "Новое название альбома", + "albums": "Список альбомов ({% for album in albums %})", + "assets": "Список файлов ({% for asset in assets %})", + "date": "Текущая дата/время", + "asset_filename": "Имя файла", + "asset_type": "IMAGE или VIDEO", + "asset_created_at": "Дата создания (ISO 8601)", + "asset_owner": "Имя владельца", + "asset_description": "Описание (EXIF или пользовательское)", + "asset_url": "Ссылка для просмотра", + "asset_download_url": "Ссылка для скачивания", + "asset_photo_url": "URL превью (только фото)", + "asset_playback_url": "URL видео (только видео)", + "asset_is_favorite": "В избранном", + "asset_rating": "Рейтинг (1-5 или null)", + "asset_city": "Город", + "asset_state": "Регион", + "asset_country": "Страна" + }, "hints": { "periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.", "scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.", @@ -318,6 +358,8 @@ "newPassword": "Новый пароль", "passwordChanged": "Пароль успешно изменён", "expand": "Развернуть", - "collapse": "Свернуть" + "collapse": "Свернуть", + "syntaxError": "Ошибка синтаксиса", + "line": "строка" } } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 4646158..8e53e8a 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import { page } from '$app/state'; import { goto } from '$app/navigation'; import { onMount } from 'svelte'; + import { fade } from 'svelte/transition'; import { api } from '$lib/api'; import { getAuth, loadUser, logout } from '$lib/auth.svelte'; import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n'; @@ -196,9 +197,11 @@
-
- {@render children()} -
+ {#key page.url.pathname} +
+ {@render children()} +
+ {/key}
{:else} diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index c2cf0b3..cf77767 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -24,21 +24,28 @@ let confirmDelete = $state(null); let slotPreview = $state>({}); let slotErrors = $state>({}); + let slotErrorLines = $state>({}); let validateTimers: Record> = {}; function validateSlot(slotKey: string, template: string) { // Clear previous timer if (validateTimers[slotKey]) clearTimeout(validateTimers[slotKey]); - if (!template) { slotErrors = { ...slotErrors, [slotKey]: '' }; return; } + if (!template) { + slotErrors = { ...slotErrors, [slotKey]: '' }; + slotErrorLines = { ...slotErrorLines, [slotKey]: null }; + return; + } // Debounce 800ms validateTimers[slotKey] = setTimeout(async () => { try { const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template }) }); slotErrors = { ...slotErrors, [slotKey]: res.error || '' }; + slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null }; } catch { // Network error, don't show as template error slotErrors = { ...slotErrors, [slotKey]: '' }; + slotErrorLines = { ...slotErrorLines, [slotKey]: null }; } }, 800); } @@ -175,14 +182,14 @@ {/if} {#if varsRef[slot.key]} + class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')} {/if} {#if (slot.rows || 2) > 2} - { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 6} /> + { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 6} errorLine={slotErrorLines[slot.key] || null} /> {#if slotErrors[slot.key]} -

Syntax error: {slotErrors[slot.key]}

+

{t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}

{/if} {#if slotPreview[slot.key]}
@@ -239,25 +246,25 @@ onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> - showVarsFor = null}> + showVarsFor = null}> {#if showVarsFor && varsRef[showVarsFor]} -

{varsRef[showVarsFor].description}

+

{t(`templateVars.${showVarsFor}.description`, varsRef[showVarsFor].description)}

-

Variables:

+

{t('templateConfig.variables')}:

{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
{'{{ ' + name + ' }}'} - {desc} + {t(`templateVars.${name}`, desc as string)}
{/each}
{#if varsRef[showVarsFor].asset_fields}
-

Asset fields (in {'{'}% for asset in added_assets %{'}'}):

+

{t('templateConfig.assetFields')}:

{#each Object.entries(varsRef[showVarsFor].asset_fields || {}) as [name, desc]}
{'{{ asset.' + name + ' }}'} - {desc} + {t(`templateVars.asset_${name}`, desc as string)}
{/each}
diff --git a/packages/server/src/immich_watcher_server/api/template_configs.py b/packages/server/src/immich_watcher_server/api/template_configs.py index 11c2791..c76656b 100644 --- a/packages/server/src/immich_watcher_server/api/template_configs.py +++ b/packages/server/src/immich_watcher_server/api/template_configs.py @@ -6,6 +6,7 @@ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession from jinja2.sandbox import SandboxedEnvironment +from jinja2 import TemplateSyntaxError, UndefinedError from ..auth.dependencies import get_current_user from ..database.engine import get_session @@ -172,8 +173,16 @@ async def preview_raw( tmpl = env.from_string(body.template) rendered = tmpl.render(**_SAMPLE_CONTEXT) return {"rendered": rendered} + except TemplateSyntaxError as e: + return { + "rendered": None, + "error": e.message, + "error_line": e.lineno, + } + except UndefinedError as e: + return {"rendered": None, "error": str(e), "error_line": None} except Exception as e: - return {"rendered": None, "error": str(e)} + return {"rendered": None, "error": str(e), "error_line": None} def _response(c: TemplateConfig) -> dict: