Simplify templates to pure Jinja2 + CodeMirror editor + variable reference
Some checks failed
Validate / Hassfest (push) Has been cancelled

Major template system overhaul:
- TemplateConfig simplified from 21 fields to 9: removed all sub-templates
  (asset_image, asset_video, assets_format, people_format, etc.)
  Users write full Jinja2 with {% for %}, {% if %} inline.
- Default EN/RU templates seeded on first startup (user_id=0, system-owned)
  with proper Jinja2 loops over added_assets, people, albums.
- build_full_context() simplified: passes raw data directly to Jinja2
  instead of pre-rendering sub-templates.
- CodeMirror editor for template slots (HTML syntax highlighting,
  line wrapping, dark theme support via oneDark).
- Variable reference API: GET /api/template-configs/variables returns
  per-slot variable descriptions + asset_fields for loop contexts.
- Variable reference modal in UI: click "{{ }} Variables" next to any
  slot to see available variables with Jinja2 syntax examples.
- Route ordering fix: /variables registered before /{config_id}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:57:51 +03:00
parent bc8fda5984
commit 0bb4d8a949
9 changed files with 791 additions and 181 deletions

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { onMount } from 'svelte';
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { html } from '@codemirror/lang-html';
import { oneDark } from '@codemirror/theme-one-dark';
import { getTheme } from '$lib/theme.svelte';
let { value = '', onchange, rows = 6, placeholder = '' } = $props<{
value: string;
onchange: (val: string) => void;
rows?: number;
placeholder?: string;
}>();
let container: HTMLDivElement;
let view: EditorView;
const theme = getTheme();
onMount(() => {
const extensions = [
html(), // Jinja2 is close enough to HTML template syntax for highlighting
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onchange(update.state.doc.toString());
}
}),
EditorView.lineWrapping,
EditorView.theme({
'&': { fontSize: '13px', fontFamily: 'monospace' },
'.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' },
}),
];
if (theme.isDark) {
extensions.push(oneDark);
}
if (placeholder) {
extensions.push(cmPlaceholder(placeholder));
}
view = new EditorView({
state: EditorState.create({ doc: value, extensions }),
parent: container,
});
return () => view.destroy();
});
// Sync external value changes (e.g. when editing different config)
$effect(() => {
if (view && view.state.doc.toString() !== value) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
}
});
</script>
<div bind:this={container}></div>

View File

@@ -10,9 +10,13 @@
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import Hint from '$lib/components/Hint.svelte';
import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
let configs = $state<any[]>([]);
let loaded = $state(false);
let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
@@ -23,68 +27,44 @@
const defaultForm = () => ({
name: '', icon: '',
message_assets_added: '📷 {added_count} new photo(s) added to album "{album_name}"{common_date}{common_location}.{people}{assets}{video_warning}',
message_assets_removed: '🗑️ {removed_count} photo(s) removed from album "{album_name}".',
message_album_renamed: '✏️ Album "{old_name}" renamed to "{new_name}".',
message_album_deleted: '🗑️ Album "{album_name}" was deleted.',
message_asset_image: '\n • 🖼️ {filename}',
message_asset_video: '\n • 🎬 {filename}',
message_assets_format: '\nAssets:{assets}',
message_assets_more: '\n • ...and {more_count} more',
message_people_format: ' People: {people}.',
message_assets_added: '',
message_assets_removed: '',
message_album_renamed: '',
message_album_deleted: '',
periodic_summary_message: '',
scheduled_assets_message: '',
memory_mode_message: '',
date_format: '%d.%m.%Y, %H:%M UTC',
common_date_template: ' from {date}',
date_if_unique_template: ' ({date})',
location_format: '{city}, {country}',
common_location_template: ' in {location}',
location_if_unique_template: ' 📍 {location}',
favorite_indicator: '❤️',
periodic_summary_message: '📋 Tracked Albums Summary ({album_count} albums):{albums}',
periodic_album_template: '\n • {album_name}: {album_url}',
scheduled_assets_message: '📸 Here are some photos from album "{album_name}":{assets}',
memory_mode_message: '📅 On this day:{assets}',
video_warning: '\n\n⚠ Note: Videos may not be sent due to Telegram\'s 50 MB file size limit.',
});
let form = $state(defaultForm());
const templateSlots = [
{ group: 'eventMessages', slots: [
{ key: 'message_assets_added', label: 'assetsAdded' },
{ key: 'message_assets_removed', label: 'assetsRemoved' },
{ key: 'message_album_renamed', label: 'albumRenamed' },
{ key: 'message_album_deleted', label: 'albumDeleted' },
]},
{ group: 'assetFormatting', slots: [
{ key: 'message_asset_image', label: 'imageTemplate' },
{ key: 'message_asset_video', label: 'videoTemplate' },
{ key: 'message_assets_format', label: 'assetsWrapper' },
{ key: 'message_assets_more', label: 'moreMessage' },
{ key: 'message_people_format', label: 'peopleFormat' },
]},
{ group: 'dateLocation', slots: [
{ key: 'date_format', label: 'dateFormat' },
{ key: 'common_date_template', label: 'commonDate' },
{ key: 'date_if_unique_template', label: 'uniqueDate' },
{ key: 'location_format', label: 'locationFormat' },
{ key: 'common_location_template', label: 'commonLocation' },
{ key: 'location_if_unique_template', label: 'uniqueLocation' },
{ key: 'favorite_indicator', label: 'favoriteIndicator' },
{ key: 'message_assets_added', label: 'assetsAdded', rows: 10 },
{ key: 'message_assets_removed', label: 'assetsRemoved', rows: 3 },
{ key: 'message_album_renamed', label: 'albumRenamed', rows: 2 },
{ key: 'message_album_deleted', label: 'albumDeleted', rows: 2 },
]},
{ group: 'scheduledMessages', slots: [
{ key: 'periodic_summary_message', label: 'periodicSummary' },
{ key: 'periodic_album_template', label: 'periodicAlbum' },
{ key: 'scheduled_assets_message', label: 'scheduledAssets' },
{ key: 'memory_mode_message', label: 'memoryMode' },
{ key: 'periodic_summary_message', label: 'periodicSummary', rows: 6 },
{ key: 'scheduled_assets_message', label: 'scheduledAssets', rows: 6 },
{ key: 'memory_mode_message', label: 'memoryMode', rows: 6 },
]},
{ group: 'telegramSettings', slots: [
{ key: 'video_warning', label: 'videoWarning' },
{ key: 'date_format', label: 'dateFormat', rows: 1 },
{ key: 'video_warning', label: 'videoWarning', rows: 2 },
]},
];
onMount(load);
async function load() {
try { configs = await api('/template-configs'); }
catch (err: any) { error = err.message || t('common.loadError'); }
try {
[configs, varsRef] = await Promise.all([
api('/template-configs'),
api('/template-configs/variables'),
]);
} catch (err: any) { error = err.message || t('common.loadError'); }
finally { loaded = true; }
}
@@ -149,9 +129,19 @@
<div class="space-y-3 mt-2">
{#each group.slots as slot}
<div>
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t(`templateConfig.${slot.label}`)}</label>
<textarea bind:value={(form as any)[slot.key]} rows={slot.key.includes('message_asset_') || slot.key.includes('_template') || slot.key === 'favorite_indicator' || slot.key === 'date_format' || slot.key === 'location_format' ? 1 : 2}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono"></textarea>
<div class="flex items-center justify-between mb-1">
<label class="text-xs text-[var(--color-muted-foreground)]">{t(`templateConfig.${slot.label}`)}</label>
{#if varsRef[slot.key]}
<button type="button" onclick={() => showVarsFor = slot.key}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{{ }} Variables</button>
{/if}
</div>
{#if (slot.rows || 2) > 2}
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v) => (form as any)[slot.key] = v} rows={slot.rows || 6} />
{:else}
<input bind:value={(form as any)[slot.key]}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono" />
{/if}
</div>
{/each}
</div>
@@ -201,3 +191,30 @@
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="Template Variables: {showVarsFor}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]}
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{varsRef[showVarsFor].description}</p>
<div class="space-y-1">
<p class="text-xs font-medium mb-1">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>
</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>
{#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>
</div>
{/each}
</div>
{/if}
{/if}
</Modal>