diff --git a/CLAUDE.md b/CLAUDE.md index 9a7a584..5634ef5 100644 --- a/CLAUDE.md +++ b/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. 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 fbb20d5..11c2791 100644 --- a/packages/server/src/immich_watcher_server/api/template_configs.py +++ b/packages/server/src/immich_watcher_server/api/template_configs.py @@ -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() diff --git a/packages/server/src/immich_watcher_server/database/models.py b/packages/server/src/immich_watcher_server/database/models.py index f21ed9d..a2e24f5 100644 --- a/packages/server/src/immich_watcher_server/database/models.py +++ b/packages/server/src/immich_watcher_server/database/models.py @@ -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 %}' + ), } diff --git a/packages/server/src/immich_watcher_server/main.py b/packages/server/src/immich_watcher_server/main.py index 9168594..88d8427 100644 --- a/packages/server/src/immich_watcher_server/main.py +++ b/packages/server/src/immich_watcher_server/main.py @@ -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() diff --git a/packages/server/src/immich_watcher_server/templates/en/album_deleted.jinja2 b/packages/server/src/immich_watcher_server/templates/en/album_deleted.jinja2 new file mode 100644 index 0000000..5c9c0e4 --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/en/album_deleted.jinja2 @@ -0,0 +1 @@ +🗑️ Album "{{ album_name }}" was deleted. diff --git a/packages/server/src/immich_watcher_server/templates/en/album_renamed.jinja2 b/packages/server/src/immich_watcher_server/templates/en/album_renamed.jinja2 new file mode 100644 index 0000000..b6209fc --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/en/album_renamed.jinja2 @@ -0,0 +1 @@ +✏️ Album "{{ old_name }}" renamed to "{{ new_name }}". diff --git a/packages/server/src/immich_watcher_server/templates/en/assets_added.jinja2 b/packages/server/src/immich_watcher_server/templates/en/assets_added.jinja2 new file mode 100644 index 0000000..c3ef232 --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/en/assets_added.jinja2 @@ -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 }} diff --git a/packages/server/src/immich_watcher_server/templates/en/assets_removed.jinja2 b/packages/server/src/immich_watcher_server/templates/en/assets_removed.jinja2 new file mode 100644 index 0000000..dcd9587 --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/en/assets_removed.jinja2 @@ -0,0 +1 @@ +🗑️ {{ removed_count }} photo(s) removed from album "{{ album_name }}". diff --git a/packages/server/src/immich_watcher_server/templates/en/memory_mode.jinja2 b/packages/server/src/immich_watcher_server/templates/en/memory_mode.jinja2 new file mode 100644 index 0000000..62d5984 --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/en/memory_mode.jinja2 @@ -0,0 +1,4 @@ +📅 On this day: +{%- for asset in assets %} + • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }}) +{%- endfor %} diff --git a/packages/server/src/immich_watcher_server/templates/en/periodic_summary.jinja2 b/packages/server/src/immich_watcher_server/templates/en/periodic_summary.jinja2 new file mode 100644 index 0000000..2752d01 --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/en/periodic_summary.jinja2 @@ -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 %} diff --git a/packages/server/src/immich_watcher_server/templates/en/scheduled_assets.jinja2 b/packages/server/src/immich_watcher_server/templates/en/scheduled_assets.jinja2 new file mode 100644 index 0000000..4008258 --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/en/scheduled_assets.jinja2 @@ -0,0 +1,4 @@ +📸 Photos from "{{ album_name }}": +{%- for asset in assets %} + • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }} +{%- endfor %} diff --git a/packages/server/src/immich_watcher_server/templates/ru/album_deleted.jinja2 b/packages/server/src/immich_watcher_server/templates/ru/album_deleted.jinja2 new file mode 100644 index 0000000..38897db --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/ru/album_deleted.jinja2 @@ -0,0 +1 @@ +🗑️ Альбом "{{ album_name }}" был удалён. diff --git a/packages/server/src/immich_watcher_server/templates/ru/album_renamed.jinja2 b/packages/server/src/immich_watcher_server/templates/ru/album_renamed.jinja2 new file mode 100644 index 0000000..76c07f8 --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/ru/album_renamed.jinja2 @@ -0,0 +1 @@ +✏️ Альбом "{{ old_name }}" переименован в "{{ new_name }}". diff --git a/packages/server/src/immich_watcher_server/templates/ru/assets_added.jinja2 b/packages/server/src/immich_watcher_server/templates/ru/assets_added.jinja2 new file mode 100644 index 0000000..41b633b --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/ru/assets_added.jinja2 @@ -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 }} diff --git a/packages/server/src/immich_watcher_server/templates/ru/assets_removed.jinja2 b/packages/server/src/immich_watcher_server/templates/ru/assets_removed.jinja2 new file mode 100644 index 0000000..a72c378 --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/ru/assets_removed.jinja2 @@ -0,0 +1 @@ +🗑️ {{ removed_count }} фото удалено из альбома "{{ album_name }}". diff --git a/packages/server/src/immich_watcher_server/templates/ru/memory_mode.jinja2 b/packages/server/src/immich_watcher_server/templates/ru/memory_mode.jinja2 new file mode 100644 index 0000000..c1ecb71 --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/ru/memory_mode.jinja2 @@ -0,0 +1,4 @@ +📅 В этот день: +{%- for asset in assets %} + • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }}) +{%- endfor %} diff --git a/packages/server/src/immich_watcher_server/templates/ru/periodic_summary.jinja2 b/packages/server/src/immich_watcher_server/templates/ru/periodic_summary.jinja2 new file mode 100644 index 0000000..26a9410 --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/ru/periodic_summary.jinja2 @@ -0,0 +1,5 @@ +📋 Сводка альбомов ({{ albums | length }}): +{%- for album in albums %} + • {{ album.name }}: {{ album.asset_count }} файлов + {%- if album.url %} — {{ album.url }}{% endif %} +{%- endfor %} diff --git a/packages/server/src/immich_watcher_server/templates/ru/scheduled_assets.jinja2 b/packages/server/src/immich_watcher_server/templates/ru/scheduled_assets.jinja2 new file mode 100644 index 0000000..8665fb5 --- /dev/null +++ b/packages/server/src/immich_watcher_server/templates/ru/scheduled_assets.jinja2 @@ -0,0 +1,4 @@ +📸 Фото из "{{ album_name }}": +{%- for asset in assets %} + • {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }} +{%- endfor %}