Move default templates to .jinja2 files + add live preview + update CLAUDE.md
Some checks failed
Validate / Hassfest (push) Has been cancelled
Some checks failed
Validate / Hassfest (push) Has been cancelled
Templates:
- Default EN/RU templates moved from inline Python strings to
14 .jinja2 files in templates/{en,ru}/ directory
- Properly formatted with readable indentation and Jinja2
whitespace control ({%- -%})
- load_default_templates(locale) loads from files on first access
- Seed function uses file loader instead of inline dicts
Preview:
- New POST /api/template-configs/preview-raw endpoint: renders
arbitrary Jinja2 text with sample data (for live editing preview)
- Route ordering fixed: /variables before /{config_id}
CLAUDE.md:
- Added Frontend Architecture Notes (i18n, Svelte 5 runes, auth
flow, Tailwind v4 quirks)
- Added Backend Architecture Notes (SQLAlchemy+aiohttp greenlet
issue, SandboxedEnvironment import, system-owned entities,
FastAPI route ordering, __pycache__)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
17
CLAUDE.md
17
CLAUDE.md
@@ -45,3 +45,20 @@ The README is the primary user-facing documentation and must accurately reflect
|
||||
1. Kill existing process on port 5173
|
||||
2. Start: `cd frontend && npx vite dev --port 5173 --host &`
|
||||
3. Verify: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/`
|
||||
|
||||
## Frontend Architecture Notes
|
||||
|
||||
- **i18n**: Uses `$state` rune in `.svelte.ts` file (`lib/i18n/index.svelte.ts` or `index.ts` with auto-detect). Locale auto-detects from localStorage at module load time. `t()` is reactive via `$state`. `setLocale()` updates immediately without page reload.
|
||||
- **Svelte 5 runes**: `$state` only works in `.svelte` and `.svelte.ts` files. Regular `.ts` files cannot use runes -- use plain variables instead.
|
||||
- **Static adapter**: Frontend uses `@sveltejs/adapter-static` with SPA fallback. API calls proxied via Vite dev server config.
|
||||
- **Auth flow**: After login/setup, use `window.location.href = '/'` (hard redirect), NOT `goto('/')` (races with layout auth check).
|
||||
- **Tailwind CSS v4**: Uses `@theme` directive in `app.css` for CSS variables. Grid/flex classes work but `fixed`/`absolute` positioning requires inline styles in overlay components.
|
||||
|
||||
## Backend Architecture Notes
|
||||
|
||||
- **SQLAlchemy async + aiohttp**: Cannot nest `async with aiohttp.ClientSession()` inside a route that has an active SQLAlchemy async session -- greenlet context breaks. Eagerly load all DB data before entering aiohttp context, or use `check_tracker_with_session()` pattern.
|
||||
- **Jinja2 SandboxedEnvironment**: All template rendering MUST use `from jinja2.sandbox import SandboxedEnvironment` (not `jinja2.sandbox.SandboxedEnvironment` -- dotted access doesn't work).
|
||||
- **System-owned entities**: `user_id=0` means system-owned (e.g. default templates). Access checks must allow `user_id == 0` in `_get()` helpers.
|
||||
- **Default templates**: Stored as `.jinja2` files in `packages/server/src/immich_watcher_server/templates/{en,ru}/`. Loaded by `load_default_templates(locale)` and seeded to DB on first startup if no templates exist.
|
||||
- **FastAPI route ordering**: Static path routes (e.g. `/variables`) MUST be registered BEFORE parameterized routes (e.g. `/{config_id}`) to avoid path conflicts.
|
||||
- **`__pycache__`**: Add to `.gitignore`. Never commit.
|
||||
|
||||
@@ -157,6 +157,25 @@ async def preview_config(
|
||||
raise HTTPException(status_code=400, detail=f"Template error: {e}")
|
||||
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
template: str
|
||||
|
||||
|
||||
@router.post("/preview-raw")
|
||||
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."""
|
||||
try:
|
||||
env = SandboxedEnvironment(autoescape=False)
|
||||
tmpl = env.from_string(body.template)
|
||||
rendered = tmpl.render(**_SAMPLE_CONTEXT)
|
||||
return {"rendered": rendered}
|
||||
except Exception as e:
|
||||
return {"rendered": None, "error": str(e)}
|
||||
|
||||
|
||||
def _response(c: TemplateConfig) -> dict:
|
||||
return {k: getattr(c, k) for k in TemplateConfig.model_fields if k != "user_id"} | {
|
||||
"created_at": c.created_at.isoformat()
|
||||
|
||||
@@ -141,20 +141,70 @@ class TemplateConfig(SQLModel, table=True):
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
# --- Default template content (EN) ---
|
||||
# --- Default template loading from .jinja2 files ---
|
||||
|
||||
DEFAULT_TEMPLATE_EN = {
|
||||
"message_assets_added": """📷 {{ added_count }} new photo(s) added to album "{{ album_name }}".\
|
||||
{% if people %}
|
||||
👤 {{ people | join(", ") }}{% endif %}\
|
||||
{% if added_assets %}
|
||||
{% for asset in added_assets %}\
|
||||
• {% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }}\
|
||||
{% if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}\
|
||||
{% if asset.is_favorite %} ❤️{% endif %}
|
||||
{% endfor %}\
|
||||
{% endif %}\
|
||||
{{ video_warning }}""",
|
||||
from pathlib import Path as _Path
|
||||
|
||||
_TEMPLATES_DIR = _Path(__file__).parent.parent / "templates"
|
||||
|
||||
_SLOT_TO_FILE = {
|
||||
"message_assets_added": "assets_added.jinja2",
|
||||
"message_assets_removed": "assets_removed.jinja2",
|
||||
"message_album_renamed": "album_renamed.jinja2",
|
||||
"message_album_deleted": "album_deleted.jinja2",
|
||||
"periodic_summary_message": "periodic_summary.jinja2",
|
||||
"scheduled_assets_message": "scheduled_assets.jinja2",
|
||||
"memory_mode_message": "memory_mode.jinja2",
|
||||
}
|
||||
|
||||
|
||||
def load_default_templates(locale: str) -> dict[str, str]:
|
||||
"""Load default template files for a locale (en/ru)."""
|
||||
result = {}
|
||||
locale_dir = _TEMPLATES_DIR / locale
|
||||
for slot, filename in _SLOT_TO_FILE.items():
|
||||
path = locale_dir / filename
|
||||
if path.exists():
|
||||
result[slot] = path.read_text(encoding="utf-8").strip()
|
||||
else:
|
||||
result[slot] = ""
|
||||
return result
|
||||
|
||||
|
||||
# Lazy-loaded defaults (loaded from files on first access)
|
||||
DEFAULT_TEMPLATE_EN: dict[str, str] = {}
|
||||
DEFAULT_TEMPLATE_RU: dict[str, str] = {}
|
||||
|
||||
|
||||
def get_default_templates(locale: str) -> dict[str, str]:
|
||||
"""Get default templates, loading from files on first call."""
|
||||
global DEFAULT_TEMPLATE_EN, DEFAULT_TEMPLATE_RU
|
||||
if locale == "ru":
|
||||
if not DEFAULT_TEMPLATE_RU:
|
||||
DEFAULT_TEMPLATE_RU = load_default_templates("ru")
|
||||
return DEFAULT_TEMPLATE_RU
|
||||
if not DEFAULT_TEMPLATE_EN:
|
||||
DEFAULT_TEMPLATE_EN = load_default_templates("en")
|
||||
return DEFAULT_TEMPLATE_EN
|
||||
|
||||
|
||||
_INLINE_TEMPLATES_REMOVED = {
|
||||
"message_assets_added": (
|
||||
'📷 {{ added_count }} new photo(s) added to album "{{ album_name }}".\n'
|
||||
'{%- if people %}\n'
|
||||
'👤 {{ people | join(", ") }}\n'
|
||||
'{%- endif %}'
|
||||
'{%- if added_assets %}\n'
|
||||
'{%- for asset in added_assets %}\n'
|
||||
' • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}'
|
||||
'{%- if asset.city %} 📍 {{ asset.city }}'
|
||||
'{%- if asset.country %}, {{ asset.country }}{% endif %}'
|
||||
'{%- endif %}'
|
||||
'{%- if asset.is_favorite %} ❤️{% endif %}\n'
|
||||
'{%- endfor %}'
|
||||
'{%- endif %}'
|
||||
'{{ video_warning }}'
|
||||
),
|
||||
|
||||
"message_assets_removed": '🗑️ {{ removed_count }} photo(s) removed from album "{{ album_name }}".',
|
||||
|
||||
@@ -162,36 +212,49 @@ DEFAULT_TEMPLATE_EN = {
|
||||
|
||||
"message_album_deleted": '🗑️ Album "{{ album_name }}" was deleted.',
|
||||
|
||||
"periodic_summary_message": """📋 Tracked Albums Summary ({{ albums | length }} albums):\
|
||||
{% for album in albums %}
|
||||
• {{ album.name }}: {{ album.asset_count }} assets{% if album.url %} — {{ album.url }}{% endif %}\
|
||||
{% endfor %}""",
|
||||
"periodic_summary_message": (
|
||||
'📋 Tracked Albums Summary ({{ albums | length }} albums):\n'
|
||||
'{%- for album in albums %}\n'
|
||||
' • {{ album.name }}: {{ album.asset_count }} assets'
|
||||
'{%- if album.url %} — {{ album.url }}{% endif %}\n'
|
||||
'{%- endfor %}'
|
||||
),
|
||||
|
||||
"scheduled_assets_message": """📸 Photos from "{{ album_name }}":\
|
||||
{% for asset in assets %}
|
||||
• {% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }}\
|
||||
{% endfor %}""",
|
||||
"scheduled_assets_message": (
|
||||
'📸 Photos from "{{ album_name }}":\n'
|
||||
'{%- for asset in assets %}\n'
|
||||
' • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}\n'
|
||||
'{%- endfor %}'
|
||||
),
|
||||
|
||||
"memory_mode_message": """📅 On this day:\
|
||||
{% for asset in assets %}
|
||||
• {% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})\
|
||||
{% endfor %}""",
|
||||
"memory_mode_message": (
|
||||
'📅 On this day:\n'
|
||||
'{%- for asset in assets %}\n'
|
||||
' • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}'
|
||||
' ({{ asset.created_at[:4] }})\n'
|
||||
'{%- endfor %}'
|
||||
),
|
||||
}
|
||||
|
||||
# --- Default template content (RU) ---
|
||||
|
||||
DEFAULT_TEMPLATE_RU = {
|
||||
"message_assets_added": """📷 {{ added_count }} новых фото добавлено в альбом "{{ album_name }}".\
|
||||
{% if people %}
|
||||
👤 {{ people | join(", ") }}{% endif %}\
|
||||
{% if added_assets %}
|
||||
{% for asset in added_assets %}\
|
||||
• {% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }}\
|
||||
{% if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}\
|
||||
{% if asset.is_favorite %} ❤️{% endif %}
|
||||
{% endfor %}\
|
||||
{% endif %}\
|
||||
{{ video_warning }}""",
|
||||
"message_assets_added": (
|
||||
'📷 {{ added_count }} новых фото добавлено в альбом "{{ album_name }}".\n'
|
||||
'{%- if people %}\n'
|
||||
'👤 {{ people | join(", ") }}\n'
|
||||
'{%- endif %}'
|
||||
'{%- if added_assets %}\n'
|
||||
'{%- for asset in added_assets %}\n'
|
||||
' • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}'
|
||||
'{%- if asset.city %} 📍 {{ asset.city }}'
|
||||
'{%- if asset.country %}, {{ asset.country }}{% endif %}'
|
||||
'{%- endif %}'
|
||||
'{%- if asset.is_favorite %} ❤️{% endif %}\n'
|
||||
'{%- endfor %}'
|
||||
'{%- endif %}'
|
||||
'{{ video_warning }}'
|
||||
),
|
||||
|
||||
"message_assets_removed": '🗑️ {{ removed_count }} фото удалено из альбома "{{ album_name }}".',
|
||||
|
||||
@@ -199,20 +262,28 @@ DEFAULT_TEMPLATE_RU = {
|
||||
|
||||
"message_album_deleted": '🗑️ Альбом "{{ album_name }}" был удалён.',
|
||||
|
||||
"periodic_summary_message": """📋 Сводка альбомов ({{ albums | length }}):\
|
||||
{% for album in albums %}
|
||||
• {{ album.name }}: {{ album.asset_count }} файлов{% if album.url %} — {{ album.url }}{% endif %}\
|
||||
{% endfor %}""",
|
||||
"periodic_summary_message": (
|
||||
'📋 Сводка альбомов ({{ albums | length }}):\n'
|
||||
'{%- for album in albums %}\n'
|
||||
' • {{ album.name }}: {{ album.asset_count }} файлов'
|
||||
'{%- if album.url %} — {{ album.url }}{% endif %}\n'
|
||||
'{%- endfor %}'
|
||||
),
|
||||
|
||||
"scheduled_assets_message": """📸 Фото из "{{ album_name }}":\
|
||||
{% for asset in assets %}
|
||||
• {% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }}\
|
||||
{% endfor %}""",
|
||||
"scheduled_assets_message": (
|
||||
'📸 Фото из "{{ album_name }}":\n'
|
||||
'{%- for asset in assets %}\n'
|
||||
' • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}\n'
|
||||
'{%- endfor %}'
|
||||
),
|
||||
|
||||
"memory_mode_message": """📅 В этот день:\
|
||||
{% for asset in assets %}
|
||||
• {% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})\
|
||||
{% endfor %}""",
|
||||
"memory_mode_message": (
|
||||
'📅 В этот день:\n'
|
||||
'{%- for asset in assets %}\n'
|
||||
' • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}'
|
||||
' ({{ asset.created_at[:4] }})\n'
|
||||
'{%- endfor %}'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -38,11 +38,7 @@ async def _seed_default_templates():
|
||||
from sqlmodel import func, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from .database.engine import get_engine
|
||||
from .database.models import (
|
||||
TemplateConfig,
|
||||
DEFAULT_TEMPLATE_EN,
|
||||
DEFAULT_TEMPLATE_RU,
|
||||
)
|
||||
from .database.models import TemplateConfig, get_default_templates
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
@@ -52,8 +48,8 @@ async def _seed_default_templates():
|
||||
return
|
||||
|
||||
# user_id=0 means system-owned (available to all users)
|
||||
en = TemplateConfig(user_id=0, name="Default EN", icon="mdiTranslate", **DEFAULT_TEMPLATE_EN)
|
||||
ru = TemplateConfig(user_id=0, name="По умолчанию RU", icon="mdiTranslate", **DEFAULT_TEMPLATE_RU)
|
||||
en = TemplateConfig(user_id=0, name="Default EN", icon="mdiTranslate", **get_default_templates("en"))
|
||||
ru = TemplateConfig(user_id=0, name="По умолчанию RU", icon="mdiTranslate", **get_default_templates("ru"))
|
||||
session.add(en)
|
||||
session.add(ru)
|
||||
await session.commit()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
🗑️ Album "{{ album_name }}" was deleted.
|
||||
@@ -0,0 +1 @@
|
||||
✏️ Album "{{ old_name }}" renamed to "{{ new_name }}".
|
||||
@@ -0,0 +1,12 @@
|
||||
📷 {{ added_count }} new photo(s) added to album "{{ album_name }}".
|
||||
{%- if people %}
|
||||
👤 {{ people | join(", ") }}
|
||||
{%- endif %}
|
||||
{%- if added_assets %}
|
||||
{%- for asset in added_assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{{ video_warning }}
|
||||
@@ -0,0 +1 @@
|
||||
🗑️ {{ removed_count }} photo(s) removed from album "{{ album_name }}".
|
||||
@@ -0,0 +1,4 @@
|
||||
📅 On this day:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})
|
||||
{%- endfor %}
|
||||
@@ -0,0 +1,5 @@
|
||||
📋 Tracked Albums Summary ({{ albums | length }} albums):
|
||||
{%- for album in albums %}
|
||||
• {{ album.name }}: {{ album.asset_count }} assets
|
||||
{%- if album.url %} — {{ album.url }}{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -0,0 +1,4 @@
|
||||
📸 Photos from "{{ album_name }}":
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
|
||||
{%- endfor %}
|
||||
@@ -0,0 +1 @@
|
||||
🗑️ Альбом "{{ album_name }}" был удалён.
|
||||
@@ -0,0 +1 @@
|
||||
✏️ Альбом "{{ old_name }}" переименован в "{{ new_name }}".
|
||||
@@ -0,0 +1,12 @@
|
||||
📷 {{ added_count }} новых фото добавлено в альбом "{{ album_name }}".
|
||||
{%- if people %}
|
||||
👤 {{ people | join(", ") }}
|
||||
{%- endif %}
|
||||
{%- if added_assets %}
|
||||
{%- for asset in added_assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{{ video_warning }}
|
||||
@@ -0,0 +1 @@
|
||||
🗑️ {{ removed_count }} фото удалено из альбома "{{ album_name }}".
|
||||
@@ -0,0 +1,4 @@
|
||||
📅 В этот день:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})
|
||||
{%- endfor %}
|
||||
@@ -0,0 +1,5 @@
|
||||
📋 Сводка альбомов ({{ albums | length }}):
|
||||
{%- for album in albums %}
|
||||
• {{ album.name }}: {{ album.asset_count }} файлов
|
||||
{%- if album.url %} — {{ album.url }}{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -0,0 +1,4 @@
|
||||
📸 Фото из "{{ album_name }}":
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
|
||||
{%- endfor %}
|
||||
Reference in New Issue
Block a user