Avoid to use an hash map in translations

This commit is contained in:
Greg Burri 2025-01-07 16:47:24 +01:00
parent 03ebbb74fa
commit 91ab379718
10 changed files with 334 additions and 237 deletions

42
Cargo.lock generated
View file

@ -122,9 +122,9 @@ dependencies = [
[[package]]
name = "async-trait"
version = "0.1.84"
version = "0.1.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1244b10dcd56c92219da4e14caa97e312079e185f04ba3eea25061561dc0a0"
checksum = "3f934833b4b7233644e5848f235df3f57ed8c80f1528a26c3dfa13d2147fa056"
dependencies = [
"proc-macro2",
"quote",
@ -1756,18 +1756,18 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "pin-project"
version = "1.1.7"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be57f64e946e500c8ee36ef6331845d40a93055567ec57e8fae13efd33759b95"
checksum = "1e2ec53ad785f4d35dac0adea7f7dc6f1bb277ad84a680c7afefeae05d1f5916"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.7"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c"
checksum = "d56a66c0c55993aa927429d0f8a0abfd74f084e4d9c192cffed01e418d83eefb"
dependencies = [
"proc-macro2",
"quote",
@ -1776,9 +1776,9 @@ dependencies = [
[[package]]
name = "pin-project-lite"
version = "0.2.15"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pin-utils"
@ -1930,9 +1930,10 @@ dependencies = [
"rinja",
"rinja_axum",
"ron",
"rustc-hash",
"serde",
"sqlx",
"strum",
"strum_macros",
"thiserror 2.0.9",
"tokio",
"tower",
@ -2210,9 +2211,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.134"
version = "1.0.135"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d"
checksum = "2b0d7ba2887406110130a978386c4e1befb98c674b4fba677954e4db976630d9"
dependencies = [
"itoa",
"memchr",
@ -2572,6 +2573,25 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "subtle"
version = "2.6.1"

View file

@ -23,7 +23,6 @@ ron = "0.8"
serde = { version = "1.0", features = ["derive"] }
itertools = "0.14"
rustc-hash = "2.1"
clap = { version = "4", features = ["derive"] }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] }
@ -34,6 +33,8 @@ rinja_axum = "0.3"
argon2 = { version = "0.5", features = ["default", "std"] }
rand_core = { version = "0.6", features = ["std"] }
rand = "0.8"
strum = "0.26"
strum_macros = "0.26"
lettre = { version = "0.11", default-features = false, features = [
"smtp-transport",

View file

@ -76,6 +76,12 @@ body {
font-size: 0.5em;
}
.drag-handle {
width: 20px;
height: 20px;
background-color: gray;
}
.main-container {
display: flex;
flex-direction: row;

View file

@ -2,7 +2,7 @@ use rinja_axum::Template;
use crate::{
data::model,
translation::{Sentence, Tr},
translation::{self, Sentence, Tr},
};
pub struct Recipes {

View file

@ -231,7 +231,7 @@ async fn translation(
let language = if let Some(user) = user {
user.lang
} else {
let available_codes = Tr::available_codes();
let available_codes = translation::available_codes();
let jar = CookieJar::from_headers(req.headers());
match jar.get(consts::COOKIE_LANG_NAME) {
Some(lang) if available_codes.contains(&lang.value()) => lang.value().to_string(),

View file

@ -249,7 +249,7 @@ pub async fn set_language(
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeLanguage>,
) -> Result<StatusCode> {
if !crate::translation::Tr::available_codes()
if !crate::translation::available_codes()
.iter()
.any(|&l| l == ron.lang)
{

View file

@ -1,15 +1,16 @@
use std::{fs::File, sync::LazyLock};
use ron::de::from_reader;
use rustc_hash::FxHashMap;
use serde::Deserialize;
use strum::EnumCount;
use strum_macros::EnumCount;
use tracing::{event, Level};
use crate::consts;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Clone)]
#[derive(Debug, Clone, EnumCount, Deserialize)]
pub enum Sentence {
MainTitle,
MainTitle = 0,
CreateNewRecipe,
UnpublishedRecipes,
UntitledRecipe,
@ -107,6 +108,8 @@ pub enum Sentence {
RecipeIngredientComment,
}
const DEFAULT_LANGUAGE_CODE: &str = "en";
#[derive(Clone)]
pub struct Tr {
lang: &'static Language,
@ -114,61 +117,48 @@ pub struct Tr {
impl Tr {
pub fn new(code: &str) -> Self {
for lang in TRANSLATIONS.iter() {
if lang.code == code {
return Self { lang };
}
Self {
lang: get_language_translation(code),
}
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
),
}
//&'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 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
),
// 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
}
pub fn current_lang_code(&self) -> &str {
&self.lang.code
}
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]
@ -185,22 +175,98 @@ impl Tr {
// };
// }
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Deserialize)]
struct StoredLanguage {
code: String,
name: String,
translation: Vec<(Sentence, String)>,
}
#[derive(Debug)]
struct Language {
code: String,
name: String,
translation: FxHashMap<Sentence, String>,
translation: Vec<String>,
}
impl Language {
pub fn from_stored_language(stored_language: StoredLanguage) -> Self {
println!("!!!!!!!!!!!! {:?}", &stored_language.code);
Self {
code: stored_language.code,
name: stored_language.name,
translation: {
let mut translation = vec![String::new(); Sentence::COUNT];
for (sentence, text) in stored_language.translation {
translation[sentence as usize] = text;
}
translation
},
}
}
pub fn get(&'static self, sentence: Sentence) -> &'static str {
let text: &str = self
.translation
.get(sentence.clone() as usize)
.unwrap()
.as_ref();
if text.is_empty() && self.code != DEFAULT_LANGUAGE_CODE {
return get_language_translation(DEFAULT_LANGUAGE_CODE).get(sentence);
}
text
}
}
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()
}
fn get_language_translation(code: &str) -> &'static Language {
for lang in TRANSLATIONS.iter() {
if lang.code == code {
return lang;
}
}
event!(
Level::WARN,
"Unable to find translation for language {}",
code
);
if code != DEFAULT_LANGUAGE_CODE {
get_language_translation(DEFAULT_LANGUAGE_CODE)
} else {
// 'DEFAULT_LANGUAGE_CODE' must exist.
panic!("Unable to find language {}", code);
}
}
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
)
}),
Ok(file) => {
let stored_languages: Vec<StoredLanguage> = from_reader(file).unwrap_or_else(|error| {
{
panic!(
"Failed to read translation file {}: {}",
consts::TRANSLATION_FILE,
error
)
}
});
stored_languages
.into_iter()
.map(Language::from_stored_language)
.collect()
}
Err(error) => {
panic!(
"Failed to open translation file {}: {}",

View file

@ -21,7 +21,7 @@
{% endmatch %}
<select id="select-website-language">
{% for lang in Tr::available_languages() %}
{% for lang in translation::available_languages() %}
<option value="{{ lang.0 }}"
{%+ if tr.current_lang_code() == lang.0 %}
selected

View file

@ -59,7 +59,7 @@
<label for="select-language">{{ tr.t(Sentence::RecipeLanguage) }}</label>
<select id="select-language">
{% for lang in Tr::available_languages() %}
{% for lang in translation::available_languages() %}
<option value="{{ lang.0 }}"
{%+ if recipe.lang == lang.0 %}
selected
@ -86,6 +86,8 @@
<div id="hidden-templates">
<div class="group">
<div class="drag-handle"></div>
<label for="input-group-name">{{ tr.t(Sentence::RecipeGroupName) }}</label>
<input class="input-group-name" type="text" />
@ -100,6 +102,8 @@
</div>
<div class="step">
<div class="drag-handle"></div>
<label for="text-area-step-action">{{ tr.t(Sentence::RecipeStepAction) }}</label>
<textarea class="text-area-step-action"></textarea>

View file

@ -2,193 +2,193 @@
(
code: "en",
name: "English",
translation: {
MainTitle: "Cooking Recipes",
CreateNewRecipe: "Create a new recipe",
UnpublishedRecipes: "Unpublished recipes",
UntitledRecipe: "Untitled recipe",
translation: [
(MainTitle, "Cooking Recipes"),
(CreateNewRecipe, "Create a new recipe"),
(UnpublishedRecipes, "Unpublished recipes"),
(UntitledRecipe, "Untitled recipe"),
Name: "Name",
EmailAddress: "Email address",
Password: "Password",
(Name, "Name"),
(EmailAddress, "Email address"),
(Password, "Password"),
SignOut: "Sign out",
Save: "Save",
NotLoggedIn: "No logged in",
(SignOut, "Sign out"),
(Save, "Save"),
(NotLoggedIn, "No logged in"),
DatabaseError: "Database error",
(DatabaseError, "Database error"),
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",
(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"),
ValidationSuccessful: "Email validation successful",
ValidationExpired: "The validation has expired. Try to sign up again with the same email",
ValidationErrorTryToSignUpAgain: "Validation error. Try to sign up again with the same email",
ValidationError: "Validation error",
ValidationUserAlreadyExists: "User already exists",
(ValidationSuccessful, "Email validation successful"),
(ValidationExpired, "The validation has expired. Try to sign up again with the same email"),
(ValidationErrorTryToSignUpAgain, "Validation error. Try to sign up again with the same email"),
(ValidationError, "Validation error"),
(ValidationUserAlreadyExists, "User already exists"),
SignUpMenu: "Sign up",
SignUpTitle: "Sign up",
SignUpButton: "Sign up",
SignUpEmailSent: "An email has been sent, follow the link to validate your account",
SignUpFollowEmailLink: "Follow this link to confirm your inscription: {}",
SignUpEmailValidationSuccess: "Email validation successful, your account has been created",
SignUpValidationExpired: "The validation has expired. Try to sign up again",
SignUpValidationErrorTryAgain: "Validation error. Try to sign up again",
ChooseAPassword: "Choose a password (minimum {} characters)",
ReEnterPassword: "Re-enter password",
(SignUpMenu, "Sign up"),
(SignUpTitle, "Sign up"),
(SignUpButton, "Sign up"),
(SignUpEmailSent, "An email has been sent), follow the link to validate your account"),
(SignUpFollowEmailLink, "Follow this link to confirm your inscription, {}"),
(SignUpEmailValidationSuccess, "Email validation successful), your account has been created"),
(SignUpValidationExpired, "The validation has expired. Try to sign up again"),
(SignUpValidationErrorTryAgain, "Validation error. Try to sign up again"),
(ChooseAPassword, "Choose a password (minimum {} characters)"),
(ReEnterPassword, "Re-enter password"),
LostPassword: "Lost password",
AskResetButton: "Ask reset",
AskResetAlreadyLoggedInError: "Can't ask to reset password when already logged in",
AskResetEmailAlreadyResetError: "The password has already been reset for this email",
AskResetFollowEmailLink: "Follow this link to reset your password: {}",
AskResetEmailSent: "An email has been sent, follow the link to reset your password",
AskResetTokenMissing: "Reset token missing",
AskResetTokenExpired: "Token expired, try to reset password again",
PasswordReset: "Your password has been reset",
EmailUnknown: "Email unknown",
UnableToSendResetEmail: "Unable to send the reset password email",
(LostPassword, "Lost password"),
(AskResetButton, "Ask reset"),
(AskResetAlreadyLoggedInError, "Can't ask to reset password when already logged in"),
(AskResetEmailAlreadyResetError, "The password has already been reset for this email"),
(AskResetFollowEmailLink, "Follow this link to reset your password, {}"),
(AskResetEmailSent, "An email has been sent), follow the link to reset your password"),
(AskResetTokenMissing, "Reset token missing"),
(AskResetTokenExpired, "Token expired), try to reset password again"),
(PasswordReset, "Your password has been reset"),
(EmailUnknown, "Email unknown"),
(UnableToSendResetEmail, "Unable to send the reset password email"),
ProfileTitle: "Profile",
ProfileEmail: "Email (need to be revalidated if changed)",
ProfileNewPassword: "New password (minimum {} characters)",
ProfileFollowEmailLink: "Follow this link to validate this email address: {}",
ProfileEmailSent: "An email has been sent, follow the link to validate your new email",
ProfileSaved: "Profile saved",
(ProfileTitle, "Profile"),
(ProfileEmail, "Email (need to be revalidated if changed)"),
(ProfileNewPassword, "New password (minimum {} characters)"),
(ProfileFollowEmailLink, "Follow this link to validate this email address, {}"),
(ProfileEmailSent, "An email has been sent), follow the link to validate your new email"),
(ProfileSaved, "Profile saved"),
RecipeNotAllowedToEdit: "Not allowed to edit this recipe",
RecipeNotAllowedToView: "Not allowed the view the recipe {}",
RecipeNotFound: "Recipe not found",
RecipeTitle : "Title",
RecipeDescription : "Description",
RecipeServings : "Servings",
RecipeEstimatedTime : "Estimated time [min]",
RecipeDifficulty : "Difficulty",
RecipeDifficultyEasy : "Easy",
RecipeDifficultyMedium : "Medium",
RecipeDifficultyHard : "Hard",
RecipeTags : "Tags",
RecipeLanguage : "Language",
RecipeIsPublished : "Is published",
RecipeDelete : "Delete recipe",
RecipeAddAGroup : "Add a group",
RecipeRemoveGroup : "Remove group",
RecipeGroupName : "Name",
RecipeGroupComment : "Comment",
RecipeAddAStep : "Add a step",
RecipeRemoveStep : "Remove step",
RecipeStepAction : "Action",
RecipeAddAnIngredient : "Add an ingredient",
RecipeRemoveIngredient : "Remove ingredient",
RecipeIngredientName : "Name",
RecipeIngredientQuantity : "Quantity",
RecipeIngredientUnit : "Unit",
RecipeIngredientComment : "Comment",
}
(RecipeNotAllowedToEdit, "Not allowed to edit this recipe"),
(RecipeNotAllowedToView, "Not allowed the view the recipe {}"),
(RecipeNotFound, "Recipe not found"),
(RecipeTitle, "Title"),
(RecipeDescription, "Description"),
(RecipeServings, "Servings"),
(RecipeEstimatedTime, "Estimated time [min]"),
(RecipeDifficulty, "Difficulty"),
(RecipeDifficultyEasy, "Easy"),
(RecipeDifficultyMedium, "Medium"),
(RecipeDifficultyHard, "Hard"),
(RecipeTags, "Tags"),
(RecipeLanguage, "Language"),
(RecipeIsPublished, "Is published"),
(RecipeDelete, "Delete recipe"),
(RecipeAddAGroup, "Add a group"),
(RecipeRemoveGroup, "Remove group"),
(RecipeGroupName, "Name"),
(RecipeGroupComment, "Comment"),
(RecipeAddAStep, "Add a step"),
(RecipeRemoveStep, "Remove step"),
(RecipeStepAction, "Action"),
(RecipeAddAnIngredient, "Add an ingredient"),
(RecipeRemoveIngredient, "Remove ingredient"),
(RecipeIngredientName, "Name"),
(RecipeIngredientQuantity, "Quantity"),
(RecipeIngredientUnit, "Unit"),
(RecipeIngredientComment, "Comment"),
]
),
(
code: "fr",
name: "Français",
translation: {
MainTitle: "Recettes de Cuisine",
CreateNewRecipe: "Créer une nouvelle recette",
UnpublishedRecipes: "Recettes non-publiés",
UntitledRecipe: "Recette sans nom",
translation: [
(MainTitle, "Recettes de Cuisine"),
(CreateNewRecipe, "Créer une nouvelle recette"),
(UnpublishedRecipes, "Recettes non-publiés"),
(UntitledRecipe, "Recette sans nom"),
Name: "Nom",
EmailAddress: "Adresse email",
Password: "Mot de passe",
(Name, "Nom"),
(EmailAddress, "Adresse email"),
(Password, "Mot de passe"),
SignOut: "Se déconnecter",
Save: "Sauvegarder",
NotLoggedIn: "Pas connecté",
(SignOut, "Se déconnecter"),
(Save, "Sauvegarder"),
(NotLoggedIn, "Pas connecté"),
DatabaseError: "Erreur de la base de données",
(DatabaseError, "Erreur de la base de données"),
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é",
(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é"),
ValidationSuccessful: "Email validé avec succès",
ValidationExpired: "La validation a expiré. Essayez de vous inscrire à nouveau avec la même adresse email",
ValidationErrorTryToSignUpAgain: "Erreur de validation. Essayez de vous inscrire à nouveau avec la même adresse email",
ValidationError: "Erreur de validation",
ValidationUserAlreadyExists: "Utilisateur déjà existant",
(ValidationSuccessful, "Email validé avec succès"),
(ValidationExpired, "La validation a expiré. Essayez de vous inscrire à nouveau avec la même adresse email"),
(ValidationErrorTryToSignUpAgain, "Erreur de validation. Essayez de vous inscrire à nouveau avec la même adresse email"),
(ValidationError, "Erreur de validation"),
(ValidationUserAlreadyExists, "Utilisateur déjà existant"),
SignUpMenu: "S'inscrire",
SignUpTitle: "Inscription",
SignUpButton: "Valider",
SignUpEmailSent: "Un email a été envoyé, suivez le lien pour valider votre compte",
SignUpFollowEmailLink: "Suivez ce lien pour valider votre inscription: {}",
SignUpEmailValidationSuccess: "La validation de votre email s'est déroulée avec succès, votre compte a été créé",
SignUpValidationExpired: "La validation a expiré. Essayez de vous inscrire à nouveau",
SignUpValidationErrorTryAgain: "Erreur de validation. Essayez de vous inscrire à nouveau",
ChooseAPassword: "Choisir un mot de passe (minimum {} caractères)",
ReEnterPassword: "Entrez à nouveau le mot de passe",
(SignUpMenu, "S'inscrire"),
(SignUpTitle, "Inscription"),
(SignUpButton, "Valider"),
(SignUpEmailSent, "Un email a été envoyé), suivez le lien pour valider votre compte"),
(SignUpFollowEmailLink, "Suivez ce lien pour valider votre inscription, {}"),
(SignUpEmailValidationSuccess, "La validation de votre email s'est déroulée avec succès), votre compte a été créé"),
(SignUpValidationExpired, "La validation a expiré. Essayez de vous inscrire à nouveau"),
(SignUpValidationErrorTryAgain, "Erreur de validation. Essayez de vous inscrire à nouveau"),
(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",
AskResetAlreadyLoggedInError: "Impossible de demander une réinitialisation du mot de passe lorsque déjà connecté",
AskResetEmailAlreadyResetError: "Le mot de passe a déjà été réinitialisé pour cette adresse email",
AskResetFollowEmailLink: "Suivez ce lien pour réinitialiser votre mot de passe: {}",
AskResetEmailSent: "Un email a été envoyé, suivez le lien pour réinitialiser votre mot de passe",
AskResetTokenMissing: "Jeton de réinitialisation manquant",
AskResetTokenExpired: "Jeton expiré, essayez de réinitialiser votre mot de passe à nouveau",
PasswordReset: "Votre mot de passe a été réinitialisé",
EmailUnknown: "Email inconnu",
UnableToSendResetEmail: "Impossible d'envoyer l'email pour la réinitialisation du mot de passe",
(LostPassword, "Mot de passe perdu"),
(AskResetButton, "Demander la réinitialisation"),
(AskResetAlreadyLoggedInError, "Impossible de demander une réinitialisation du mot de passe lorsque déjà connecté"),
(AskResetEmailAlreadyResetError, "Le mot de passe a déjà été réinitialisé pour cette adresse email"),
(AskResetFollowEmailLink, "Suivez ce lien pour réinitialiser votre mot de passe, {}"),
(AskResetEmailSent, "Un email a été envoyé), suivez le lien pour réinitialiser votre mot de passe"),
(AskResetTokenMissing, "Jeton de réinitialisation manquant"),
(AskResetTokenExpired, "Jeton expiré), essayez de réinitialiser votre mot de passe à nouveau"),
(PasswordReset, "Votre mot de passe a été réinitialisé"),
(EmailUnknown, "Email inconnu"),
(UnableToSendResetEmail, "Impossible d'envoyer l'email pour la réinitialisation du mot de passe"),
ProfileTitle: "Profile",
ProfileEmail: "Email (doit être revalidé si changé)",
ProfileNewPassword: "Nouveau mot de passe (minimum {} caractères)",
ProfileFollowEmailLink: "Suivez ce lien pour valider l'adresse email: {}",
ProfileEmailSent: "Un email a été envoyé, suivez le lien pour valider la nouvelle adresse email",
ProfileSaved: "Profile sauvegardé",
(ProfileTitle, "Profile"),
(ProfileEmail, "Email (doit être revalidé si changé)"),
(ProfileNewPassword, "Nouveau mot de passe (minimum {} caractères)"),
(ProfileFollowEmailLink, "Suivez ce lien pour valider l'adresse email, {}"),
(ProfileEmailSent, "Un email a été envoyé), suivez le lien pour valider la nouvelle adresse email"),
(ProfileSaved, "Profile sauvegardé"),
RecipeNotAllowedToEdit: "Vous n'êtes pas autorisé à éditer cette recette",
RecipeNotAllowedToView: "Vous n'êtes pas autorisé à voir la recette {}",
RecipeNotFound: "Recette non-trouvée",
RecipeTitle : "Titre",
RecipeDescription : "Description",
RecipeServings : "Nombre de personnes",
RecipeEstimatedTime : "Temps estimé",
RecipeDifficulty : "Difficulté",
RecipeDifficultyEasy : "Facile",
RecipeDifficultyMedium : "Moyen",
RecipeDifficultyHard : "Difficile",
RecipeTags : "Tags",
RecipeLanguage : "Langue",
RecipeIsPublished : "Est publié",
RecipeDelete : "Supprimer la recette",
RecipeAddAGroup : "Ajouter un groupe",
RecipeRemoveGroup : "Supprimer le groupe",
RecipeGroupName : "Nom",
RecipeGroupComment : "Commentaire",
RecipeAddAStep : "Ajouter une étape",
RecipeRemoveStep : "Supprimer l'étape",
RecipeStepAction : "Action",
RecipeAddAnIngredient : "Ajouter un ingrédient",
RecipeRemoveIngredient : "Supprimer l'ingrédient",
RecipeIngredientName : "Nom",
RecipeIngredientQuantity : "Quantité",
RecipeIngredientUnit : "Unité",
RecipeIngredientComment : "Commentaire",
}
(RecipeNotAllowedToEdit, "Vous n'êtes pas autorisé à éditer cette recette"),
(RecipeNotAllowedToView, "Vous n'êtes pas autorisé à voir la recette {}"),
(RecipeNotFound, "Recette non-trouvée"),
(RecipeTitle, "Titre"),
(RecipeDescription, "Description"),
(RecipeServings, "Nombre de personnes"),
(RecipeEstimatedTime, "Temps estimé"),
(RecipeDifficulty, "Difficulté"),
(RecipeDifficultyEasy, "Facile"),
(RecipeDifficultyMedium, "Moyen"),
(RecipeDifficultyHard, "Difficile"),
(RecipeTags, "Tags"),
(RecipeLanguage, "Langue"),
(RecipeIsPublished, "Est publié"),
(RecipeDelete, "Supprimer la recette"),
(RecipeAddAGroup, "Ajouter un groupe"),
(RecipeRemoveGroup, "Supprimer le groupe"),
(RecipeGroupName, "Nom"),
(RecipeGroupComment, "Commentaire"),
(RecipeAddAStep, "Ajouter une étape"),
(RecipeRemoveStep, "Supprimer l'étape"),
(RecipeStepAction, "Action"),
(RecipeAddAnIngredient, "Ajouter un ingrédient"),
(RecipeRemoveIngredient, "Supprimer l'ingrédient"),
(RecipeIngredientName, "Nom"),
(RecipeIngredientQuantity, "Quantité"),
(RecipeIngredientUnit, "Unité"),
(RecipeIngredientComment, "Commentaire"),
]
)
]