From afb8be8101c8dd3492ad68dbd1c5ca2f9ee117c2 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 19 Mar 2026 20:49:58 +0300 Subject: [PATCH] Jinja2 syntax validation with debounced API check Two-pass validation in preview-raw endpoint: 1. Syntax check (catches {% if %}, unclosed tags) 2. StrictUndefined render (catches {{ asset.a }}, {{ bad_var }}) Frontend shows: - Red error for syntax errors with line number - Amber warning for undefined variables - Error line highlighted in editor Sample context now uses proper structured data (lists of dicts for assets/albums) so valid field access like {{ asset.filename }} renders correctly during preview. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/lib/i18n/en.json | 1 + frontend/src/lib/i18n/ru.json | 1 + .../src/routes/template-configs/+page.svelte | 10 ++- .../api/template_configs.py | 75 ++++++++++++++----- 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index e5fe806..8625244 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -360,6 +360,7 @@ "expand": "Expand", "collapse": "Collapse", "syntaxError": "Syntax error", + "undefinedVar": "Unknown variable", "line": "line" } } diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index b088ca2..cbf6425 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -360,6 +360,7 @@ "expand": "Развернуть", "collapse": "Свернуть", "syntaxError": "Ошибка синтаксиса", + "undefinedVar": "Неизвестная переменная", "line": "строка" } } diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index cf77767..3809725 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -25,6 +25,7 @@ let slotPreview = $state>({}); let slotErrors = $state>({}); let slotErrorLines = $state>({}); + let slotErrorTypes = $state>({}); let validateTimers: Record> = {}; function validateSlot(slotKey: string, template: string) { @@ -33,6 +34,7 @@ if (!template) { slotErrors = { ...slotErrors, [slotKey]: '' }; slotErrorLines = { ...slotErrorLines, [slotKey]: null }; + slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' }; return; } @@ -42,10 +44,12 @@ 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 }; + slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' }; } catch { // Network error, don't show as template error slotErrors = { ...slotErrors, [slotKey]: '' }; slotErrorLines = { ...slotErrorLines, [slotKey]: null }; + slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' }; } }, 800); } @@ -189,7 +193,11 @@ {#if (slot.rows || 2) > 2} { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 6} errorLine={slotErrorLines[slot.key] || null} /> {#if slotErrors[slot.key]} -

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

+ {#if slotErrorTypes[slot.key] === 'undefined'} +

⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}

+ {:else} +

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

+ {/if} {/if} {#if slotPreview[slot.key]}
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 c76656b..84b5008 100644 --- a/packages/server/src/immich_watcher_server/api/template_configs.py +++ b/packages/server/src/immich_watcher_server/api/template_configs.py @@ -6,7 +6,7 @@ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession from jinja2.sandbox import SandboxedEnvironment -from jinja2 import TemplateSyntaxError, UndefinedError +from jinja2 import TemplateSyntaxError, UndefinedError, StrictUndefined from ..auth.dependencies import get_current_user from ..database.engine import get_session @@ -14,26 +14,51 @@ from ..database.models import TemplateConfig, User router = APIRouter(prefix="/api/template-configs", tags=["template-configs"]) -# Sample data for template preview +# Sample asset for template preview +_SAMPLE_ASSET = { + "filename": "IMG_001.jpg", + "type": "IMAGE", + "created_at": "2026-03-19T10:30:00", + "owner": "Alice", + "description": "Family picnic", + "url": "https://immich.example.com/photos/abc123", + "download_url": "https://immich.example.com/api/assets/abc123/original", + "photo_url": "https://immich.example.com/api/assets/abc123/thumbnail", + "playback_url": "", + "is_favorite": True, + "rating": 5, + "city": "Paris", + "state": "Île-de-France", + "country": "France", +} + +_SAMPLE_ALBUM = { + "name": "Family Photos", + "url": "https://immich.example.com/share/abc123", + "asset_count": 42, + "shared": True, +} + +# Full context covering all possible template variables _SAMPLE_CONTEXT = { + # Event variables "album_name": "Family Photos", "album_url": "https://immich.example.com/share/abc123", - "album_created": "01.01.2024", - "album_updated": "19.03.2026", "added_count": 3, - "removed_count": 0, + "removed_count": 1, "change_type": "assets_added", - "people": "Alice, Bob", - "assets": "\n • 🖼️ IMG_001.jpg\n • 🖼️ IMG_002.jpg\n • 🎬 VID_003.mp4", - "common_date": " from 19.03.2026", - "common_location": " in Paris, France", - "video_warning": "", + "people": ["Alice", "Bob"], + "added_assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "VID_002.mp4", "type": "VIDEO", "is_favorite": False, "rating": None, "playback_url": "https://immich.example.com/api/assets/def456/video"}], + "removed_assets": ["asset-id-1", "asset-id-2"], + "shared": True, + "video_warning": "\n\n⚠️ Note: Videos may not be sent due to Telegram's 50 MB file size limit.", + # Rename variables "old_name": "Old Album", "new_name": "New Album", - "album_count": 5, - "albums": "\n • Family Photos: https://example.com/share/abc", - "asset_count": 10, - "more_count": 7, + # Scheduled/periodic variables + "albums": [_SAMPLE_ALBUM, {**_SAMPLE_ALBUM, "name": "Vacation 2025", "asset_count": 120}], + "assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "IMG_002.jpg", "city": "London", "country": "UK"}], + "date": "2026-03-19", } @@ -167,20 +192,32 @@ async def preview_raw( body: PreviewRequest, user: User = Depends(get_current_user), ): - """Render arbitrary Jinja2 template text with sample data. For live preview while editing.""" + """Render arbitrary Jinja2 template text with sample data. + + Two-pass validation: + 1. Parse with default Undefined (catches syntax errors) + 2. Render with StrictUndefined (catches unknown variables like {{ asset.a }}) + """ + # Pass 1: syntax check try: env = SandboxedEnvironment(autoescape=False) - tmpl = env.from_string(body.template) - rendered = tmpl.render(**_SAMPLE_CONTEXT) - return {"rendered": rendered} + env.from_string(body.template) except TemplateSyntaxError as e: return { "rendered": None, "error": e.message, "error_line": e.lineno, } + + # Pass 2: render with strict undefined to catch unknown variables + try: + strict_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined) + tmpl = strict_env.from_string(body.template) + rendered = tmpl.render(**_SAMPLE_CONTEXT) + return {"rendered": rendered} except UndefinedError as e: - return {"rendered": None, "error": str(e), "error_line": None} + # Still a valid template syntactically, but references unknown variable + return {"rendered": None, "error": str(e), "error_line": None, "error_type": "undefined"} except Exception as e: return {"rendered": None, "error": str(e), "error_line": None}