Translation (WIP)
authorGreg Burri <greg.burri@gmail.com>
Sun, 5 Jan 2025 21:38:46 +0000 (22:38 +0100)
committerGreg Burri <greg.burri@gmail.com>
Sun, 5 Jan 2025 21:38:46 +0000 (22:38 +0100)
29 files changed:
Cargo.lock
backend/Cargo.toml
backend/sql/version_1.sql
backend/src/consts.rs
backend/src/data/db/recipe.rs
backend/src/data/db/user.rs
backend/src/data/model.rs
backend/src/html_templates.rs
backend/src/main.rs
backend/src/services.rs
backend/src/services/fragments.rs
backend/src/services/recipe.rs
backend/src/services/user.rs
backend/src/translation.rs [new file with mode: 0644]
backend/templates/ask_reset_password.html
backend/templates/base_with_header.html
backend/templates/message.html
backend/templates/profile.html
backend/templates/recipe_view.html
backend/templates/recipes_list_fragment.html
backend/templates/sign_in_form.html
backend/templates/sign_up_form.html
backend/templates/title.html
backend/translation.ron [new file with mode: 0644]
common/src/consts.rs [new file with mode: 0644]
common/src/lib.rs
common/src/utils.rs
deploy.nu
frontend/src/handles.rs

index df74748..82ec42d 100644 (file)
@@ -120,67 +120,11 @@ dependencies = [
  "password-hash",
 ]
 
-[[package]]
-name = "askama"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28"
-dependencies = [
- "askama_derive",
- "askama_escape",
- "comrak",
- "humansize",
- "num-traits",
- "percent-encoding",
-]
-
-[[package]]
-name = "askama_axum"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a41603f7cdbf5ac4af60760f17253eb6adf6ec5b6f14a7ed830cf687d375f163"
-dependencies = [
- "askama",
- "axum-core",
- "http 1.2.0",
-]
-
-[[package]]
-name = "askama_derive"
-version = "0.12.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83"
-dependencies = [
- "askama_parser",
- "basic-toml",
- "mime",
- "mime_guess",
- "proc-macro2",
- "quote",
- "serde",
- "syn",
-]
-
-[[package]]
-name = "askama_escape"
-version = "0.10.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
-
-[[package]]
-name = "askama_parser"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
-dependencies = [
- "nom",
-]
-
 [[package]]
 name = "async-trait"
-version = "0.1.83"
+version = "0.1.84"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd"
+checksum = "1b1244b10dcd56c92219da4e14caa97e312079e185f04ba3eea25061561dc0a0"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -391,9 +335,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
 
 [[package]]
 name = "cc"
-version = "1.2.6"
+version = "1.2.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8d6dbb628b8f8555f86d0323c2eb39e3ec81901f4b83e091db8a6a76d316a333"
+checksum = "a012a0df96dd6d06ba9a1b29d6402d1a5d77c6befd2566afdc26e10603dc93d7"
 dependencies = [
  "shlex",
 ]
@@ -483,21 +427,6 @@ dependencies = [
  "serde",
 ]
 
-[[package]]
-name = "comrak"
-version = "0.18.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "482aa5695bca086022be453c700a40c02893f1ba7098a2c88351de55341ae894"
-dependencies = [
- "entities",
- "memchr",
- "once_cell",
- "regex",
- "slug",
- "typed-arena",
- "unicode_categories",
-]
-
 [[package]]
 name = "concurrent-queue"
 version = "2.5.0"
@@ -640,12 +569,6 @@ dependencies = [
  "unicode-xid",
 ]
 
-[[package]]
-name = "deunicode"
-version = "1.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00"
-
 [[package]]
 name = "digest"
 version = "0.10.7"
@@ -709,12 +632,6 @@ dependencies = [
  "cfg-if",
 ]
 
-[[package]]
-name = "entities"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
-
 [[package]]
 name = "equivalent"
 version = "1.0.1"
@@ -776,6 +693,12 @@ version = "1.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
 
+[[package]]
+name = "foldhash"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f"
+
 [[package]]
 name = "form_urlencoded"
 version = "1.2.1"
@@ -1126,14 +1049,19 @@ name = "hashbrown"
 version = "0.15.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
 
 [[package]]
 name = "hashlink"
-version = "0.9.1"
+version = "0.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
 dependencies = [
- "hashbrown 0.14.5",
+ "hashbrown 0.15.2",
 ]
 
 [[package]]
@@ -1473,9 +1401,9 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
 
 [[package]]
 name = "itertools"
-version = "0.13.0"
+version = "0.14.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
 dependencies = [
  "either",
 ]
@@ -1811,12 +1739,6 @@ dependencies = [
  "subtle",
 ]
 
-[[package]]
-name = "paste"
-version = "1.0.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
-
 [[package]]
 name = "pem-rfc7468"
 version = "0.7.0"
@@ -1995,8 +1917,6 @@ name = "recipes"
 version = "1.0.0"
 dependencies = [
  "argon2",
- "askama",
- "askama_axum",
  "axum",
  "axum-extra",
  "chrono",
@@ -2007,7 +1927,10 @@ dependencies = [
  "lettre",
  "rand",
  "rand_core",
+ "rinja",
+ "rinja_axum",
  "ron",
+ "rustc-hash",
  "serde",
  "sqlx",
  "thiserror 2.0.9",
@@ -2086,6 +2009,58 @@ dependencies = [
  "windows-sys 0.52.0",
 ]
 
+[[package]]
+name = "rinja"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3dc4940d00595430b3d7d5a01f6222b5e5b51395d1120bdb28d854bb8abb17a5"
+dependencies = [
+ "humansize",
+ "itoa",
+ "percent-encoding",
+ "rinja_derive",
+]
+
+[[package]]
+name = "rinja_axum"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc64d77bb950f6498d0fc64b028d168fcb4e56ac31b66a8ae05f64d3b0c218b6"
+dependencies = [
+ "axum-core",
+ "http 1.2.0",
+ "rinja",
+]
+
+[[package]]
+name = "rinja_derive"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d9ed0146aef6e2825f1b1515f074510549efba38d71f4554eec32eb36ba18b"
+dependencies = [
+ "basic-toml",
+ "memchr",
+ "mime",
+ "mime_guess",
+ "proc-macro2",
+ "quote",
+ "rinja_parser",
+ "rustc-hash",
+ "serde",
+ "syn",
+]
+
+[[package]]
+name = "rinja_parser"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93f9a866e2e00a7a1fb27e46e9e324a6f7c0e7edc4543cae1d38f4e4a100c610"
+dependencies = [
+ "memchr",
+ "nom",
+ "serde",
+]
+
 [[package]]
 name = "ron"
 version = "0.8.1"
@@ -2124,6 +2099,12 @@ version = "0.1.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
 
+[[package]]
+name = "rustc-hash"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497"
+
 [[package]]
 name = "rustix"
 version = "0.38.42"
@@ -2326,16 +2307,6 @@ dependencies = [
  "autocfg",
 ]
 
-[[package]]
-name = "slug"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
-dependencies = [
- "deunicode",
- "wasm-bindgen",
-]
-
 [[package]]
 name = "smallvec"
 version = "1.13.2"
@@ -2374,21 +2345,11 @@ dependencies = [
  "der",
 ]
 
-[[package]]
-name = "sqlformat"
-version = "0.2.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790"
-dependencies = [
- "nom",
- "unicode_categories",
-]
-
 [[package]]
 name = "sqlx"
-version = "0.8.2"
+version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93334716a037193fac19df402f8571269c84a00852f6a7066b5d2616dcd64d3e"
+checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f"
 dependencies = [
  "sqlx-core",
  "sqlx-macros",
@@ -2399,38 +2360,32 @@ dependencies = [
 
 [[package]]
 name = "sqlx-core"
-version = "0.8.2"
+version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4d8060b456358185f7d50c55d9b5066ad956956fddec42ee2e8567134a8936e"
+checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0"
 dependencies = [
- "atoi",
- "byteorder",
  "bytes",
  "chrono",
  "crc",
  "crossbeam-queue",
  "either",
  "event-listener",
- "futures-channel",
  "futures-core",
  "futures-intrusive",
  "futures-io",
  "futures-util",
- "hashbrown 0.14.5",
+ "hashbrown 0.15.2",
  "hashlink",
- "hex",
  "indexmap",
  "log",
  "memchr",
  "once_cell",
- "paste",
  "percent-encoding",
  "serde",
  "serde_json",
  "sha2",
  "smallvec",
- "sqlformat",
- "thiserror 1.0.69",
+ "thiserror 2.0.9",
  "tokio",
  "tokio-stream",
  "tracing",
@@ -2439,9 +2394,9 @@ dependencies = [
 
 [[package]]
 name = "sqlx-macros"
-version = "0.8.2"
+version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cac0692bcc9de3b073e8d747391827297e075c7710ff6276d9f7a1f3d58c6657"
+checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2452,9 +2407,9 @@ dependencies = [
 
 [[package]]
 name = "sqlx-macros-core"
-version = "0.8.2"
+version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1804e8a7c7865599c9c79be146dc8a9fd8cc86935fa641d3ea58e5f0688abaa5"
+checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad"
 dependencies = [
  "dotenvy",
  "either",
@@ -2478,9 +2433,9 @@ dependencies = [
 
 [[package]]
 name = "sqlx-mysql"
-version = "0.8.2"
+version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "64bb4714269afa44aef2755150a0fc19d756fb580a67db8885608cf02f47d06a"
+checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233"
 dependencies = [
  "atoi",
  "base64 0.22.1",
@@ -2514,16 +2469,16 @@ dependencies = [
  "smallvec",
  "sqlx-core",
  "stringprep",
- "thiserror 1.0.69",
+ "thiserror 2.0.9",
  "tracing",
  "whoami",
 ]
 
 [[package]]
 name = "sqlx-postgres"
-version = "0.8.2"
+version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6fa91a732d854c5d7726349bb4bb879bb9478993ceb764247660aee25f67c2f8"
+checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613"
 dependencies = [
  "atoi",
  "base64 0.22.1",
@@ -2535,7 +2490,6 @@ dependencies = [
  "etcetera",
  "futures-channel",
  "futures-core",
- "futures-io",
  "futures-util",
  "hex",
  "hkdf",
@@ -2553,16 +2507,16 @@ dependencies = [
  "smallvec",
  "sqlx-core",
  "stringprep",
- "thiserror 1.0.69",
+ "thiserror 2.0.9",
  "tracing",
  "whoami",
 ]
 
 [[package]]
 name = "sqlx-sqlite"
-version = "0.8.2"
+version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680"
+checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540"
 dependencies = [
  "atoi",
  "chrono",
@@ -2626,9 +2580,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
 
 [[package]]
 name = "syn"
-version = "2.0.93"
+version = "2.0.95"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058"
+checksum = "46f71c0377baf4ef1cc3e3402ded576dccc315800fbc62dfc7fe04b009773b4a"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2654,12 +2608,13 @@ dependencies = [
 
 [[package]]
 name = "tempfile"
-version = "3.14.0"
+version = "3.15.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c"
+checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
 dependencies = [
  "cfg-if",
  "fastrand",
+ "getrandom",
  "once_cell",
  "rustix",
  "windows-sys 0.59.0",
@@ -2966,12 +2921,6 @@ dependencies = [
  "tracing-log",
 ]
 
-[[package]]
-name = "typed-arena"
-version = "2.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
-
 [[package]]
 name = "typenum"
 version = "1.17.0"
@@ -3023,12 +2972,6 @@ version = "0.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
 
-[[package]]
-name = "unicode_categories"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
-
 [[package]]
 name = "untrusted"
 version = "0.9.0"
index 18761b6..9a47b14 100644 (file)
@@ -22,18 +22,14 @@ chrono = "0.4"
 ron = "0.8"
 serde = { version = "1.0", features = ["derive"] }
 
-itertools = "0.13"
+itertools = "0.14"
+rustc-hash = "2.1"
 clap = { version = "4", features = ["derive"] }
 
 sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] }
 
-askama = { version = "0.12", features = [
-    "with-axum",
-    "mime",
-    "mime_guess",
-    "markdown",
-] }
-askama_axum = "0.4"
+rinja = { version = "0.3", features = ["with-axum"] }
+rinja_axum = "0.3"
 
 argon2 = { version = "0.5", features = ["default", "std"] }
 rand_core = { version = "0.6", features = ["std"] }
index 2db2354..76c96cd 100644 (file)
@@ -10,6 +10,7 @@ CREATE TABLE [User] (
     [email] TEXT NOT NULL,
     [name] TEXT NOT NULL DEFAULT '',
     [default_servings] INTEGER DEFAULT 4,
+    [lang] TEXT NOT NULL DEFAULT 'en',
 
     [password] TEXT NOT NULL, -- argon2(password_plain, salt).
 
index 1b85736..76986db 100644 (file)
@@ -1,6 +1,7 @@
 use std::{sync::LazyLock, time::Duration};
 
 pub const FILE_CONF: &str = "conf.ron";
+pub const TRANSLATION_FILE: &str = "translation.ron";
 pub const DB_DIRECTORY: &str = "data";
 pub const DB_FILENAME: &str = "recipes.sqlite";
 pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql";
index 28608df..28f714e 100644 (file)
@@ -102,8 +102,8 @@ WHERE [Ingredient].[id] = $1 AND [user_id] = $2
         .map_err(DBError::from)
     }
 
-    pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
-        sqlx::query_as(
+    pub async fn get_recipe(&self, id: i64, with_groups: bool) -> Result<Option<model::Recipe>> {
+        match sqlx::query_as::<_, model::Recipe>(
             r#"
 SELECT
     [id], [user_id], [title], [lang],
@@ -114,8 +114,14 @@ FROM [Recipe] WHERE [id] = $1
         )
         .bind(id)
         .fetch_optional(&self.pool)
-        .await
-        .map_err(DBError::from)
+        .await?
+        {
+            Some(mut recipe) if with_groups => {
+                recipe.groups = self.get_groups(id).await?;
+                Ok(Some(recipe))
+            }
+            recipe => Ok(recipe),
+        }
     }
 
     pub async fn create_recipe(&self, user_id: i64) -> Result<i64> {
@@ -543,8 +549,6 @@ ORDER BY [name]
 
 #[cfg(test)]
 mod tests {
-    use axum::routing::connect;
-
     use super::*;
 
     #[tokio::test]
@@ -555,7 +559,7 @@ mod tests {
         let recipe_id = connection.create_recipe(user_id).await?;
 
         connection.set_recipe_title(recipe_id, "Crêpe").await?;
-        let recipe = connection.get_recipe(recipe_id).await?.unwrap();
+        let recipe = connection.get_recipe(recipe_id, false).await?.unwrap();
         assert_eq!(recipe.title, "Crêpe".to_string());
 
         Ok(())
@@ -581,7 +585,7 @@ mod tests {
         connection.set_recipe_language(recipe_id, "fr").await?;
         connection.set_recipe_is_published(recipe_id, true).await?;
 
-        let recipe = connection.get_recipe(recipe_id).await?.unwrap();
+        let recipe = connection.get_recipe(recipe_id, false).await?.unwrap();
 
         assert_eq!(recipe.id, recipe_id);
         assert_eq!(recipe.title, "Ouiche");
index 4a37d69..a95feb4 100644 (file)
@@ -76,7 +76,7 @@ FROM [UserLoginToken] WHERE [token] = $1
     }
 
     pub async fn load_user(&self, user_id: i64) -> Result<Option<model::User>> {
-        sqlx::query_as("SELECT [id], [email], [name] FROM [User] WHERE [id] = $1")
+        sqlx::query_as("SELECT [id], [email], [name], [lang] FROM [User] WHERE [id] = $1")
             .bind(user_id)
             .fetch_optional(&self.pool)
             .await
@@ -102,13 +102,14 @@ FROM [UserLoginToken] WHERE [token] = $1
         .fetch_one(&mut *tx)
         .await?;
 
+        let new_email = new_email.map(str::trim);
         let email_changed = new_email.is_some_and(|new_email| new_email != email);
 
         // Check if email not already taken.
         let validation_token = if email_changed {
-            if sqlx::query_scalar::<_, i64>(
+            if sqlx::query_scalar(
                 r#"
-SELECT COUNT(*)
+SELECT COUNT(*) > 0
 FROM [User]
 WHERE [email] = $1
                 "#,
@@ -116,7 +117,6 @@ WHERE [email] = $1
             .bind(new_email.unwrap())
             .fetch_one(&mut *tx)
             .await?
-                > 0
             {
                 return Ok(UpdateUserResult::EmailAlreadyTaken);
             }
@@ -148,7 +148,7 @@ WHERE [id] = $1
         )
         .bind(user_id)
         .bind(new_email.unwrap_or(&email))
-        .bind(new_name.unwrap_or(&name))
+        .bind(new_name.map(str::trim).unwrap_or(&name))
         .bind(hashed_new_password.unwrap_or(hashed_password))
         .execute(&mut *tx)
         .await?;
index dd2bbb0..d38db56 100644 (file)
@@ -7,6 +7,7 @@ pub struct User {
     pub id: i64,
     pub name: String,
     pub email: String,
+    pub lang: String,
 }
 
 #[derive(FromRow)]
@@ -30,8 +31,9 @@ pub struct Recipe {
 
     pub servings: Option<u32>,
     pub is_published: bool,
-    // pub tags: Vec<String>,
-    // pub groups: Vec<Group>,
+
+    #[sqlx(skip)]
+    pub groups: Vec<Group>,
 }
 
 #[derive(FromRow)]
index 9f5cf0a..d7d4160 100644 (file)
@@ -1,6 +1,9 @@
-use askama::Template;
+use rinja_axum::Template;
 
-use crate::data::model;
+use crate::{
+    data::model,
+    translation::{Sentence, Tr},
+};
 
 pub struct Recipes {
     pub published: Vec<(i64, String)>,
@@ -18,6 +21,8 @@ impl Recipes {
 #[template(path = "home.html")]
 pub struct HomeTemplate {
     pub user: Option<model::User>,
+    pub tr: Tr,
+
     pub recipes: Recipes,
 }
 
@@ -25,23 +30,26 @@ pub struct HomeTemplate {
 #[template(path = "message.html")]
 pub struct MessageTemplate {
     pub user: Option<model::User>,
+    pub tr: Tr,
 
     pub message: String,
     pub as_code: bool, // Display the message in <pre> markup.
 }
 
 impl MessageTemplate {
-    pub fn new(message: &str) -> MessageTemplate {
+    pub fn new(message: &str, tr: Tr) -> MessageTemplate {
         MessageTemplate {
             user: None,
+            tr,
             message: message.to_string(),
             as_code: false,
         }
     }
 
-    pub fn new_with_user(message: &str, user: Option<model::User>) -> MessageTemplate {
+    pub fn new_with_user(message: &str, tr: Tr, user: Option<model::User>) -> MessageTemplate {
         MessageTemplate {
             user,
+            tr,
             message: message.to_string(),
             as_code: false,
         }
@@ -52,6 +60,7 @@ impl MessageTemplate {
 #[template(path = "sign_up_form.html")]
 pub struct SignUpFormTemplate {
     pub user: Option<model::User>,
+    pub tr: Tr,
 
     pub email: String,
     pub message: String,
@@ -63,6 +72,7 @@ pub struct SignUpFormTemplate {
 #[template(path = "sign_in_form.html")]
 pub struct SignInFormTemplate {
     pub user: Option<model::User>,
+    pub tr: Tr,
 
     pub email: String,
     pub message: String,
@@ -72,6 +82,7 @@ pub struct SignInFormTemplate {
 #[template(path = "ask_reset_password.html")]
 pub struct AskResetPasswordTemplate {
     pub user: Option<model::User>,
+    pub tr: Tr,
 
     pub email: String,
     pub message: String,
@@ -82,6 +93,7 @@ pub struct AskResetPasswordTemplate {
 #[template(path = "reset_password.html")]
 pub struct ResetPasswordTemplate {
     pub user: Option<model::User>,
+    pub tr: Tr,
 
     pub reset_token: String,
     pub message: String,
@@ -92,6 +104,7 @@ pub struct ResetPasswordTemplate {
 #[template(path = "profile.html")]
 pub struct ProfileTemplate {
     pub user: Option<model::User>,
+    pub tr: Tr,
 
     pub username: String,
     pub email: String,
@@ -104,6 +117,8 @@ pub struct ProfileTemplate {
 #[template(path = "recipe_view.html")]
 pub struct RecipeViewTemplate {
     pub user: Option<model::User>,
+    pub tr: Tr,
+
     pub recipes: Recipes,
 
     pub recipe: model::Recipe,
@@ -113,6 +128,8 @@ pub struct RecipeViewTemplate {
 #[template(path = "recipe_edit.html")]
 pub struct RecipeEditTemplate {
     pub user: Option<model::User>,
+    pub tr: Tr,
+
     pub recipes: Recipes,
 
     pub recipe: model::Recipe,
@@ -123,5 +140,7 @@ pub struct RecipeEditTemplate {
 #[template(path = "recipes_list_fragment.html")]
 pub struct RecipesListFragmentTemplate {
     pub user: Option<model::User>,
+    pub tr: Tr,
+
     pub recipes: Recipes,
 }
index bb3d485..af80c90 100644 (file)
@@ -1,7 +1,7 @@
 use std::{net::SocketAddr, path::Path};
 
 use axum::{
-    extract::{ConnectInfo, FromRef, Request, State},
+    extract::{ConnectInfo, Extension, FromRef, Request, State},
     http::StatusCode,
     middleware::{self, Next},
     response::{Response, Result},
@@ -12,10 +12,12 @@ use axum_extra::extract::cookie::CookieJar;
 use chrono::prelude::*;
 use clap::Parser;
 use config::Config;
+use itertools::Itertools;
 use tower_http::{services::ServeDir, trace::TraceLayer};
 use tracing::{event, Level};
 
 use data::{db, model};
+use translation::Tr;
 
 mod config;
 mod consts;
@@ -26,6 +28,7 @@ mod html_templates;
 mod ron_extractor;
 mod ron_utils;
 mod services;
+mod translation;
 mod utils;
 
 #[derive(Clone)]
@@ -191,6 +194,7 @@ async fn main() {
         .fallback(services::not_found)
         .layer(TraceLayer::new_for_http())
         // FIXME: Should be 'route_layer' but it doesn't work for 'fallback(..)'.
+        .layer(middleware::from_fn(translation))
         .layer(middleware::from_fn_with_state(
             state.clone(),
             user_authentication,
@@ -218,6 +222,39 @@ async fn user_authentication(
     Ok(next.run(req).await)
 }
 
+async fn translation(
+    Extension(user): Extension<Option<model::User>>,
+    mut req: Request,
+    next: Next,
+) -> Result<Response> {
+    let language = if let Some(user) = user {
+        user.lang
+    } else {
+        let available_codes = Tr::available_codes();
+
+        // TODO: Check cookies before http headers.
+
+        let accept_language = req
+            .headers()
+            .get(axum::http::header::ACCEPT_LANGUAGE)
+            .map(|v| v.to_str().unwrap_or_default())
+            .unwrap_or_default()
+            .split(',')
+            .map(|l| l.split('-').next().unwrap_or_default())
+            .find_or_first(|l| available_codes.contains(l));
+
+        // TODO: Save to cookies.
+
+        accept_language.unwrap_or("en").to_string()
+    };
+
+    let tr = Tr::new(&language);
+
+    // let jar = CookieJar::from_headers(req.headers());
+    req.extensions_mut().insert(tr);
+    Ok(next.run(req).await)
+}
+
 async fn get_current_user(
     connection: db::Connection,
     jar: &CookieJar,
index 65067a3..8036a14 100644 (file)
@@ -10,7 +10,7 @@ use axum::{
 use crate::{
     data::{db, model},
     html_templates::*,
-    ron_utils,
+    ron_utils, translation,
 };
 
 pub mod fragments;
@@ -19,7 +19,11 @@ pub mod ron;
 pub mod user;
 
 // Will embed RON error in HTML page.
-pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
+pub async fn ron_error_to_html(
+    Extension(tr): Extension<translation::Tr>,
+    req: Request,
+    next: Next,
+) -> Result<Response> {
     let response = next.run(req).await;
 
     if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) {
@@ -32,6 +36,7 @@ pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
                 user: None,
                 message,
                 as_code: true,
+                tr,
             }
             .into_response());
         }
@@ -46,6 +51,7 @@ pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
 pub async fn home_page(
     State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
 ) -> Result<impl IntoResponse> {
     let recipes = Recipes {
         published: connection.get_all_published_recipe_titles().await?,
@@ -59,15 +65,18 @@ pub async fn home_page(
         current_id: None,
     };
 
-    Ok(HomeTemplate { user, recipes })
+    Ok(HomeTemplate { user, recipes, tr })
 }
 
 ///// 404 /////
 
 #[debug_handler]
-pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse {
+pub async fn not_found(
+    Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
+) -> impl IntoResponse {
     (
         StatusCode::NOT_FOUND,
-        MessageTemplate::new_with_user("404: Not found", user),
+        MessageTemplate::new_with_user("404: Not found", tr, user),
     )
 }
index 84a2d40..47e9e93 100644 (file)
@@ -9,6 +9,7 @@ use serde::Deserialize;
 use crate::{
     data::{db, model},
     html_templates::*,
+    translation,
 };
 
 #[derive(Deserialize)]
@@ -21,6 +22,7 @@ pub async fn recipes_list_fragments(
     State(connection): State<db::Connection>,
     current_recipe: Query<CurrentRecipeId>,
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
 ) -> Result<impl IntoResponse> {
     let recipes = Recipes {
         published: connection.get_all_published_recipe_titles().await?,
@@ -33,5 +35,5 @@ pub async fn recipes_list_fragments(
         },
         current_id: current_recipe.current_recipe_id,
     };
-    Ok(RecipesListFragmentTemplate { user, recipes })
+    Ok(RecipesListFragmentTemplate { user, tr, recipes })
 }
index b6c872f..8c7eaa4 100644 (file)
@@ -9,20 +9,20 @@ use crate::{
     consts,
     data::{db, model},
     html_templates::*,
+    translation,
 };
 
-///// RECIPE /////
-
 #[debug_handler]
 pub async fn create(
     State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
 ) -> Result<Response> {
     if let Some(user) = user {
         let recipe_id = connection.create_recipe(user.id).await?;
         Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response())
     } else {
-        Ok(MessageTemplate::new("Not logged in").into_response())
+        Ok(MessageTemplate::new("Not logged in", tr).into_response())
     }
 }
 
@@ -30,10 +30,11 @@ pub async fn create(
 pub async fn edit_recipe(
     State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
     Path(recipe_id): Path<i64>,
 ) -> Result<Response> {
     if let Some(user) = user {
-        if let Some(recipe) = connection.get_recipe(recipe_id).await? {
+        if let Some(recipe) = connection.get_recipe(recipe_id, false).await? {
             if recipe.user_id == user.id {
                 let recipes = Recipes {
                     published: connection.get_all_published_recipe_titles().await?,
@@ -45,19 +46,20 @@ pub async fn edit_recipe(
 
                 Ok(RecipeEditTemplate {
                     user: Some(user),
+                    tr,
                     recipes,
                     recipe,
                     languages: *consts::LANGUAGES,
                 }
                 .into_response())
             } else {
-                Ok(MessageTemplate::new("Not allowed to edit this recipe").into_response())
+                Ok(MessageTemplate::new("Not allowed to edit this recipe", tr).into_response())
             }
         } else {
-            Ok(MessageTemplate::new("Recipe not found").into_response())
+            Ok(MessageTemplate::new("Recipe not found", tr).into_response())
         }
     } else {
-        Ok(MessageTemplate::new("Not logged in").into_response())
+        Ok(MessageTemplate::new("Not logged in", tr).into_response())
     }
 }
 
@@ -65,15 +67,17 @@ pub async fn edit_recipe(
 pub async fn view(
     State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
     Path(recipe_id): Path<i64>,
 ) -> Result<Response> {
-    match connection.get_recipe(recipe_id).await? {
+    match connection.get_recipe(recipe_id, true).await? {
         Some(recipe) => {
             if !recipe.is_published
                 && (user.is_none() || recipe.user_id != user.as_ref().unwrap().id)
             {
                 return Ok(MessageTemplate::new_with_user(
                     &format!("Not allowed the view the recipe {}", recipe_id),
+                    tr,
                     user,
                 )
                 .into_response());
@@ -93,6 +97,7 @@ pub async fn view(
 
             Ok(RecipeViewTemplate {
                 user,
+                tr,
                 recipes,
                 recipe,
             }
@@ -100,6 +105,7 @@ pub async fn view(
         }
         None => Ok(MessageTemplate::new_with_user(
             &format!("Cannot find the recipe {}", recipe_id),
+            tr,
             user,
         )
         .into_response()),
index 5eeee8b..76b7676 100644 (file)
@@ -19,6 +19,7 @@ use crate::{
     data::{db, model},
     email,
     html_templates::*,
+    translation::{self, Sentence},
     utils, AppState,
 };
 
@@ -27,9 +28,11 @@ use crate::{
 #[debug_handler]
 pub async fn sign_up_get(
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
 ) -> Result<impl IntoResponse> {
     Ok(SignUpFormTemplate {
         user,
+        tr,
         email: String::new(),
         message: String::new(),
         message_email: String::new(),
@@ -59,34 +62,37 @@ pub async fn sign_up_post(
     State(connection): State<db::Connection>,
     State(config): State<Config>,
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
     Form(form_data): Form<SignUpFormData>,
 ) -> Result<Response> {
     fn error_response(
         error: SignUpError,
         form_data: &SignUpFormData,
         user: Option<model::User>,
+        tr: translation::Tr,
     ) -> Result<Response> {
         Ok(SignUpFormTemplate {
             user,
             email: form_data.email.clone(),
             message_email: match error {
-                SignUpError::InvalidEmail => "Invalid email",
-                _ => "",
-            }
-            .to_string(),
+                SignUpError::InvalidEmail => tr.t(Sentence::InvalidEmail),
+                _ => String::new(),
+            },
             message_password: match error {
-                SignUpError::PasswordsNotEqual => "Passwords don't match",
-                SignUpError::InvalidPassword => "Password must have at least eight characters",
-                _ => "",
-            }
-            .to_string(),
+                SignUpError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
+                SignUpError::InvalidPassword => tr.tp(
+                    Sentence::InvalidPassword,
+                    &[Box::new(common::consts::MIN_PASSWORD_SIZE)],
+                ),
+                _ => String::new(),
+            },
             message: match error {
-                SignUpError::UserAlreadyExists => "This email is not available",
-                SignUpError::DatabaseError => "Database error",
-                SignUpError::UnableSendEmail => "Unable to send the validation email",
-                _ => "",
-            }
-            .to_string(),
+                SignUpError::UserAlreadyExists => tr.t(Sentence::EmailAlreadyTaken),
+                SignUpError::DatabaseError => "Database error".to_string(),
+                SignUpError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail),
+                _ => String::new(),
+            },
+            tr,
         }
         .into_response())
     }
@@ -95,17 +101,17 @@ pub async fn sign_up_post(
     if let common::utils::EmailValidation::NotValid =
         common::utils::validate_email(&form_data.email)
     {
-        return error_response(SignUpError::InvalidEmail, &form_data, user);
+        return error_response(SignUpError::InvalidEmail, &form_data, user, tr);
     }
 
     if form_data.password_1 != form_data.password_2 {
-        return error_response(SignUpError::PasswordsNotEqual, &form_data, user);
+        return error_response(SignUpError::PasswordsNotEqual, &form_data, user, tr);
     }
 
     if let common::utils::PasswordValidation::TooShort =
         common::utils::validate_password(&form_data.password_1)
     {
-        return error_response(SignUpError::InvalidPassword, &form_data, user);
+        return error_response(SignUpError::InvalidPassword, &form_data, user, tr);
     }
 
     match connection
@@ -113,7 +119,7 @@ pub async fn sign_up_post(
         .await
     {
         Ok(db::user::SignUpResult::UserAlreadyExists) => {
-            error_response(SignUpError::UserAlreadyExists, &form_data, user)
+            error_response(SignUpError::UserAlreadyExists, &form_data, user, tr)
         }
         Ok(db::user::SignUpResult::UserCreatedWaitingForValidation(token)) => {
             let url = utils::get_url_from_host(&host);
@@ -133,16 +139,16 @@ pub async fn sign_up_post(
                 Ok(()) => Ok(
                     MessageTemplate::new_with_user(
                         "An email has been sent, follow the link to validate your account",
-                    user).into_response()),
+                    tr, user).into_response()),
                 Err(_) => {
                     // error!("Email validation error: {}", error); // TODO: log
-                    error_response(SignUpError::UnableSendEmail, &form_data, user)
+                    error_response(SignUpError::UnableSendEmail, &form_data, user, tr)
                 }
             }
         }
         Err(_) => {
             // error!("Signup database error: {}", error); // TODO: log
-            error_response(SignUpError::DatabaseError, &form_data, user)
+            error_response(SignUpError::DatabaseError, &form_data, user, tr)
         }
     }
 }
@@ -151,6 +157,7 @@ pub async fn sign_up_post(
 pub async fn sign_up_validation(
     State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
     ConnectInfo(addr): ConnectInfo<SocketAddr>,
     Query(query): Query<HashMap<String, String>>,
     headers: HeaderMap,
@@ -159,7 +166,7 @@ pub async fn sign_up_validation(
     if user.is_some() {
         return Ok((
             jar,
-            MessageTemplate::new_with_user("User already exists", user),
+            MessageTemplate::new_with_user("User already exists", tr, user),
         ));
     }
     let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
@@ -183,6 +190,7 @@ pub async fn sign_up_validation(
                         jar,
                         MessageTemplate::new_with_user(
                             "Email validation successful, your account has been created",
+                            tr,
                             user,
                         ),
                     ))
@@ -191,18 +199,23 @@ pub async fn sign_up_validation(
                     jar,
                     MessageTemplate::new_with_user(
                         "The validation has expired. Try to sign up again",
+                        tr,
                         user,
                     ),
                 )),
                 db::user::ValidationResult::UnknownUser => Ok((
                     jar,
-                    MessageTemplate::new_with_user("Validation error. Try to sign up again", user),
+                    MessageTemplate::new_with_user(
+                        "Validation error. Try to sign up again",
+                        tr,
+                        user,
+                    ),
                 )),
             }
         }
         None => Ok((
             jar,
-            MessageTemplate::new_with_user("Validation error", user),
+            MessageTemplate::new_with_user("Validation error", tr, user),
         )),
     }
 }
@@ -212,9 +225,11 @@ pub async fn sign_up_validation(
 #[debug_handler]
 pub async fn sign_in_get(
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
 ) -> Result<impl IntoResponse> {
     Ok(SignInFormTemplate {
         user,
+        tr,
         email: String::new(),
         message: String::new(),
     })
@@ -231,6 +246,7 @@ pub async fn sign_in_post(
     ConnectInfo(addr): ConnectInfo<SocketAddr>,
     State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
     headers: HeaderMap,
     Form(form_data): Form<SignInFormData>,
 ) -> Result<(CookieJar, Response)> {
@@ -251,7 +267,8 @@ pub async fn sign_in_post(
             SignInFormTemplate {
                 user,
                 email: form_data.email,
-                message: "This account must be validated first".to_string(),
+                message: tr.t(Sentence::AccountMustBeValidatedFirst),
+                tr,
             }
             .into_response(),
         )),
@@ -260,7 +277,8 @@ pub async fn sign_in_post(
             SignInFormTemplate {
                 user,
                 email: form_data.email,
-                message: "Wrong email or password".to_string(),
+                message: tr.t(Sentence::WrongEmailOrPassword),
+                tr,
             }
             .into_response(),
         )),
@@ -292,16 +310,19 @@ pub async fn sign_out(
 #[debug_handler]
 pub async fn ask_reset_password_get(
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
 ) -> Result<Response> {
     if user.is_some() {
         Ok(MessageTemplate::new_with_user(
             "Can't ask to reset password when already logged in",
+            tr,
             user,
         )
         .into_response())
     } else {
         Ok(AskResetPasswordTemplate {
             user,
+            tr,
             email: String::new(),
             message: String::new(),
             message_email: String::new(),
@@ -329,15 +350,18 @@ pub async fn ask_reset_password_post(
     State(connection): State<db::Connection>,
     State(config): State<Config>,
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
     Form(form_data): Form<AskResetPasswordForm>,
 ) -> Result<Response> {
     fn error_response(
         error: AskResetPasswordError,
         email: &str,
         user: Option<model::User>,
+        tr: translation::Tr,
     ) -> Result<Response> {
         Ok(AskResetPasswordTemplate {
             user,
+            tr,
             email: email.to_string(),
             message_email: match error {
                 AskResetPasswordError::InvalidEmail => "Invalid email",
@@ -362,7 +386,12 @@ pub async fn ask_reset_password_post(
     if let common::utils::EmailValidation::NotValid =
         common::utils::validate_email(&form_data.email)
     {
-        return error_response(AskResetPasswordError::InvalidEmail, &form_data.email, user);
+        return error_response(
+            AskResetPasswordError::InvalidEmail,
+            &form_data.email,
+            user,
+            tr,
+        );
     }
 
     match connection
@@ -376,10 +405,14 @@ pub async fn ask_reset_password_post(
             AskResetPasswordError::EmailAlreadyReset,
             &form_data.email,
             user,
+            tr,
+        ),
+        Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => error_response(
+            AskResetPasswordError::EmailUnknown,
+            &form_data.email,
+            user,
+            tr,
         ),
-        Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => {
-            error_response(AskResetPasswordError::EmailUnknown, &form_data.email, user)
-        }
         Ok(db::user::GetTokenResetPasswordResult::Ok(token)) => {
             let url = utils::get_url_from_host(&host);
             match email::send_email(
@@ -396,6 +429,7 @@ pub async fn ask_reset_password_post(
             {
                 Ok(()) => Ok(MessageTemplate::new_with_user(
                     "An email has been sent, follow the link to reset your password.",
+                    tr,
                     user,
                 )
                 .into_response()),
@@ -405,13 +439,19 @@ pub async fn ask_reset_password_post(
                         AskResetPasswordError::UnableSendEmail,
                         &form_data.email,
                         user,
+                        tr,
                     )
                 }
             }
         }
         Err(error) => {
             event!(Level::ERROR, "{}", error);
-            error_response(AskResetPasswordError::DatabaseError, &form_data.email, user)
+            error_response(
+                AskResetPasswordError::DatabaseError,
+                &form_data.email,
+                user,
+                tr,
+            )
         }
     }
 }
@@ -419,18 +459,20 @@ pub async fn ask_reset_password_post(
 #[debug_handler]
 pub async fn reset_password_get(
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
     Query(query): Query<HashMap<String, String>>,
 ) -> Result<Response> {
     if let Some(reset_token) = query.get("reset_token") {
         Ok(ResetPasswordTemplate {
             user,
+            tr,
             reset_token: reset_token.to_string(),
             message: String::new(),
             message_password: String::new(),
         }
         .into_response())
     } else {
-        Ok(MessageTemplate::new_with_user("Reset token missing", user).into_response())
+        Ok(MessageTemplate::new_with_user("Reset token missing", tr, user).into_response())
     }
 }
 
@@ -452,15 +494,18 @@ enum ResetPasswordError {
 pub async fn reset_password_post(
     State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
     Form(form_data): Form<ResetPasswordForm>,
 ) -> Result<Response> {
     fn error_response(
         error: ResetPasswordError,
         form_data: &ResetPasswordForm,
         user: Option<model::User>,
+        tr: translation::Tr,
     ) -> Result<Response> {
         Ok(ResetPasswordTemplate {
             user,
+            tr,
             reset_token: form_data.reset_token.clone(),
             message_password: match error {
                 ResetPasswordError::PasswordsNotEqual => "Passwords don't match",
@@ -481,13 +526,13 @@ pub async fn reset_password_post(
     }
 
     if form_data.password_1 != form_data.password_2 {
-        return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, user);
+        return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, user, tr);
     }
 
     if let common::utils::PasswordValidation::TooShort =
         common::utils::validate_password(&form_data.password_1)
     {
-        return error_response(ResetPasswordError::InvalidPassword, &form_data, user);
+        return error_response(ResetPasswordError::InvalidPassword, &form_data, user, tr);
     }
 
     match connection
@@ -498,34 +543,39 @@ pub async fn reset_password_post(
         )
         .await
     {
-        Ok(db::user::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user(
-            "Your password has been reset",
-            user,
-        )
-        .into_response()),
+        Ok(db::user::ResetPasswordResult::Ok) => {
+            Ok(
+                MessageTemplate::new_with_user("Your password has been reset", tr, user)
+                    .into_response(),
+            )
+        }
         Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
-            error_response(ResetPasswordError::TokenExpired, &form_data, user)
+            error_response(ResetPasswordError::TokenExpired, &form_data, user, tr)
         }
-        Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user),
+        Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user, tr),
     }
 }
 
 /// EDIT PROFILE ///
 
 #[debug_handler]
-pub async fn edit_user_get(Extension(user): Extension<Option<model::User>>) -> Response {
+pub async fn edit_user_get(
+    Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
+) -> Response {
     if let Some(user) = user {
         ProfileTemplate {
             username: user.name.clone(),
             email: user.email.clone(),
-            user: Some(user),
             message: String::new(),
             message_email: String::new(),
             message_password: String::new(),
+            user: Some(user),
+            tr,
         }
         .into_response()
     } else {
-        MessageTemplate::new("Not logged in").into_response()
+        MessageTemplate::new("Not logged in", tr).into_response()
     }
 }
 
@@ -552,6 +602,7 @@ pub async fn edit_user_post(
     State(connection): State<db::Connection>,
     State(config): State<Config>,
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
     Form(form_data): Form<EditUserForm>,
 ) -> Result<Response> {
     if let Some(user) = user {
@@ -559,6 +610,7 @@ pub async fn edit_user_post(
             error: ProfileUpdateError,
             form_data: &EditUserForm,
             user: model::User,
+            tr: translation::Tr,
         ) -> Result<Response> {
             Ok(ProfileTemplate {
                 user: Some(user),
@@ -584,6 +636,7 @@ pub async fn edit_user_post(
                     _ => "",
                 }
                 .to_string(),
+                tr,
             }
             .into_response())
         }
@@ -591,17 +644,17 @@ pub async fn edit_user_post(
         if let common::utils::EmailValidation::NotValid =
             common::utils::validate_email(&form_data.email)
         {
-            return error_response(ProfileUpdateError::InvalidEmail, &form_data, user);
+            return error_response(ProfileUpdateError::InvalidEmail, &form_data, user, tr);
         }
 
         let new_password = if !form_data.password_1.is_empty() || !form_data.password_2.is_empty() {
             if form_data.password_1 != form_data.password_2 {
-                return error_response(ProfileUpdateError::PasswordsNotEqual, &form_data, user);
+                return error_response(ProfileUpdateError::PasswordsNotEqual, &form_data, user, tr);
             }
             if let common::utils::PasswordValidation::TooShort =
                 common::utils::validate_password(&form_data.password_1)
             {
-                return error_response(ProfileUpdateError::InvalidPassword, &form_data, user);
+                return error_response(ProfileUpdateError::InvalidPassword, &form_data, user, tr);
             }
             Some(form_data.password_1.as_ref())
         } else {
@@ -621,7 +674,7 @@ pub async fn edit_user_post(
             .await
         {
             Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => {
-                return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user);
+                return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user, tr);
             }
             Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => {
                 let url = utils::get_url_from_host(&host);
@@ -644,14 +697,17 @@ pub async fn edit_user_post(
                     }
                     Err(_) => {
                         // error!("Email validation error: {}", error); // TODO: log
-                        return error_response(ProfileUpdateError::UnableSendEmail, &form_data, user);
+                        return error_response(
+                            ProfileUpdateError::UnableSendEmail, &form_data, user, tr);
                     }
                 }
             }
             Ok(db::user::UpdateUserResult::Ok) => {
                 message = "Profile saved";
             }
-            Err(_) => return error_response(ProfileUpdateError::DatabaseError, &form_data, user),
+            Err(_) => {
+                return error_response(ProfileUpdateError::DatabaseError, &form_data, user, tr)
+            }
         }
 
         // Reload after update.
@@ -664,10 +720,11 @@ pub async fn edit_user_post(
             message: message.to_string(),
             message_email: String::new(),
             message_password: String::new(),
+            tr,
         }
         .into_response())
     } else {
-        Ok(MessageTemplate::new("Not logged in").into_response())
+        Ok(MessageTemplate::new("Not logged in", tr).into_response())
     }
 }
 
@@ -675,6 +732,7 @@ pub async fn edit_user_post(
 pub async fn email_revalidation(
     State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
+    Extension(tr): Extension<translation::Tr>,
     ConnectInfo(addr): ConnectInfo<SocketAddr>,
     Query(query): Query<HashMap<String, String>>,
     headers: HeaderMap,
@@ -683,7 +741,7 @@ pub async fn email_revalidation(
     if user.is_some() {
         return Ok((
             jar,
-            MessageTemplate::new_with_user("User already exists", user),
+            MessageTemplate::new_with_user("User already exists", tr, user),
         ));
     }
     let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
@@ -705,13 +763,14 @@ pub async fn email_revalidation(
                     let user = connection.load_user(user_id).await?;
                     Ok((
                         jar,
-                        MessageTemplate::new_with_user("Email validation successful", user),
+                        MessageTemplate::new_with_user("Email validation successful", tr, user),
                     ))
                 }
                 db::user::ValidationResult::ValidationExpired => Ok((
                     jar,
                     MessageTemplate::new_with_user(
                         "The validation has expired. Try to sign up again with the same email",
+                        tr,
                         user,
                     ),
                 )),
@@ -719,6 +778,7 @@ pub async fn email_revalidation(
                     jar,
                     MessageTemplate::new_with_user(
                         "Validation error. Try to sign up again with the same email",
+                        tr,
                         user,
                     ),
                 )),
@@ -726,7 +786,7 @@ pub async fn email_revalidation(
         }
         None => Ok((
             jar,
-            MessageTemplate::new_with_user("Validation error", user),
+            MessageTemplate::new_with_user("Validation error", tr, user),
         )),
     }
 }
diff --git a/backend/src/translation.rs b/backend/src/translation.rs
new file mode 100644 (file)
index 0000000..97740cb
--- /dev/null
@@ -0,0 +1,142 @@
+use std::{fs::File, sync::LazyLock};
+
+use ron::de::from_reader;
+use rustc_hash::FxHashMap;
+use serde::Deserialize;
+use tracing::{event, Level};
+
+use crate::consts;
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Clone)]
+pub enum Sentence {
+    ProfileTitle,
+    MainTitle,
+    CreateNewRecipe,
+    UnpublishedRecipes,
+    UntitledRecipe,
+
+    EmailAddress,
+    Password,
+
+    // Sign in page.
+    SignInMenu,
+    SignInTitle,
+    SignInButton,
+    WrongEmailOrPassword,
+
+    // Sign up page.
+    SignUpMenu,
+    SignUpTitle,
+    SignUpButton,
+    ChooseAPassword,
+    ReEnterPassword,
+    AccountMustBeValidatedFirst,
+    InvalidEmail,
+    PasswordDontMatch,
+    InvalidPassword,
+    EmailAlreadyTaken,
+    UnableToSendEmail,
+
+    // Reset password page.
+    LostPassword,
+    AskResetButton,
+}
+
+#[derive(Clone)]
+pub struct Tr {
+    lang: &'static Language,
+}
+
+impl Tr {
+    pub fn new(code: &str) -> Self {
+        for lang in TRANSLATIONS.iter() {
+            if lang.code == code {
+                return Self { lang };
+            }
+        }
+
+        event!(
+            Level::WARN,
+            "Unable to find translation for language {}",
+            code
+        );
+
+        Tr::new("en")
+    }
+
+    pub fn t(&self, sentence: Sentence) -> String {
+        match self.lang.translation.get(&sentence) {
+            Some(str) => str.clone(),
+            None => format!(
+                "Translation missing, lang: {}/{}, element: {:?}",
+                self.lang.name, self.lang.code, sentence
+            ),
+        }
+    }
+
+    pub fn tp(&self, sentence: Sentence, params: &[Box<dyn ToString>]) -> String {
+        match self.lang.translation.get(&sentence) {
+            Some(str) => {
+                let mut result = str.clone();
+                for p in params {
+                    result = result.replacen("{}", &p.to_string(), 1);
+                }
+                result
+            }
+            None => format!(
+                "Translation missing, lang: {}/{}, element: {:?}",
+                self.lang.name, self.lang.code, sentence
+            ),
+        }
+    }
+
+    pub fn available_languages() -> Vec<(&'static str, &'static str)> {
+        TRANSLATIONS
+            .iter()
+            .map(|tr| (tr.code.as_ref(), tr.name.as_ref()))
+            .collect()
+    }
+
+    pub fn available_codes() -> Vec<&'static str> {
+        TRANSLATIONS.iter().map(|tr| tr.code.as_ref()).collect()
+    }
+}
+
+// #[macro_export]
+// macro_rules! t {
+//     ($self:expr, $str:expr) => {
+//         $self.t($str)
+//     };
+//     ($self:expr, $str:expr, $( $x:expr ),+ ) => {
+//         {
+//             let mut result = $self.t($str);
+//             $( result = result.replacen("{}", &$x.to_string(), 1); )+
+//             result
+//         }
+//     };
+// }
+
+#[derive(Debug, Deserialize, Clone)]
+struct Language {
+    code: String,
+    name: String,
+    translation: FxHashMap<Sentence, String>,
+}
+
+static TRANSLATIONS: LazyLock<Vec<Language>> =
+    LazyLock::new(|| match File::open(consts::TRANSLATION_FILE) {
+        Ok(file) => from_reader(file).unwrap_or_else(|error| {
+            panic!(
+                "Failed to read translation file {}: {}",
+                consts::TRANSLATION_FILE,
+                error
+            )
+        }),
+        Err(error) => {
+            panic!(
+                "Failed to open translation file {}: {}",
+                consts::TRANSLATION_FILE,
+                error
+            )
+        }
+    });
index 246c844..e670cee 100644 (file)
@@ -2,6 +2,7 @@
 
 {% block main_container %}
     <div class="content">
+        <h1></h1>
         <form action="/ask_reset_password" method="post">
             <label for="email_field">Your email address</label>
             <input id="email_field" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
index 558220b..e2de70c 100644 (file)
@@ -6,7 +6,7 @@
 
         {% match user %}
         {% when Some with (user) %}
-            <a class="create-recipe" href="/recipe/new" >Create a new recipe</a>
+            <a class="create-recipe" href="/recipe/new" >{{ tr.t(Sentence::CreateNewRecipe) }}</a>
             <span><a href="/user/edit">
                 {% if user.name == "" %}
                     {{ user.email }}
@@ -16,7 +16,7 @@
             </a> / <a href="/signout" />Sign out</a></span>
         {% when None %}
             <span>
-                <a href="/signin" >Sign in</a>/<a href="/signup">Sign up</a>/<a href="/ask_reset_password">Lost password</a>
+                <a href="/signin" >{{ tr.t(Sentence::SignInMenu) }}</a>/<a href="/signup">{{ tr.t(Sentence::SignUpMenu) }}</a>/<a href="/ask_reset_password">{{ tr.t(Sentence::LostPassword) }}</a>
             </span>
         {% endmatch %}
 
index d41f1da..4bfa738 100644 (file)
@@ -7,7 +7,7 @@
         <pre><code>
     {% endif %}
 
-    {{ message|markdown }}
+    {{ message }}
 
     {% if as_code %}
         </code></pre>
index 67bf5a4..72b042b 100644 (file)
@@ -6,7 +6,8 @@
 {% when Some with (user) %}
 
 <div class="content" id="user-edit">
-    <h1>Profile</h1>
+    <h1>{{ tr.t(Sentence::ProfileTitle) }}</h1>
+
     <form action="/user/edit" method="post">
 
         <label for="input-name">Name</label>
@@ -20,7 +21,9 @@
             autofocus="autofocus" />
 
         <label for="input-email">Email (need to be revalidated if changed)</label>
-        <input id="input-email" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
+        <input id="input-email" type="email"
+            name="email" value="{{ email }}"
+            autocapitalize="none" autocomplete="email" autofocus="autofocus" />
 
         {{ message_email }}
 
index 1be8187..7a5f7a6 100644 (file)
@@ -11,7 +11,7 @@
 
     {% if !recipe.description.is_empty() %}
         <div class="recipe-description" >
-        {{ recipe.description.clone()|markdown }}
+        {{ recipe.description.clone() }}
         </div>
     {% endif %}
 </div>
index 8132bef..3574398 100644 (file)
@@ -1,8 +1,7 @@
 {% macro recipe_item(id, title, class) %}
 <a href="/recipe/view/{{ id }}" class="{{ class }}" id="recipe-{{ id }}">
     {% if title == "" %}
-        {# TODO: Translation #}
-        Untitled recipe
+        {{ tr.t(Sentence::UntitledRecipe) }}
     {% else %}
         {{ title }}
     {% endif %}
@@ -13,7 +12,7 @@
 <div id="recipes-list">
 
     {% if !recipes.unpublished.is_empty() %}
-    Unpublished recipes
+    {{ tr.t(Sentence::UnpublishedRecipes) }}
     {% endif %}
 
     <nav class="recipes-list-unpublished">
index 76f6d5f..0f2d204 100644 (file)
@@ -4,16 +4,16 @@
 
 <div class="content" id="sign-in">
 
-    <h1>Sign in</h1>
+    <h1>{{ tr.t(Sentence::SignInTitle) }}</h1>
 
     <form action="/signin" method="post">
-        <label for="input-email">Email address</label>
+        <label for="input-email">{{ tr.t(Sentence::EmailAddress) }}</label>
         <input id="input-email" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
 
-        <label for="input-password">Password</label>
+        <label for="input-password">{{ tr.t(Sentence::Password) }}</label>
         <input id="input-password" type="password" name="password" autocomplete="current-password" />
 
-        <input type="submit" value="Sign in" />
+        <input type="submit" value="{{ tr.t(Sentence::SignInMenu) }}" />
     </form>
     {{ message }}
 </div>
index b35970a..ee53ba6 100644 (file)
@@ -4,23 +4,27 @@
 
 <div class="content" id="sign-up">
 
-    <h1>Sign up</h1>
+    <h1>{{ tr.t(Sentence::SignUpTitle) }}</h1>
 
     <form action="/signup" method="post">
-        <label for="input-email">Your email address</label>
-        <input id="input-email" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
+        <label for="input-email">{{ tr.t(Sentence::EmailAddress) }}</label>
+        <input id="input-email" type="email"
+            name="email" value="{{ email }}"
+            autocapitalize="none" autocomplete="email" autofocus="autofocus" />
 
         {{ message_email }}
 
-        <label for="input-password-1">Choose a password (minimum 8 characters)</label>
+        <label for="input-password-1">
+            {{ tr.tp(Sentence::ChooseAPassword, [Box::new(common::consts::MIN_PASSWORD_SIZE)]) }}
+        </label>
         <input id="input-password-1" type="password" name="password_1" autocomplete="new-password" />
 
-        <label for="input-password-2">Re-enter password</label>
+        <label for="input-password-2">{{ tr.t(Sentence::ReEnterPassword) }}</label>
         <input id="input-password-2" type="password" name="password_2" autocomplete="new-password" />
 
         {{ message_password }}
 
-        <input type="submit" name="commit" value="Sign up" />
+        <input type="submit" name="commit" value="{{ tr.t(Sentence::SignUpButton) }}" />
     </form>
     {{ message }}
 </div>
index 33f7014..192b705 100644 (file)
@@ -1 +1 @@
-<a class="title" href="/">~~ Recettes de cuisine ~~</a>
\ No newline at end of file
+<a class="title" href="/">{{ tr.t(Sentence::MainTitle) }}</a>
\ No newline at end of file
diff --git a/backend/translation.ron b/backend/translation.ron
new file mode 100644 (file)
index 0000000..4daa75b
--- /dev/null
@@ -0,0 +1,70 @@
+[
+    (
+        code: "en",
+        name: "English",
+        translation: {
+            ProfileTitle: "Profile",
+            MainTitle: "Cooking Recipes",
+            CreateNewRecipe: "Create a new recipe",
+            UnpublishedRecipes: "Unpublished recipes",
+            UntitledRecipe: "Untitled recipe",
+
+            EmailAddress: "Email address",
+            Password: "Password",
+
+            SignInMenu: "Sign in",
+            SignInTitle: "Sign in",
+            SignInButton: "Sign in",
+            WrongEmailOrPassword: "Wrong email or password",
+            AccountMustBeValidatedFirst: "This account must be validated first",
+            InvalidEmail: "Invalid email",
+            PasswordDontMatch: "Passwords don't match",
+            InvalidPassword: "Password must have at least {} characters",
+            EmailAlreadyTaken: "This email is not available",
+            UnableToSendEmail: "Unable to send the validation email",
+
+            SignUpMenu: "Sign up",
+            SignUpTitle: "Sign up",
+            SignUpButton: "Sign up",
+            ChooseAPassword: "Choose a password (minimum {} characters)",
+            ReEnterPassword: "Re-enter password",
+
+            LostPassword: "Lost password",
+            AskResetButton: "Ask reset",
+        }
+    ),
+    (
+        code: "fr",
+        name: "Français",
+        translation: {
+            ProfileTitle: "Profile",
+            MainTitle: "Recette de Cuisine",
+            CreateNewRecipe: "Créer une nouvelle recette",
+            UnpublishedRecipes: "Recettes non-publiés",
+            UntitledRecipe: "Recette sans nom",
+
+            EmailAddress: "Adresse email",
+            Password: "Mot de passe",
+
+            SignInMenu: "Se connecter",
+            SignInTitle: "Se connecter",
+            SignInButton: "Se connecter",
+            WrongEmailOrPassword: "Mot de passe ou email invalide",
+            AccountMustBeValidatedFirst: "Ce compte doit d'abord être validé",
+            InvalidEmail: "Adresse email invalide",
+            PasswordDontMatch: "Les mots de passe ne correspondent pas",
+            InvalidPassword: "Le mot de passe doit avoir au moins {} caractères",
+            EmailAlreadyTaken: "Cette adresse email n'est pas disponible",
+            UnableToSendEmail: "L'email de validation n'a pas pu être envoyé",
+
+            SignUpMenu: "S'inscrire",
+            SignUpTitle: "Inscription",
+            SignUpButton: "Valider",
+            ChooseAPassword: "Choisir un mot de passe (minimum {} caractères)",
+            ReEnterPassword: "Entrez à nouveau le mot de passe",
+
+            LostPassword: "Mot de passe perdu",
+            AskResetButton: "Demander la réinitialisation",
+        }
+    )
+]
\ No newline at end of file
diff --git a/common/src/consts.rs b/common/src/consts.rs
new file mode 100644 (file)
index 0000000..6861e36
--- /dev/null
@@ -0,0 +1 @@
+pub const MIN_PASSWORD_SIZE: usize = 8;
index 2a89f96..63c9595 100644 (file)
@@ -1,2 +1,3 @@
+pub mod consts;
 pub mod ron_api;
 pub mod utils;
index 7bf0f40..0b598cf 100644 (file)
@@ -2,6 +2,8 @@ use std::sync::LazyLock;
 
 use regex::Regex;
 
+use crate::consts;
+
 pub enum EmailValidation {
     Ok,
     NotValid,
@@ -28,7 +30,7 @@ pub enum PasswordValidation {
 }
 
 pub fn validate_password(password: &str) -> PasswordValidation {
-    if password.len() < 8 {
+    if password.len() < consts::MIN_PASSWORD_SIZE {
         PasswordValidation::TooShort
     } else {
         PasswordValidation::Ok
index 912f29f..b1e9792 100644 (file)
--- a/deploy.nu
+++ b/deploy.nu
@@ -29,6 +29,7 @@ def main [host: string, destination: string, ssh_key: path] {
     invoke_ssh [rm -rf recipes/static]
     copy_ssh ./backend/static/ $destination
     copy_ssh ./backend/sql/ $destination
+    copy_ssh ./backend/translation.ron $destination
     invoke_ssh [chmod u+x recipes/recipes]
     invoke_ssh [sudo systemctl start recipes]
     print "Deployment finished"
index f190eee..d523042 100644 (file)
@@ -6,12 +6,9 @@ use gloo::{
 };
 use wasm_bindgen::prelude::*;
 use wasm_bindgen_futures::spawn_local;
-use web_sys::{
-    Element, Event, HtmlDialogElement, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement,
-    KeyboardEvent,
-};
+use web_sys::{Element, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement, KeyboardEvent};
 
-use common::ron_api::{self, Ingredient};
+use common::ron_api;
 
 use crate::{
     modal_dialog, request,