[Database] Add 'creation_datetime' to User + some little things
authorGreg Burri <greg.burri@gmail.com>
Tue, 7 Jan 2025 22:55:16 +0000 (23:55 +0100)
committerGreg Burri <greg.burri@gmail.com>
Tue, 7 Jan 2025 22:55:16 +0000 (23:55 +0100)
16 files changed:
backend/sql/data_test.sql
backend/sql/version_1.sql
backend/src/data/db.rs [deleted file]
backend/src/data/db/mod.rs [new file with mode: 0644]
backend/src/data/db/recipe.rs
backend/src/data/db/user.rs
backend/src/html_templates.rs
backend/src/main.rs
backend/src/services.rs [deleted file]
backend/src/services/mod.rs [new file with mode: 0644]
backend/src/services/recipe.rs
backend/src/services/user.rs
backend/src/translation.rs
backend/src/utils.rs
frontend/src/handles.rs
frontend/src/lib.rs

index a00170c..5f37367 100644 (file)
@@ -1,18 +1,20 @@
-INSERT INTO [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+INSERT INTO [User] ([id], [email], [name], [creation_datetime], [password], [validation_token_datetime], [validation_token])
 VALUES (
     1,
     'paul@atreides.com',
     'Paul',
+    '2025-01-07T10:41:05.697884837+00:00',
     '$argon2id$v=19$m=4096,t=4,p=2$l1fAMRc0VfkNzqpEfFEReg$/gsUsY2aML8EbKjPeCxucenxkxhiFSXDmizWZPLvNuo',
     0,
     NULL
 );
 
-INSERT INTO [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+INSERT INTO [User] ([id], [email], [name], [creation_datetime], [password], [validation_token_datetime], [validation_token])
 VALUES (
     2,
     'alia@atreides.com',
     'Alia',
+    '2025-01-07T10:41:05.697884837+00:00',
     '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY',
     0,
     NULL
index 76c96cd..306f195 100644 (file)
@@ -7,8 +7,11 @@ CREATE TABLE [Version] (
 
 CREATE TABLE [User] (
     [id] INTEGER PRIMARY KEY,
+
     [email] TEXT NOT NULL,
     [name] TEXT NOT NULL DEFAULT '',
+    [creation_datetime] TEXT NOT NULL,
+
     [default_servings] INTEGER DEFAULT 4,
     [lang] TEXT NOT NULL DEFAULT 'en',
 
diff --git a/backend/src/data/db.rs b/backend/src/data/db.rs
deleted file mode 100644 (file)
index bf58047..0000000
+++ /dev/null
@@ -1,211 +0,0 @@
-use std::{
-    fmt,
-    fs::{self, File},
-    io::Read,
-    path::Path,
-    str::FromStr,
-    time::Duration,
-};
-
-use sqlx::{
-    sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
-    Pool, Sqlite, Transaction,
-};
-use thiserror::Error;
-use tracing::{event, Level};
-
-use crate::consts;
-
-pub mod recipe;
-pub mod user;
-
-const CURRENT_DB_VERSION: u32 = 1;
-
-#[derive(Error, Debug)]
-pub enum DBError {
-    #[error("Sqlx error: {0}")]
-    Sqlx(#[from] sqlx::Error),
-
-    #[error(
-        "Unsupported database version: {0} (application version: {current})",
-        current = CURRENT_DB_VERSION
-    )]
-    UnsupportedVersion(u32),
-
-    #[error("Unknown error: {0}")]
-    Other(String),
-}
-
-impl DBError {
-    fn from_dyn_error(error: Box<dyn std::error::Error>) -> Self {
-        DBError::Other(error.to_string())
-    }
-}
-
-type Result<T> = std::result::Result<T, DBError>;
-
-#[derive(Clone)]
-pub struct Connection {
-    pool: Pool<Sqlite>,
-}
-
-impl Connection {
-    pub async fn new() -> Result<Connection> {
-        let path = Path::new(consts::DB_DIRECTORY).join(consts::DB_FILENAME);
-        Self::new_from_file(path).await
-    }
-
-    #[cfg(test)]
-    pub async fn new_in_memory() -> Result<Connection> {
-        Self::create_connection(SqlitePoolOptions::new().connect("sqlite::memory:").await?).await
-    }
-
-    pub async fn new_from_file<P: AsRef<Path>>(file: P) -> Result<Connection> {
-        if let Some(data_dir) = file.as_ref().parent() {
-            if !data_dir.exists() {
-                fs::DirBuilder::new().create(data_dir).unwrap();
-            }
-        }
-
-        let options = SqliteConnectOptions::from_str(&format!(
-            "sqlite://{}",
-            file.as_ref().to_str().unwrap()
-        ))?
-        .journal_mode(SqliteJournalMode::Wal) // TODO: use 'Wal2' when available.
-        .create_if_missing(true)
-        .busy_timeout(Duration::from_secs(10))
-        .foreign_keys(true)
-        .synchronous(SqliteSynchronous::Normal);
-
-        Self::create_connection(
-            SqlitePoolOptions::new()
-                .max_connections(consts::MAX_DB_CONNECTION)
-                .connect_with(options)
-                .await?,
-        )
-        .await
-    }
-
-    async fn create_connection(pool: Pool<Sqlite>) -> Result<Connection> {
-        let connection = Connection { pool };
-        connection.create_or_update_db().await?;
-        Ok(connection)
-    }
-
-    async fn tx(&self) -> Result<Transaction<Sqlite>> {
-        self.pool.begin().await.map_err(DBError::from)
-    }
-
-    /// Called after the connection has been established for creating or updating the database.
-    /// The 'Version' table tracks the current state of the database.
-    async fn create_or_update_db(&self) -> Result<()> {
-        let mut tx = self.tx().await?; //con.transaction()?;
-
-        // Check current database version. (Version 0 corresponds to an empty database).
-        let mut version = match sqlx::query(
-            r#"
-SELECT [name] FROM [sqlite_master]
-WHERE [type] = 'table' AND [name] = 'Version'
-            "#,
-        )
-        .fetch_one(&mut *tx)
-        .await
-        {
-            Ok(_) => sqlx::query_scalar("SELECT [version] FROM [Version] ORDER BY [id] DESC")
-                .fetch_optional(&mut *tx)
-                .await?
-                .unwrap_or(0),
-            Err(_) => 0, // If the database doesn't exist.
-        };
-
-        while Self::update_to_next_version(version, &mut tx).await? {
-            version += 1;
-        }
-
-        tx.commit().await?;
-
-        Ok(())
-    }
-
-    async fn update_to_next_version(
-        current_version: u32,
-        tx: &mut Transaction<'_, Sqlite>,
-    ) -> Result<bool> {
-        let next_version = current_version + 1;
-
-        if next_version <= CURRENT_DB_VERSION {
-            event!(Level::INFO, "Update to version {}...", next_version);
-        }
-
-        async fn update_version(to_version: u32, tx: &mut Transaction<'_, Sqlite>) -> Result<()> {
-            sqlx::query(
-                "INSERT INTO [Version] ([version], [datetime]) VALUES ($1, datetime('now'))",
-            )
-            .bind(to_version)
-            .execute(&mut **tx)
-            .await?;
-            Ok(())
-        }
-
-        fn ok(updated: bool) -> Result<bool> {
-            if updated {
-                event!(Level::INFO, "Version updated");
-            }
-            Ok(updated)
-        }
-
-        match next_version {
-            1 => {
-                let sql_file = consts::SQL_FILENAME.replace("{VERSION}", &next_version.to_string());
-                sqlx::query(&load_sql_file(&sql_file)?)
-                    .execute(&mut **tx)
-                    .await?;
-                update_version(next_version, tx).await?;
-
-                ok(true)
-            }
-
-            // Version 2 doesn't exist yet.
-            2 => ok(false),
-
-            v => Err(DBError::UnsupportedVersion(v)),
-        }
-    }
-
-    /// Execute a given SQL file.
-    pub async fn execute_file<P: AsRef<Path> + fmt::Display>(&self, file: P) -> Result<()> {
-        let sql = load_sql_file(file)?;
-        sqlx::query(&sql)
-            .execute(&self.pool)
-            .await
-            .map(|_| ())
-            .map_err(DBError::from)
-    }
-
-    pub async fn execute_sql<'a>(
-        &self,
-        query: sqlx::query::Query<'a, Sqlite, sqlx::sqlite::SqliteArguments<'a>>,
-    ) -> Result<u64> {
-        query
-            .execute(&self.pool)
-            .await
-            .map(|db_result| db_result.rows_affected())
-            .map_err(DBError::from)
-    }
-
-    // pub async fn execute_sql_and_fetch_all<'a>(
-    //     &self,
-    //     query: sqlx::query::Query<'a, Sqlite, sqlx::sqlite::SqliteArguments<'a>>,
-    // ) -> Result<Vec<SqliteRow>> {
-    //     query.fetch_all(&self.pool).await.map_err(DBError::from)
-    // }
-}
-
-fn load_sql_file<P: AsRef<Path> + fmt::Display>(sql_file: P) -> Result<String> {
-    let mut file = File::open(&sql_file)
-        .map_err(|err| DBError::Other(format!("Cannot open SQL file ({}): {}", &sql_file, err)))?;
-    let mut sql = String::new();
-    file.read_to_string(&mut sql)
-        .map_err(|err| DBError::Other(format!("Cannot read SQL file ({}) : {}", &sql_file, err)))?;
-    Ok(sql)
-}
diff --git a/backend/src/data/db/mod.rs b/backend/src/data/db/mod.rs
new file mode 100644 (file)
index 0000000..bf58047
--- /dev/null
@@ -0,0 +1,211 @@
+use std::{
+    fmt,
+    fs::{self, File},
+    io::Read,
+    path::Path,
+    str::FromStr,
+    time::Duration,
+};
+
+use sqlx::{
+    sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
+    Pool, Sqlite, Transaction,
+};
+use thiserror::Error;
+use tracing::{event, Level};
+
+use crate::consts;
+
+pub mod recipe;
+pub mod user;
+
+const CURRENT_DB_VERSION: u32 = 1;
+
+#[derive(Error, Debug)]
+pub enum DBError {
+    #[error("Sqlx error: {0}")]
+    Sqlx(#[from] sqlx::Error),
+
+    #[error(
+        "Unsupported database version: {0} (application version: {current})",
+        current = CURRENT_DB_VERSION
+    )]
+    UnsupportedVersion(u32),
+
+    #[error("Unknown error: {0}")]
+    Other(String),
+}
+
+impl DBError {
+    fn from_dyn_error(error: Box<dyn std::error::Error>) -> Self {
+        DBError::Other(error.to_string())
+    }
+}
+
+type Result<T> = std::result::Result<T, DBError>;
+
+#[derive(Clone)]
+pub struct Connection {
+    pool: Pool<Sqlite>,
+}
+
+impl Connection {
+    pub async fn new() -> Result<Connection> {
+        let path = Path::new(consts::DB_DIRECTORY).join(consts::DB_FILENAME);
+        Self::new_from_file(path).await
+    }
+
+    #[cfg(test)]
+    pub async fn new_in_memory() -> Result<Connection> {
+        Self::create_connection(SqlitePoolOptions::new().connect("sqlite::memory:").await?).await
+    }
+
+    pub async fn new_from_file<P: AsRef<Path>>(file: P) -> Result<Connection> {
+        if let Some(data_dir) = file.as_ref().parent() {
+            if !data_dir.exists() {
+                fs::DirBuilder::new().create(data_dir).unwrap();
+            }
+        }
+
+        let options = SqliteConnectOptions::from_str(&format!(
+            "sqlite://{}",
+            file.as_ref().to_str().unwrap()
+        ))?
+        .journal_mode(SqliteJournalMode::Wal) // TODO: use 'Wal2' when available.
+        .create_if_missing(true)
+        .busy_timeout(Duration::from_secs(10))
+        .foreign_keys(true)
+        .synchronous(SqliteSynchronous::Normal);
+
+        Self::create_connection(
+            SqlitePoolOptions::new()
+                .max_connections(consts::MAX_DB_CONNECTION)
+                .connect_with(options)
+                .await?,
+        )
+        .await
+    }
+
+    async fn create_connection(pool: Pool<Sqlite>) -> Result<Connection> {
+        let connection = Connection { pool };
+        connection.create_or_update_db().await?;
+        Ok(connection)
+    }
+
+    async fn tx(&self) -> Result<Transaction<Sqlite>> {
+        self.pool.begin().await.map_err(DBError::from)
+    }
+
+    /// Called after the connection has been established for creating or updating the database.
+    /// The 'Version' table tracks the current state of the database.
+    async fn create_or_update_db(&self) -> Result<()> {
+        let mut tx = self.tx().await?; //con.transaction()?;
+
+        // Check current database version. (Version 0 corresponds to an empty database).
+        let mut version = match sqlx::query(
+            r#"
+SELECT [name] FROM [sqlite_master]
+WHERE [type] = 'table' AND [name] = 'Version'
+            "#,
+        )
+        .fetch_one(&mut *tx)
+        .await
+        {
+            Ok(_) => sqlx::query_scalar("SELECT [version] FROM [Version] ORDER BY [id] DESC")
+                .fetch_optional(&mut *tx)
+                .await?
+                .unwrap_or(0),
+            Err(_) => 0, // If the database doesn't exist.
+        };
+
+        while Self::update_to_next_version(version, &mut tx).await? {
+            version += 1;
+        }
+
+        tx.commit().await?;
+
+        Ok(())
+    }
+
+    async fn update_to_next_version(
+        current_version: u32,
+        tx: &mut Transaction<'_, Sqlite>,
+    ) -> Result<bool> {
+        let next_version = current_version + 1;
+
+        if next_version <= CURRENT_DB_VERSION {
+            event!(Level::INFO, "Update to version {}...", next_version);
+        }
+
+        async fn update_version(to_version: u32, tx: &mut Transaction<'_, Sqlite>) -> Result<()> {
+            sqlx::query(
+                "INSERT INTO [Version] ([version], [datetime]) VALUES ($1, datetime('now'))",
+            )
+            .bind(to_version)
+            .execute(&mut **tx)
+            .await?;
+            Ok(())
+        }
+
+        fn ok(updated: bool) -> Result<bool> {
+            if updated {
+                event!(Level::INFO, "Version updated");
+            }
+            Ok(updated)
+        }
+
+        match next_version {
+            1 => {
+                let sql_file = consts::SQL_FILENAME.replace("{VERSION}", &next_version.to_string());
+                sqlx::query(&load_sql_file(&sql_file)?)
+                    .execute(&mut **tx)
+                    .await?;
+                update_version(next_version, tx).await?;
+
+                ok(true)
+            }
+
+            // Version 2 doesn't exist yet.
+            2 => ok(false),
+
+            v => Err(DBError::UnsupportedVersion(v)),
+        }
+    }
+
+    /// Execute a given SQL file.
+    pub async fn execute_file<P: AsRef<Path> + fmt::Display>(&self, file: P) -> Result<()> {
+        let sql = load_sql_file(file)?;
+        sqlx::query(&sql)
+            .execute(&self.pool)
+            .await
+            .map(|_| ())
+            .map_err(DBError::from)
+    }
+
+    pub async fn execute_sql<'a>(
+        &self,
+        query: sqlx::query::Query<'a, Sqlite, sqlx::sqlite::SqliteArguments<'a>>,
+    ) -> Result<u64> {
+        query
+            .execute(&self.pool)
+            .await
+            .map(|db_result| db_result.rows_affected())
+            .map_err(DBError::from)
+    }
+
+    // pub async fn execute_sql_and_fetch_all<'a>(
+    //     &self,
+    //     query: sqlx::query::Query<'a, Sqlite, sqlx::sqlite::SqliteArguments<'a>>,
+    // ) -> Result<Vec<SqliteRow>> {
+    //     query.fetch_all(&self.pool).await.map_err(DBError::from)
+    // }
+}
+
+fn load_sql_file<P: AsRef<Path> + fmt::Display>(sql_file: P) -> Result<String> {
+    let mut file = File::open(&sql_file)
+        .map_err(|err| DBError::Other(format!("Cannot open SQL file ({}): {}", &sql_file, err)))?;
+    let mut sql = String::new();
+    file.read_to_string(&mut sql)
+        .map_err(|err| DBError::Other(format!("Cannot read SQL file ({}) : {}", &sql_file, err)))?;
+    Ok(sql)
+}
index 48a94f9..1dc5c8f 100644 (file)
@@ -628,14 +628,15 @@ mod tests {
             sqlx::query(
             r#"
 INSERT INTO [User]
-    ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+    ([id], [email], [name], [creation_datetime], [password], [validation_token_datetime], [validation_token])
 VALUES
-    ($1, $2, $3, $4, $5, $6)
+    ($1, $2, $3, $4, $5, $6, $7)
             "#
             )
             .bind(user_id)
             .bind("paul@atreides.com")
             .bind("paul")
+            .bind("")
             .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
             .bind("2022-11-29 22:05:04.121407300+00:00")
             .bind(None::<&str>) // 'null'.
index b960379..446bdf5 100644 (file)
@@ -222,11 +222,12 @@ WHERE [id] = $1
                 sqlx::query(
                     r#"
 INSERT INTO [User]
-([email], [validation_token], [validation_token_datetime], [password])
-VALUES ($1, $2, $3, $4)
+([email], [creation_datetime], [validation_token], [validation_token_datetime], [password])
+VALUES ($1, $2, $3, $4, $5)
                     "#,
                 )
                 .bind(email)
+                .bind(Utc::now())
                 .bind(&token)
                 .bind(datetime)
                 .bind(hashed_password)
@@ -509,11 +510,12 @@ mod tests {
             sqlx::query(
             r#"
 INSERT INTO
-    [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+    [User] ([id], [email], [name], [creation_datetime], [password], [validation_token_datetime], [validation_token])
     VALUES (
         1,
         'paul@atreides.com',
         'paul',
+        '',
         '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY',
         0,
         NULL
@@ -557,10 +559,11 @@ INSERT INTO
             sqlx::query(
             r#"
 INSERT INTO [User]
-    ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+    ([id], [email], [creation_datetime], [name], [password], [validation_token_datetime], [validation_token])
 VALUES (
     1,
     'paul@atreides.com',
+    '',
     'paul',
     '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY',
     0,
@@ -896,17 +899,11 @@ VALUES (
             sqlx::query(
             r#"
 INSERT INTO [User]
-    ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+    ([id], [email], [name], [creation_datetime], [password], [validation_token_datetime], [validation_token])
 VALUES
-    ($1, $2, $3, $4, $5, $6)
+    (1, 'paul@atreides.com', 'paul', '', '$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc', '2022-11-29 22:05:04.121407300+00:00', NULL)
             "#
             )
-            .bind(1)
-            .bind("paul@atreides.com")
-            .bind("paul")
-            .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
-            .bind("2022-11-29 22:05:04.121407300+00:00")
-            .bind(None::<&str>) // 'null'.
         ).await?;
 
         let user = connection.load_user(1).await?.unwrap();
index 1679e6a..a1fb570 100644 (file)
@@ -28,16 +28,16 @@ pub struct HomeTemplate {
 
 #[derive(Template)]
 #[template(path = "message.html")]
-pub struct MessageTemplate {
+pub struct MessageTemplate<'a> {
     pub user: Option<model::User>,
     pub tr: Tr,
 
-    pub message: String,
+    pub message: &'a str,
     pub as_code: bool, // Display the message in <pre> markup.
 }
 
-impl MessageTemplate {
-    pub fn new(message: String, tr: Tr) -> MessageTemplate {
+impl<'a> MessageTemplate<'a> {
+    pub fn new(message: &'a str, tr: Tr) -> MessageTemplate<'a> {
         MessageTemplate {
             user: None,
             tr,
@@ -46,7 +46,11 @@ impl MessageTemplate {
         }
     }
 
-    pub fn new_with_user(message: String, tr: Tr, user: Option<model::User>) -> MessageTemplate {
+    pub fn new_with_user(
+        message: &'a str,
+        tr: Tr,
+        user: Option<model::User>,
+    ) -> MessageTemplate<'a> {
         MessageTemplate {
             user,
             tr,
@@ -58,59 +62,59 @@ impl MessageTemplate {
 
 #[derive(Template)]
 #[template(path = "sign_up_form.html")]
-pub struct SignUpFormTemplate {
+pub struct SignUpFormTemplate<'a> {
     pub user: Option<model::User>,
     pub tr: Tr,
 
     pub email: String,
-    pub message: String,
-    pub message_email: String,
-    pub message_password: String,
+    pub message: &'a str,
+    pub message_email: &'a str,
+    pub message_password: &'a str,
 }
 
 #[derive(Template)]
 #[template(path = "sign_in_form.html")]
-pub struct SignInFormTemplate {
+pub struct SignInFormTemplate<'a> {
     pub user: Option<model::User>,
     pub tr: Tr,
 
-    pub email: String,
-    pub message: String,
+    pub email: &'a str,
+    pub message: &'a str,
 }
 
 #[derive(Template)]
 #[template(path = "ask_reset_password.html")]
-pub struct AskResetPasswordTemplate {
+pub struct AskResetPasswordTemplate<'a> {
     pub user: Option<model::User>,
     pub tr: Tr,
 
-    pub email: String,
-    pub message: String,
-    pub message_email: String,
+    pub email: &'a str,
+    pub message: &'a str,
+    pub message_email: &'a str,
 }
 
 #[derive(Template)]
 #[template(path = "reset_password.html")]
-pub struct ResetPasswordTemplate {
+pub struct ResetPasswordTemplate<'a> {
     pub user: Option<model::User>,
     pub tr: Tr,
 
-    pub reset_token: String,
-    pub message: String,
-    pub message_password: String,
+    pub reset_token: &'a str,
+    pub message: &'a str,
+    pub message_password: &'a str,
 }
 
 #[derive(Template)]
 #[template(path = "profile.html")]
-pub struct ProfileTemplate {
+pub struct ProfileTemplate<'a> {
     pub user: Option<model::User>,
     pub tr: Tr,
 
-    pub username: String,
-    pub email: String,
-    pub message: String,
-    pub message_email: String,
-    pub message_password: String,
+    pub username: &'a str,
+    pub email: &'a str,
+    pub message: &'a str,
+    pub message_email: &'a str,
+    pub message_password: &'a str,
 }
 
 #[derive(Template)]
index 274349a..90b169e 100644 (file)
@@ -245,7 +245,11 @@ async fn translation(
                     .map(|l| l.split('-').next().unwrap_or_default())
                     .find_or_first(|l| available_codes.contains(l));
 
-                accept_language.unwrap_or("en").to_string()
+                match accept_language {
+                    Some(lang) if !lang.is_empty() => lang,
+                    _ => translation::DEFAULT_LANGUAGE_CODE,
+                }
+                .to_string()
             }
         }
     };
diff --git a/backend/src/services.rs b/backend/src/services.rs
deleted file mode 100644 (file)
index 1a18e16..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-use axum::{
-    body, debug_handler,
-    extract::{Extension, Request, State},
-    http::{header, StatusCode},
-    middleware::Next,
-    response::{IntoResponse, Response, Result},
-};
-
-use crate::{
-    data::{db, model},
-    html_templates::*,
-    ron_utils, translation,
-};
-
-pub mod fragments;
-pub mod recipe;
-pub mod ron;
-pub mod user;
-
-// Will embed RON error in HTML page.
-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) {
-        if content_type == ron_utils::RON_CONTENT_TYPE {
-            let message = match body::to_bytes(response.into_body(), usize::MAX).await {
-                Ok(bytes) => String::from_utf8(bytes.to_vec()).unwrap_or_default(),
-                Err(error) => error.to_string(),
-            };
-            return Ok(MessageTemplate {
-                user: None,
-                message,
-                as_code: true,
-                tr,
-            }
-            .into_response());
-        }
-    }
-
-    Ok(response)
-}
-
-///// HOME /////
-
-#[debug_handler]
-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(tr.current_lang_code(), user.as_ref().map(|u| u.id))
-            .await?,
-        unpublished: if let Some(user) = user.as_ref() {
-            connection
-                .get_all_unpublished_recipe_titles(user.id)
-                .await?
-        } else {
-            vec![]
-        },
-        current_id: None,
-    };
-
-    Ok(HomeTemplate { user, recipes, tr })
-}
-
-///// 404 /////
-
-#[debug_handler]
-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".to_string(), tr, user),
-    )
-}
diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs
new file mode 100644 (file)
index 0000000..05aed48
--- /dev/null
@@ -0,0 +1,83 @@
+use axum::{
+    body, debug_handler,
+    extract::{Extension, Request, State},
+    http::{header, StatusCode},
+    middleware::Next,
+    response::{IntoResponse, Response, Result},
+};
+
+use crate::{
+    data::{db, model},
+    html_templates::*,
+    ron_utils, translation,
+};
+
+pub mod fragments;
+pub mod recipe;
+pub mod ron;
+pub mod user;
+
+// Will embed RON error in HTML page.
+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) {
+        if content_type == ron_utils::RON_CONTENT_TYPE {
+            let message = match body::to_bytes(response.into_body(), usize::MAX).await {
+                Ok(bytes) => String::from_utf8(bytes.to_vec()).unwrap_or_default(),
+                Err(error) => error.to_string(),
+            };
+            return Ok(MessageTemplate {
+                user: None,
+                message: &message,
+                as_code: true,
+                tr,
+            }
+            .into_response());
+        }
+    }
+
+    Ok(response)
+}
+
+///// HOME /////
+
+#[debug_handler]
+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(tr.current_lang_code(), user.as_ref().map(|u| u.id))
+            .await?,
+        unpublished: if let Some(user) = user.as_ref() {
+            connection
+                .get_all_unpublished_recipe_titles(user.id)
+                .await?
+        } else {
+            vec![]
+        },
+        current_id: None,
+    };
+
+    Ok(HomeTemplate { user, recipes, tr })
+}
+
+///// 404 /////
+
+#[debug_handler]
+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", tr, user),
+    )
+}
index 9887b2d..39d5778 100644 (file)
@@ -79,7 +79,7 @@ pub async fn view(
                 && (user.is_none() || recipe.user_id != user.as_ref().unwrap().id)
             {
                 return Ok(MessageTemplate::new_with_user(
-                    tr.tp(Sentence::RecipeNotAllowedToView, &[Box::new(recipe_id)]),
+                    &tr.tp(Sentence::RecipeNotAllowedToView, &[Box::new(recipe_id)]),
                     tr,
                     user,
                 )
index ae2b138..e54672e 100644 (file)
@@ -34,9 +34,9 @@ pub async fn sign_up_get(
         user,
         tr,
         email: String::new(),
-        message: String::new(),
-        message_email: String::new(),
-        message_password: String::new(),
+        message: "",
+        message_email: "",
+        message_password: "",
     })
 }
 
@@ -71,26 +71,27 @@ pub async fn sign_up_post(
         user: Option<model::User>,
         tr: translation::Tr,
     ) -> Result<Response> {
+        let invalid_password_mess = &tr.tp(
+            Sentence::InvalidPassword,
+            &[Box::new(common::consts::MIN_PASSWORD_SIZE)],
+        );
         Ok(SignUpFormTemplate {
             user,
             email: form_data.email.clone(),
             message_email: match error {
                 SignUpError::InvalidEmail => tr.t(Sentence::InvalidEmail),
-                _ => String::new(),
+                _ => "",
             },
             message_password: match error {
                 SignUpError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
-                SignUpError::InvalidPassword => tr.tp(
-                    Sentence::InvalidPassword,
-                    &[Box::new(common::consts::MIN_PASSWORD_SIZE)],
-                ),
-                _ => String::new(),
+                SignUpError::InvalidPassword => invalid_password_mess,
+                _ => "",
             },
             message: match error {
                 SignUpError::UserAlreadyExists => tr.t(Sentence::EmailAlreadyTaken),
-                SignUpError::DatabaseError => "Database error".to_string(),
+                SignUpError::DatabaseError => tr.t(Sentence::DatabaseError),
                 SignUpError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail),
-                _ => String::new(),
+                _ => "",
             },
             tr,
         }
@@ -235,8 +236,8 @@ pub async fn sign_in_get(
     Ok(SignInFormTemplate {
         user,
         tr,
-        email: String::new(),
-        message: String::new(),
+        email: "",
+        message: "",
     })
 }
 
@@ -271,7 +272,7 @@ pub async fn sign_in_post(
             jar,
             SignInFormTemplate {
                 user,
-                email: form_data.email,
+                email: &form_data.email,
                 message: tr.t(Sentence::AccountMustBeValidatedFirst),
                 tr,
             }
@@ -281,7 +282,7 @@ pub async fn sign_in_post(
             jar,
             SignInFormTemplate {
                 user,
-                email: form_data.email,
+                email: &form_data.email,
                 message: tr.t(Sentence::WrongEmailOrPassword),
                 tr,
             }
@@ -326,9 +327,9 @@ pub async fn ask_reset_password_get(
         Ok(AskResetPasswordTemplate {
             user,
             tr,
-            email: String::new(),
-            message: String::new(),
-            message_email: String::new(),
+            email: "",
+            message: "",
+            message_email: "",
         }
         .into_response())
     }
@@ -364,10 +365,10 @@ pub async fn ask_reset_password_post(
     ) -> Result<Response> {
         Ok(AskResetPasswordTemplate {
             user,
-            email: email.to_string(),
+            email,
             message_email: match error {
                 AskResetPasswordError::InvalidEmail => tr.t(Sentence::InvalidEmail),
-                _ => String::new(),
+                _ => "",
             },
             message: match error {
                 AskResetPasswordError::EmailAlreadyReset => {
@@ -376,7 +377,7 @@ pub async fn ask_reset_password_post(
                 AskResetPasswordError::EmailUnknown => tr.t(Sentence::EmailUnknown),
                 AskResetPasswordError::UnableSendEmail => tr.t(Sentence::UnableToSendResetEmail),
                 AskResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError),
-                _ => String::new(),
+                _ => "",
             },
             tr,
         }
@@ -470,9 +471,9 @@ pub async fn reset_password_get(
         Ok(ResetPasswordTemplate {
             user,
             tr,
-            reset_token: reset_token.to_string(),
-            message: String::new(),
-            message_password: String::new(),
+            reset_token,
+            message: "",
+            message_password: "",
         }
         .into_response())
     } else {
@@ -510,21 +511,22 @@ pub async fn reset_password_post(
         user: Option<model::User>,
         tr: translation::Tr,
     ) -> Result<Response> {
+        let reset_password_mess = &tr.tp(
+            Sentence::InvalidPassword,
+            &[Box::new(common::consts::MIN_PASSWORD_SIZE)],
+        );
         Ok(ResetPasswordTemplate {
             user,
-            reset_token: form_data.reset_token.clone(),
+            reset_token: &form_data.reset_token,
             message_password: match error {
                 ResetPasswordError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
-                ResetPasswordError::InvalidPassword => tr.tp(
-                    Sentence::InvalidPassword,
-                    &[Box::new(common::consts::MIN_PASSWORD_SIZE)],
-                ),
-                _ => String::new(),
+                ResetPasswordError::InvalidPassword => reset_password_mess,
+                _ => "",
             },
             message: match error {
                 ResetPasswordError::TokenExpired => tr.t(Sentence::AskResetTokenExpired),
                 ResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError),
-                _ => String::new(),
+                _ => "",
             },
             tr,
         }
@@ -571,12 +573,12 @@ pub async fn edit_user_get(
 ) -> Response {
     if let Some(user) = user {
         ProfileTemplate {
-            username: user.name.clone(),
-            email: user.email.clone(),
-            message: String::new(),
-            message_email: String::new(),
-            message_password: String::new(),
-            user: Some(user),
+            username: &user.name,
+            email: &user.email,
+            message: "",
+            message_email: "",
+            message_password: "",
+            user: Some(user.clone()),
             tr,
         }
         .into_response()
@@ -592,6 +594,7 @@ pub struct EditUserForm {
     password_1: String,
     password_2: String,
 }
+
 enum ProfileUpdateError {
     InvalidEmail,
     EmailAlreadyTaken,
@@ -618,27 +621,28 @@ pub async fn edit_user_post(
             user: model::User,
             tr: translation::Tr,
         ) -> Result<Response> {
+            let invalid_password_mess = &tr.tp(
+                Sentence::InvalidPassword,
+                &[Box::new(common::consts::MIN_PASSWORD_SIZE)],
+            );
             Ok(ProfileTemplate {
                 user: Some(user),
-                username: form_data.name.clone(),
-                email: form_data.email.clone(),
+                username: &form_data.name,
+                email: &form_data.email,
                 message_email: match error {
                     ProfileUpdateError::InvalidEmail => tr.t(Sentence::InvalidEmail),
                     ProfileUpdateError::EmailAlreadyTaken => tr.t(Sentence::EmailAlreadyTaken),
-                    _ => String::new(),
+                    _ => "",
                 },
                 message_password: match error {
                     ProfileUpdateError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
-                    ProfileUpdateError::InvalidPassword => tr.tp(
-                        Sentence::InvalidPassword,
-                        &[Box::new(common::consts::MIN_PASSWORD_SIZE)],
-                    ),
-                    _ => String::new(),
+                    ProfileUpdateError::InvalidPassword => invalid_password_mess,
+                    _ => "",
                 },
                 message: match error {
                     ProfileUpdateError::DatabaseError => tr.t(Sentence::DatabaseError),
                     ProfileUpdateError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail),
-                    _ => String::new(),
+                    _ => "",
                 },
                 tr,
             }
@@ -666,7 +670,7 @@ pub async fn edit_user_post(
         };
 
         let email_trimmed = form_data.email.trim();
-        let message: String;
+        let message: &str;
 
         match connection
             .update_user(
@@ -725,11 +729,11 @@ pub async fn edit_user_post(
 
         Ok(ProfileTemplate {
             user,
-            username: form_data.name,
-            email: form_data.email,
+            username: &form_data.name,
+            email: &form_data.email,
             message,
-            message_email: String::new(),
-            message_password: String::new(),
+            message_email: "",
+            message_password: "",
             tr,
         }
         .into_response())
index aede01d..8cf4489 100644 (file)
@@ -6,7 +6,7 @@ use strum::EnumCount;
 use strum_macros::EnumCount;
 use tracing::{event, Level};
 
-use crate::consts;
+use crate::{consts, utils};
 
 #[derive(Debug, Clone, EnumCount, Deserialize)]
 pub enum Sentence {
@@ -108,7 +108,8 @@ pub enum Sentence {
     RecipeIngredientComment,
 }
 
-const DEFAULT_LANGUAGE_CODE: &str = "en";
+pub const DEFAULT_LANGUAGE_CODE: &str = "en";
+pub const PLACEHOLDER_SUBSTITUTE: &str = "{}";
 
 #[derive(Clone)]
 pub struct Tr {
@@ -122,38 +123,21 @@ impl Tr {
         }
     }
 
-    pub fn t(&self, sentence: Sentence) -> String {
-        //&'static str {
-        self.lang.get(sentence).to_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 t(&self, sentence: Sentence) -> &'static str {
+        self.lang.get(sentence)
     }
 
     pub fn tp(&self, sentence: Sentence, params: &[Box<dyn ToString + Send>]) -> 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
-        //     ),
-        // }
         let text = self.lang.get(sentence);
-        let mut result = text.to_string();
-        for p in params {
-            result = result.replacen("{}", &p.to_string(), 1);
-        }
-        result
+        let params_as_string: Vec<String> = params.iter().map(|p| p.to_string()).collect();
+        utils::substitute(
+            text,
+            PLACEHOLDER_SUBSTITUTE,
+            &params_as_string
+                .iter()
+                .map(AsRef::as_ref)
+                .collect::<Vec<_>>(),
+        )
     }
 
     pub fn current_lang_code(&self) -> &str {
@@ -191,7 +175,6 @@ struct Language {
 
 impl Language {
     pub fn from_stored_language(stored_language: StoredLanguage) -> Self {
-        println!("!!!!!!!!!!!! {:?}", &stored_language.code);
         Self {
             code: stored_language.code,
             name: stored_language.name,
index f8715f3..fddeb71 100644 (file)
@@ -39,3 +39,44 @@ pub fn get_url_from_host(host: &str) -> String {
         host
     )
 }
+
+pub fn substitute(str: &str, pattern: &str, replacements: &[&str]) -> String {
+    let mut result = String::with_capacity(
+        (str.len() + replacements.iter().map(|s| s.len()).sum::<usize>())
+            .saturating_sub(pattern.len() * replacements.len()),
+    );
+
+    let mut i = 0;
+    for s in str.split(pattern) {
+        result.push_str(s);
+        if i < replacements.len() {
+            result.push_str(replacements[i]);
+        }
+        i += 1;
+    }
+
+    if i == 1 {
+        return str.to_string();
+    }
+
+    result
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_substitute() {
+        assert_eq!(substitute("", "", &[]), "");
+        assert_eq!(substitute("", "", &[""]), "");
+        assert_eq!(substitute("", "{}", &["a"]), "");
+        assert_eq!(substitute("a", "{}", &["b"]), "a");
+        assert_eq!(substitute("a{}", "{}", &["b"]), "ab");
+        assert_eq!(substitute("{}c", "{}", &["b"]), "bc");
+        assert_eq!(substitute("a{}c", "{}", &["b"]), "abc");
+        assert_eq!(substitute("{}b{}", "{}", &["a", "c"]), "abc");
+        assert_eq!(substitute("{}{}{}", "{}", &["a", "bc", "def"]), "abcdef");
+        assert_eq!(substitute("{}{}{}", "{}", &["a"]), "a");
+    }
+}
index 56b69ac..d05fa41 100644 (file)
@@ -34,7 +34,14 @@ async fn reload_recipes_list(current_recipe_id: i64) {
 pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
     // Title.
     {
-        let title: HtmlInputElement = by_id("input-title");
+        let Some(title) = document().get_element_by_id("input-title") else {
+            return Err(JsValue::from_str("Unable to find 'input-title' element"));
+        };
+
+        let title: HtmlInputElement = title.dyn_into().unwrap();
+
+        // Check if the recipe has been loaded.
+
         let mut current_title = title.value();
         EventListener::new(&title.clone(), "blur", move |_event| {
             if title.value() != current_title {
index 93c98b9..eced213 100644 (file)
@@ -5,7 +5,7 @@ mod request;
 mod toast;
 mod utils;
 
-use gloo::{events::EventListener, utils::window};
+use gloo::{console::log, events::EventListener, utils::window};
 use utils::by_id;
 use wasm_bindgen::prelude::*;
 use wasm_bindgen_futures::spawn_local;
@@ -22,7 +22,9 @@ pub fn main() -> Result<(), JsValue> {
 
     if let ["recipe", "edit", id] = path[..] {
         let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap.
-        handles::recipe_edit(id)?;
+        if let Err(error) = handles::recipe_edit(id) {
+            log!(error);
+        }
 
         // Disable: user editing data are now submitted as classic form data.
         // ["user", "edit"] => {