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