From 0bb4d8a949e67d4e88c4d66ed023592d0d03a975 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 19 Mar 2026 18:57:51 +0300 Subject: [PATCH] Simplify templates to pure Jinja2 + CodeMirror editor + variable reference 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) --- frontend/package-lock.json | 431 +++++++++++++++++- frontend/package.json | 7 +- .../src/lib/components/JinjaEditor.svelte | 63 +++ .../src/routes/template-configs/+page.svelte | 115 +++-- .../api/template_configs.py | 12 +- .../api/template_vars.py | 87 ++++ .../immich_watcher_server/database/models.py | 131 ++++-- .../server/src/immich_watcher_server/main.py | 30 ++ .../services/notifier.py | 96 +--- 9 files changed, 791 insertions(+), 181 deletions(-) create mode 100644 frontend/src/lib/components/JinjaEditor.svelte create mode 100644 packages/server/src/immich_watcher_server/api/template_vars.py diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 17c265f..ce5850d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,12 @@ "name": "frontend", "version": "0.0.1", "dependencies": { - "@mdi/js": "^7.4.47" + "@codemirror/lang-html": "^6.4.11", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.40.0", + "@mdi/js": "^7.4.47", + "codemirror": "^6.0.2" }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.0", @@ -28,6 +33,133 @@ "vite": "^7.3.1" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz", + "integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.40.0.tgz", + "integrity": "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -524,6 +656,62 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==" + }, + "node_modules/@lezer/css": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz", + "integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==" + }, "node_modules/@mdi/js": { "version": "7.4.47", "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", @@ -1347,6 +1535,20 @@ "node": ">=6" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -1356,6 +1558,11 @@ "node": ">= 0.6" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -2025,6 +2232,11 @@ "node": ">=0.10.0" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==" + }, "node_modules/style-to-object": { "version": "1.0.14", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", @@ -2291,6 +2503,11 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", @@ -2299,6 +2516,133 @@ } }, "dependencies": { + "@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "requires": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "requires": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "requires": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "requires": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "requires": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "@codemirror/language": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz", + "integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==", + "requires": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "requires": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "requires": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "requires": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "requires": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "@codemirror/view": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.40.0.tgz", + "integrity": "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==", + "requires": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -2558,6 +2902,62 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@lezer/common": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==" + }, + "@lezer/css": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz", + "integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==", + "requires": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "requires": { + "@lezer/common": "^1.3.0" + } + }, + "@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "requires": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "requires": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "requires": { + "@lezer/common": "^1.0.0" + } + }, + "@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==" + }, "@mdi/js": { "version": "7.4.47", "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", @@ -3032,12 +3432,31 @@ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "dev": true }, + "codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "requires": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "dev": true }, + "crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, "deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -3425,6 +3844,11 @@ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true }, + "style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==" + }, "style-to-object": { "version": "1.0.14", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", @@ -3563,6 +3987,11 @@ "dev": true, "requires": {} }, + "w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1a27bf4..9cc3ae1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,11 @@ "vite": "^7.3.1" }, "dependencies": { - "@mdi/js": "^7.4.47" + "@codemirror/lang-html": "^6.4.11", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.40.0", + "@mdi/js": "^7.4.47", + "codemirror": "^6.0.2" } } diff --git a/frontend/src/lib/components/JinjaEditor.svelte b/frontend/src/lib/components/JinjaEditor.svelte new file mode 100644 index 0000000..0755b2f --- /dev/null +++ b/frontend/src/lib/components/JinjaEditor.svelte @@ -0,0 +1,63 @@ + + +
diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index 0adef8c..a19faba 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -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([]); let loaded = $state(false); + let varsRef = $state>({}); + let showVarsFor = $state(null); let showForm = $state(false); let editing = $state(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 @@
{#each group.slots as slot}
- - +
+ + {#if varsRef[slot.key]} + + {/if} +
+ {#if (slot.rows || 2) > 2} + (form as any)[slot.key] = v} rows={slot.rows || 6} /> + {:else} + + {/if}
{/each}
@@ -201,3 +191,30 @@ confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> + + + showVarsFor = null}> + {#if showVarsFor && varsRef[showVarsFor]} +

{varsRef[showVarsFor].description}

+
+

Variables:

+ {#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]} +
+ {'{{ ' + name + ' }}'} + {desc} +
+ {/each} +
+ {#if varsRef[showVarsFor].asset_fields} +
+

Asset fields (in {'{'}% for asset in added_assets %{'}'}):

+ {#each Object.entries(varsRef[showVarsFor].asset_fields || {}) as [name, desc]} +
+ {'{{ asset.' + name + ' }}'} + {desc} +
+ {/each} +
+ {/if} + {/if} +
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 7f8b1a1..8383f78 100644 --- a/packages/server/src/immich_watcher_server/api/template_configs.py +++ b/packages/server/src/immich_watcher_server/api/template_configs.py @@ -69,12 +69,22 @@ async def list_configs( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): + from sqlalchemy import or_ result = await session.exec( - select(TemplateConfig).where(TemplateConfig.user_id == user.id) + select(TemplateConfig).where( + or_(TemplateConfig.user_id == user.id, TemplateConfig.user_id == 0) + ) ) return [_response(c) for c in result.all()] +@router.get("/variables") +async def get_template_variables(): + """Get the variable reference for all template slots.""" + from .template_vars import TEMPLATE_VARIABLES + return TEMPLATE_VARIABLES + + @router.post("", status_code=status.HTTP_201_CREATED) async def create_config( body: TemplateConfigCreate, diff --git a/packages/server/src/immich_watcher_server/api/template_vars.py b/packages/server/src/immich_watcher_server/api/template_vars.py new file mode 100644 index 0000000..5d59580 --- /dev/null +++ b/packages/server/src/immich_watcher_server/api/template_vars.py @@ -0,0 +1,87 @@ +"""Template variable reference for all template slots.""" + +TEMPLATE_VARIABLES: dict[str, dict] = { + "message_assets_added": { + "description": "Notification when new assets are added to an album", + "variables": { + "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 (assets_added)", + "people": "List of detected people names (use {{ people | join(', ') }})", + "added_assets": "List of asset dicts (use {% for asset in added_assets %})", + "shared": "Whether album is shared (true/false)", + "video_warning": "Video size warning text (if videos present)", + }, + "asset_fields": { + "filename": "Original filename", + "type": "IMAGE or VIDEO", + "created_at": "Creation date/time (ISO 8601)", + "owner": "Owner display name", + "description": "User description or EXIF description", + "url": "Public viewer URL", + "download_url": "Direct download URL", + "photo_url": "Preview image URL (images only)", + "playback_url": "Video playback URL (videos only)", + "is_favorite": "Whether asset is favorited (boolean)", + "rating": "Star rating (1-5 or null)", + "city": "City name", + "state": "State/region name", + "country": "Country name", + "people": "People detected in this asset (list)", + }, + }, + "message_assets_removed": { + "description": "Notification when assets are removed", + "variables": { + "album_name": "Album name", + "album_url": "Public share URL", + "removed_count": "Number of assets removed", + "removed_assets": "List of removed asset IDs", + "change_type": "Type of change (assets_removed)", + }, + }, + "message_album_renamed": { + "description": "Notification when album is renamed", + "variables": { + "old_name": "Previous album name", + "new_name": "New album name", + "album_url": "Public share URL", + }, + }, + "message_album_deleted": { + "description": "Notification when album is deleted", + "variables": { + "album_name": "Album name", + }, + }, + "periodic_summary_message": { + "description": "Periodic album summary", + "variables": { + "albums": "List of album dicts (use {% for album in albums %})", + }, + "album_fields": { + "name": "Album name", + "asset_count": "Number of assets", + "url": "Public share URL", + }, + }, + "scheduled_assets_message": { + "description": "Scheduled asset delivery", + "variables": { + "album_name": "Album name (empty in combined mode)", + "album_url": "Public share URL", + "assets": "List of asset dicts (use {% for asset in assets %})", + }, + "asset_fields": "(same as message_assets_added.asset_fields)", + }, + "memory_mode_message": { + "description": "On This Day memory notification", + "variables": { + "album_name": "Album name (empty in combined mode)", + "assets": "List of asset dicts (use {% for asset in assets %})", + }, + "asset_fields": "(same as message_assets_added.asset_fields)", + }, +} diff --git a/packages/server/src/immich_watcher_server/database/models.py b/packages/server/src/immich_watcher_server/database/models.py index 5ca93ee..f21ed9d 100644 --- a/packages/server/src/immich_watcher_server/database/models.py +++ b/packages/server/src/immich_watcher_server/database/models.py @@ -108,7 +108,11 @@ class TrackingConfig(SQLModel, table=True): class TemplateConfig(SQLModel, table=True): - """Message template configuration: all template slots from the blueprint.""" + """Message template configuration with full Jinja2 templates. + + Each slot is a complete Jinja2 template with access to loops, conditionals, + and filters. No sub-templates needed -- use {% for %}, {% if %} inline. + """ __tablename__ = "template_config" @@ -117,49 +121,19 @@ class TemplateConfig(SQLModel, table=True): name: str # e.g. "Default EN", "Default RU" icon: str = Field(default="") - # Event messages - message_assets_added: str = Field( - default='📷 {added_count} new photo(s) added to album "{album_name}"{common_date}{common_location}.{people}{assets}{video_warning}' - ) - message_assets_removed: str = Field( - default='🗑️ {removed_count} photo(s) removed from album "{album_name}".' - ) - message_album_renamed: str = Field( - default='✏️ Album "{old_name}" renamed to "{new_name}".' - ) - message_album_deleted: str = Field( - default='🗑️ Album "{album_name}" was deleted.' - ) + # Event-driven notification templates (full Jinja2) + message_assets_added: str = Field(default="") + message_assets_removed: str = Field(default="") + message_album_renamed: str = Field(default="") + message_album_deleted: str = Field(default="") - # Asset item formatting - message_asset_image: str = Field(default="\n • 🖼️ {filename}") - message_asset_video: str = Field(default="\n • 🎬 {filename}") - message_assets_format: str = Field(default="\nAssets:{assets}") - message_assets_more: str = Field(default="\n • ...and {more_count} more") - message_people_format: str = Field(default=" People: {people}.") + # Scheduled notification templates (full Jinja2) + periodic_summary_message: str = Field(default="") + scheduled_assets_message: str = Field(default="") + memory_mode_message: str = Field(default="") - # Date/location formatting + # Settings date_format: str = Field(default="%d.%m.%Y, %H:%M UTC") - common_date_template: str = Field(default=" from {date}") - date_if_unique_template: str = Field(default=" ({date})") - location_format: str = Field(default="{city}, {country}") - common_location_template: str = Field(default=" in {location}") - location_if_unique_template: str = Field(default=" 📍 {location}") - favorite_indicator: str = Field(default="❤️") - - # Scheduled notification templates - periodic_summary_message: str = Field( - default="📋 Tracked Albums Summary ({album_count} albums):{albums}" - ) - periodic_album_template: str = Field( - default="\n • {album_name}: {album_url}" - ) - scheduled_assets_message: str = Field( - default='📸 Here are some photos from album "{album_name}":{assets}' - ) - memory_mode_message: str = Field(default="📅 On this day:{assets}") - - # Telegram-specific video_warning: str = Field( default="\n\n⚠️ Note: Videos may not be sent due to Telegram's 50 MB file size limit." ) @@ -167,6 +141,81 @@ class TemplateConfig(SQLModel, table=True): created_at: datetime = Field(default_factory=_utcnow) +# --- Default template content (EN) --- + +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 }}""", + + "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.', + + "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 %}""", + + "scheduled_assets_message": """📸 Photos from "{{ album_name }}":\ +{% for asset in assets %} + • {% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }}\ +{% endfor %}""", + + "memory_mode_message": """📅 On this day:\ +{% for asset in assets %} + • {% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})\ +{% 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_removed": '🗑️ {{ removed_count }} фото удалено из альбома "{{ album_name }}".', + + "message_album_renamed": '✏️ Альбом "{{ old_name }}" переименован в "{{ new_name }}".', + + "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 %}""", + + "scheduled_assets_message": """📸 Фото из "{{ album_name }}":\ +{% for asset in assets %} + • {% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }}\ +{% endfor %}""", + + "memory_mode_message": """📅 В этот день:\ +{% for asset in assets %} + • {% if asset.type == "VIDEO" %}🎬{% else %}🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})\ +{% endfor %}""", +} + + class NotificationTarget(SQLModel, table=True): """Notification destination with tracking and template config references.""" diff --git a/packages/server/src/immich_watcher_server/main.py b/packages/server/src/immich_watcher_server/main.py index fa01fe2..9168594 100644 --- a/packages/server/src/immich_watcher_server/main.py +++ b/packages/server/src/immich_watcher_server/main.py @@ -33,6 +33,33 @@ logging.basicConfig( _LOGGER = logging.getLogger(__name__) +async def _seed_default_templates(): + """Create default EN/RU template configs if none exist.""" + 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, + ) + + engine = get_engine() + async with AsyncSession(engine) as session: + result = await session.exec(select(func.count()).select_from(TemplateConfig)) + count = result.one() + if count > 0: + 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) + session.add(en) + session.add(ru) + await session.commit() + _LOGGER.info("Seeded default EN and RU template configs") + + @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan: startup and shutdown.""" @@ -41,6 +68,9 @@ async def lifespan(app: FastAPI): await init_db() _LOGGER.info("Database initialized at %s", settings.effective_database_url) + # Seed default templates if none exist + await _seed_default_templates() + await start_scheduler() yield diff --git a/packages/server/src/immich_watcher_server/services/notifier.py b/packages/server/src/immich_watcher_server/services/notifier.py index bb507dd..d2ca49c 100644 --- a/packages/server/src/immich_watcher_server/services/notifier.py +++ b/packages/server/src/immich_watcher_server/services/notifier.py @@ -39,101 +39,21 @@ def build_full_context( event_data: dict[str, Any], template_config: TemplateConfig | None = None, ) -> dict[str, Any]: - """Build the full template context with all variables from the blueprint. + """Build template context by passing raw data directly to Jinja2. - This assembles the ~40 variables the blueprint supports: - - Direct event fields (album_name, added_count, etc.) - - Computed fields (common_date, common_location, people, assets, video_warning) - - Formatted sub-templates (asset items, people format, etc.) + The templates use {% for %}, {% if %} etc. to handle formatting, + so no pre-rendering of sub-templates is needed. """ - tc = template_config ctx = dict(event_data) - # People formatting - people_list = ctx.get("people", []) - if isinstance(people_list, list) and people_list and tc: - people_str = ", ".join(str(p) for p in people_list) - ctx["people"] = _render(tc.message_people_format, {"people": people_str}) - elif isinstance(people_list, list): - ctx["people"] = ", ".join(str(p) for p in people_list) if people_list else "" - else: - ctx["people"] = str(people_list) if people_list else "" - - # Asset list formatting - added_assets = ctx.get("added_assets", []) - if added_assets and tc: - date_fmt = tc.date_format or "%d.%m.%Y, %H:%M UTC" - - # Detect common date/location - dates = set() - locations = set() - for a in added_assets: - if a.get("created_at"): - try: - dt = datetime.fromisoformat(str(a["created_at"]).replace("Z", "+00:00")) - dates.add(dt.strftime(date_fmt)) - except (ValueError, TypeError): - pass - loc_parts = [a.get("city"), a.get("country")] - loc = ", ".join(p for p in loc_parts if p) - if loc: - locations.add(loc) - - common_date = "" - if len(dates) == 1: - common_date = _render(tc.common_date_template, {"date": next(iter(dates))}) - ctx["common_date"] = common_date - - common_location = "" - if len(locations) == 1: - common_location = _render(tc.common_location_template, {"location": next(iter(locations))}) - ctx["common_location"] = common_location - - # Format individual assets - asset_lines = [] - for a in added_assets: - asset_type = a.get("type", "IMAGE") - tmpl = tc.message_asset_image if asset_type == "IMAGE" else tc.message_asset_video - - # Per-asset date (only if dates differ) - created_if_unique = "" - if len(dates) > 1 and a.get("created_at"): - try: - dt = datetime.fromisoformat(str(a["created_at"]).replace("Z", "+00:00")) - created_if_unique = _render(tc.date_if_unique_template, {"date": dt.strftime(date_fmt)}) - except (ValueError, TypeError): - pass - - # Per-asset location - location_if_unique = "" - loc_parts = [a.get("city"), a.get("country")] - loc = ", ".join(p for p in loc_parts if p) - if loc and len(locations) > 1: - location_if_unique = _render(tc.location_if_unique_template, {"location": loc}) - - # Favorite indicator - fav = tc.favorite_indicator if a.get("is_favorite") else "" - - asset_ctx = { - **a, - "created_if_unique": created_if_unique, - "location_if_unique": location_if_unique, - "location": loc, - "is_favorite": fav, - } - asset_lines.append(_render(tmpl, asset_ctx)) - - # Assemble assets list - assets_str = "".join(asset_lines) - ctx["assets"] = _render(tc.message_assets_format, {"assets": assets_str}) - else: - ctx.setdefault("assets", "") - ctx.setdefault("common_date", "") - ctx.setdefault("common_location", "") + # Ensure lists are actual lists (not strings) + if isinstance(ctx.get("people"), str): + ctx["people"] = [ctx["people"]] if ctx["people"] else [] # Video warning + added_assets = ctx.get("added_assets", []) has_videos = any(a.get("type") == "VIDEO" for a in added_assets) if added_assets else False - ctx["video_warning"] = (tc.video_warning if tc and has_videos else "") + ctx["video_warning"] = (template_config.video_warning if template_config and has_videos else "") return ctx