Jinja2 syntax highlighting + description field + preview toggle
All checks were successful
Validate / Hassfest (push) Successful in 32s
All checks were successful
Validate / Hassfest (push) Successful in 32s
- Error line highlighting in JinjaEditor (red background on error line)
- Backend returns error_line from TemplateSyntaxError
- Localized syntax error messages with line number
- Renamed {{ }} button to "Variables" (localized)
- Localized all template variable descriptions (EN/RU)
- Added t() fallback parameter for graceful degradation
- Page transition animation (fade) to prevent content stacking
- Added syntaxError/line i18n keys
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { EditorView, placeholder as cmPlaceholder } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorView, Decoration, placeholder as cmPlaceholder, type DecorationSet } from '@codemirror/view';
|
||||
import { EditorState, StateField, StateEffect } from '@codemirror/state';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { getTheme } from '$lib/theme.svelte';
|
||||
|
||||
let { value = '', onchange, rows = 6, placeholder = '' } = $props<{
|
||||
let { value = '', onchange, rows = 6, placeholder = '', errorLine = null } = $props<{
|
||||
value: string;
|
||||
onchange: (val: string) => void;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
errorLine?: number | null;
|
||||
}>();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let view: EditorView;
|
||||
const theme = getTheme();
|
||||
|
||||
// Error line highlight effect and field
|
||||
const setErrorLine = StateEffect.define<number | null>();
|
||||
const errorLineField = StateField.define<DecorationSet>({
|
||||
create() { return Decoration.none; },
|
||||
update(decorations, tr) {
|
||||
for (const e of tr.effects) {
|
||||
if (e.is(setErrorLine)) {
|
||||
if (e.value === null) return Decoration.none;
|
||||
const lineNum = e.value;
|
||||
if (lineNum < 1 || lineNum > tr.state.doc.lines) return Decoration.none;
|
||||
const line = tr.state.doc.line(lineNum);
|
||||
return Decoration.set([
|
||||
Decoration.line({ class: 'cm-error-line' }).range(line.from),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return decorations;
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f),
|
||||
});
|
||||
|
||||
// Simple Jinja2 stream parser for syntax highlighting
|
||||
const jinjaLang = StreamLanguage.define({
|
||||
token(stream) {
|
||||
@@ -52,6 +74,7 @@
|
||||
onMount(() => {
|
||||
const extensions = [
|
||||
jinjaLang,
|
||||
errorLineField,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onchange(update.state.doc.toString());
|
||||
@@ -63,6 +86,7 @@
|
||||
'.cm-content': { minHeight: `${rows * 1.5}em`, padding: '8px' },
|
||||
'.cm-editor': { borderRadius: '0.375rem', border: '1px solid var(--color-border)' },
|
||||
'.cm-focused': { outline: '2px solid var(--color-primary)', outlineOffset: '0px' },
|
||||
'.cm-error-line': { backgroundColor: 'rgba(239, 68, 68, 0.2)', outline: '1px solid rgba(239, 68, 68, 0.4)' },
|
||||
// Jinja2 syntax colors
|
||||
'.ͼc': { color: '#e879f9' }, // keyword ({% %}) - purple
|
||||
'.ͼd': { color: '#38bdf8' }, // variableName ({{ }}) - blue
|
||||
@@ -93,6 +117,12 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (view) {
|
||||
view.dispatch({ effects: setErrorLine.of(errorLine ?? null) });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container}></div>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "строка"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 overflow-auto pb-16 md:pb-0">
|
||||
<div class="max-w-5xl mx-auto p-4 md:p-6">
|
||||
{#key page.url.pathname}
|
||||
<div class="max-w-5xl mx-auto p-4 md:p-6" in:fade={{ duration: 150, delay: 50 }}>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/key}
|
||||
</main>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -24,21 +24,28 @@
|
||||
let confirmDelete = $state<any>(null);
|
||||
let slotPreview = $state<Record<string, string>>({});
|
||||
let slotErrors = $state<Record<string, string>>({});
|
||||
let slotErrorLines = $state<Record<string, number | null>>({});
|
||||
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
|
||||
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]}
|
||||
<button type="button" onclick={() => showVarsFor = slot.key}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{'{{ }}'}</button>
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if (slot.rows || 2) > 2}
|
||||
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 6} />
|
||||
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 6} errorLine={slotErrorLines[slot.key] || null} />
|
||||
{#if slotErrors[slot.key]}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">Syntax error: {slotErrors[slot.key]}</p>
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
|
||||
{/if}
|
||||
{#if slotPreview[slot.key]}
|
||||
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
|
||||
@@ -239,25 +246,25 @@
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<!-- Variables reference modal -->
|
||||
<Modal open={showVarsFor !== null} title="Template Variables: {showVarsFor}" onclose={() => showVarsFor = null}>
|
||||
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
|
||||
{#if showVarsFor && varsRef[showVarsFor]}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{varsRef[showVarsFor].description}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{t(`templateVars.${showVarsFor}.description`, varsRef[showVarsFor].description)}</p>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium mb-1">Variables:</p>
|
||||
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
|
||||
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.${name}`, desc as string)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if varsRef[showVarsFor].asset_fields}
|
||||
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
|
||||
<p class="text-xs font-medium mb-1">Asset fields (in {'{'}% for asset in added_assets %{'}'}):</p>
|
||||
<p class="text-xs font-medium mb-1">{t('templateConfig.assetFields')}:</p>
|
||||
{#each Object.entries(varsRef[showVarsFor].asset_fields || {}) as [name, desc]}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ asset.' + name + ' }}'}</code>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.asset_${name}`, desc as string)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user