Working localization

rust
Elnu 1 year ago
parent 245fcbcf1e
commit 3abc45cd4f

7
Cargo.lock generated

@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 3
[[package]]
name = "accept-language"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2bc21ffc9b77e9c31e733bb7e937c11dcf6157bb74f80bf94734110aa9b9ebc"
[[package]] [[package]]
name = "adler" name = "adler"
version = "1.0.2" version = "1.0.2"
@ -3104,6 +3110,7 @@ dependencies = [
name = "tegakituesday" name = "tegakituesday"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"accept-language",
"chrono", "chrono",
"comrak", "comrak",
"derive_more", "derive_more",

@ -6,6 +6,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
accept-language = "2.0.0"
chrono = { version = "0.4.26", features = ["serde"] } chrono = { version = "0.4.26", features = ["serde"] }
comrak = "0.18.0" comrak = "0.18.0"
derive_more = "0.99.17" derive_more = "0.99.17"

@ -1,2 +1,5 @@
msgid "lang"
msgstr "English"
msgid "title" msgid "title"
msgstr "Tegaki Tuesday" msgstr "Tegaki Tuesday"

@ -1,2 +1,5 @@
msgid "lang"
msgstr "日本語"
msgid "title" msgid "title"
msgstr "手書きの火曜日" msgstr "手書きの火曜日"

@ -8,3 +8,4 @@ pub mod user {
pub const USER_DISCRIMINATOR_COOKIE: &str = "user_discriminator"; pub const USER_DISCRIMINATOR_COOKIE: &str = "user_discriminator";
pub const USER_AVATAR_COOKIE: &str = "user_avatar"; pub const USER_AVATAR_COOKIE: &str = "user_avatar";
} }
pub const LANG_COOKIE: &str = "lang";

@ -1,5 +1,6 @@
use derive_more::From; use derive_more::From;
use rocket_dyn_templates::tera::{self, Value}; use rocket_dyn_templates::tera::{self, Value};
use serde::Serialize;
use std::{ use std::{
collections::HashMap, collections::HashMap,
fs::{self, File}, fs::{self, File},
@ -16,9 +17,11 @@ pub enum LoadCatalogsError {
MissingDefaultLanguage, MissingDefaultLanguage,
} }
const DEFAULT: &str = "ja"; pub const DEFAULT: &str = "ja";
pub fn load_catalogs() -> Result<HashMap<String, Catalog>, LoadCatalogsError> { type Catalogs = HashMap<String, Catalog>;
pub fn load_catalogs() -> Result<(Catalogs, Vec<LangCode>), LoadCatalogsError> {
let mut catalogs = HashMap::new(); let mut catalogs = HashMap::new();
for file in fs::read_dir("i18n")? { for file in fs::read_dir("i18n")? {
let file = file?; let file = file?;
@ -51,19 +54,58 @@ pub fn load_catalogs() -> Result<HashMap<String, Catalog>, LoadCatalogsError> {
if !catalogs.contains_key(DEFAULT) { if !catalogs.contains_key(DEFAULT) {
return Err(LoadCatalogsError::MissingDefaultLanguage); return Err(LoadCatalogsError::MissingDefaultLanguage);
} }
Ok(catalogs) let langs = catalogs
.iter()
.map(|(code, catalog)|
LangCode {
code: code.clone(),
name: catalog
.gettext("lang")
.to_owned(),
}
).collect();
Ok((catalogs, langs))
} }
pub fn i18n_filter( pub fn i18n_filter(
value: &Value, value: &Value,
_args: &HashMap<String, Value>, args: &HashMap<String, Value>,
catalogs: &HashMap<String, Catalog>, catalogs: &Catalogs,
) -> tera::Result<Value> { ) -> tera::Result<Value> {
let key = value let key = value
.as_str() .as_str()
.ok_or_else(|| tera::Error::msg("The translation key must be a string"))?; .ok_or_else(|| tera::Error::msg("The translation key must be a string"))?;
let translation = catalogs.get(DEFAULT).expect("Missing catalog").gettext(key); let langs = args
.get("lang")
.map(|value| value.as_array())
.flatten()
.map(|array| {
let mut langs = Vec::with_capacity(array.len());
for lang in array {
langs.push(lang.as_str().unwrap());
}
langs
})
.unwrap_or_else(|| vec![DEFAULT]);
for lang in langs {
if let Some(catalog) = catalogs.get(lang) {
return Ok(Value::String(catalog.gettext(key).to_owned()));
}
}
panic!("Missing catalog");
}
#[derive(Serialize)]
pub struct LangCode {
pub code: String,
pub name: String,
}
Ok(Value::String(translation.to_owned())) pub fn langs_filter(
_value: &Value,
_args: &HashMap<String, Value>,
langs: &Vec<LangCode>,
) -> tera::Result<Value> {
Ok(serde_json::to_value(langs).unwrap())
} }

@ -2,7 +2,7 @@
extern crate rocket; extern crate rocket;
use poise::serenity_prelude::Http; use poise::serenity_prelude::Http;
use rocket::fs::{relative, FileServer}; use rocket::{fs::{relative, FileServer}};
use rocket_dyn_templates::{tera, Template}; use rocket_dyn_templates::{tera, Template};
use sass_rocket_fairing::SassFairing; use sass_rocket_fairing::SassFairing;
use std::{collections::HashMap, env}; use std::{collections::HashMap, env};
@ -20,6 +20,8 @@ use routes::*;
mod i18n; mod i18n;
use i18n::{i18n_filter, load_catalogs}; use i18n::{i18n_filter, load_catalogs};
use crate::i18n::langs_filter;
mod prelude; mod prelude;
#[launch] #[launch]
@ -43,14 +45,20 @@ async fn rocket() -> _ {
routes![get_challenge, login, post_login, success, logout, testing], routes![get_challenge, login, post_login, success, logout, testing],
) )
.mount("/css", FileServer::from(relative!("styles/css"))) .mount("/css", FileServer::from(relative!("styles/css")))
.attach(Template::custom(|engines| { .attach(Template::custom(move |engines| {
use tera::Value; use tera::Value;
let catalogs = load_catalogs().unwrap(); let (catalogs, langs) = load_catalogs().unwrap();
engines.tera.register_filter( engines.tera.register_filter(
"i18n", "i18n",
move |value: &Value, args: &HashMap<String, Value>| { move |value: &Value, args: &HashMap<String, Value>| {
i18n_filter(value, args, &catalogs) i18n_filter(value, args, &catalogs)
}, },
);
engines.tera.register_filter(
"langs",
move |value: &Value, args: &HashMap<String, Value>| {
langs_filter(value, args, &langs)
}
) )
})) }))
.attach(SassFairing::default()) .attach(SassFairing::default())

@ -3,19 +3,25 @@ use std::ops::Deref;
use rocket::{http::CookieJar, State}; use rocket::{http::CookieJar, State};
use rocket_dyn_templates::{context, Template}; use rocket_dyn_templates::{context, Template};
use crate::models::{Challenge, Settings, User}; use crate::{models::{Challenge, Settings, User}, cookies::LANG_COOKIE, i18n::DEFAULT as DEFAULT_LANG, utils::AcceptLanguage};
#[get("/<challenge>")] #[get("/<challenge>")]
pub async fn get_challenge( pub async fn get_challenge(
challenge: u32, challenge: u32,
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
settings: &State<Settings>, settings: &State<Settings>,
accept_language: AcceptLanguage,
) -> Template { ) -> Template {
Template::render( Template::render(
"index", "index",
context! { context! {
challenge, challenge,
settings: settings.deref(), settings: settings.deref(),
lang: cookies
.get(LANG_COOKIE)
.map(|cookie| vec![cookie.value().to_owned()])
.or_else(|| accept_language.0)
.unwrap_or_else(|| vec![DEFAULT_LANG.to_owned()]),
user: User::get(cookies).await.unwrap(), user: User::get(cookies).await.unwrap(),
content: Challenge::get(challenge), content: Challenge::get(challenge),
}, },

@ -0,0 +1,30 @@
use std::convert::Infallible;
use rocket::{
request::{self, FromRequest},
Request,
};
macro_rules! header {
($name:ident, $header:expr) => {
header!($name, $header, String, |header| header.to_owned());
};
($name:ident, $header:expr, $type:ty, $handler:expr) => {
pub struct $name(pub Option<$type>);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for $name {
type Error = Infallible;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let header = req.headers().get_one($header);
request::Outcome::Success(Self(header.map($handler)))
}
}
};
}
header!(Referer, "Referer");
header!(AcceptLanguage, "Accept-Language", Vec<String>, |header| {
accept_language::parse(header)
});

@ -1,5 +1,5 @@
mod kyujitai; mod kyujitai;
pub use kyujitai::Kyujitai; pub use kyujitai::Kyujitai;
mod referer; mod headers;
pub use referer::Referer; pub use headers::*;

@ -1,18 +0,0 @@
use std::convert::Infallible;
use rocket::{
request::{self, FromRequest},
Request,
};
pub struct Referer(pub Option<String>);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Referer {
type Error = Infallible;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let referer = req.headers().get_one("Referer");
request::Outcome::Success(Referer(referer.map(|referer| referer.to_owned())))
}
}

@ -65,8 +65,8 @@ input[type=number] {
flex-direction: column; flex-direction: column;
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 0; right: 0;
width: 100%; min-width: 100%;
} }
&:not(:hover) .dropdown-content { &:not(:hover) .dropdown-content {
@ -74,7 +74,7 @@ input[type=number] {
} }
} }
& > *, .dropdown > a { & > *, .dropdown .link {
padding: 0.45em; padding: 0.45em;
& > * { & > * {
@ -82,7 +82,7 @@ input[type=number] {
} }
} }
a { a, .link {
color: var(--fg); color: var(--fg);
font-weight: bold; font-weight: bold;
} }
@ -98,7 +98,7 @@ input[type=number] {
} }
} }
.dropdown:hover > a, nav > a:hover { .dropdown:hover > .link, nav > .link:hover {
border: none; border: none;
--fg: #{$fg}; --fg: #{$fg};
--bg: #{$bg}; --bg: #{$bg};

@ -21,7 +21,7 @@
{% endif %} {% endif %}
{% if user %} {% if user %}
<div class="dropdown right"> <div class="dropdown right">
<a href="/users/{{ user.id }}"> <a href="/users/{{ user.id }}" class="link">
<span>{{ user.username }}</span> <img src="https://cdn.discordapp.com/avatars/{{ user.id }}/{{ user.avatar }}.webp?size=1024"> <span>{{ user.username }}</span> <img src="https://cdn.discordapp.com/avatars/{{ user.id }}/{{ user.avatar }}.webp?size=1024">
</a> </a>
<nav class="dropdown-content"> <nav class="dropdown-content">
@ -30,10 +30,22 @@
</a> </a>
</nav> </nav>
</div> </div>
{% else%} {% else%}
<a href="/login" class="right">Log in</a> <a href="/login" class="right">Log in</a>
{% endif %} {% endif %}
<div class="dropdown">
<span class="link">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6" style="height: 1.5em">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 21l5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 016-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 01-3.827-5.802" />
</svg>
</span>
<nav class="dropdown-content">
{% set langs = 0 | langs %}
{% for lang in langs %}
<span class="link" onclick="document.cookie = 'lang={{ lang.code }}; expires=Tue, 19 Jan 2038 03:14:07 UTC; SameSite=Strict; path=/'; location.reload()">{{ lang.name }}</span>
{% endfor %}
</nav>
</a>
</nav> </nav>
<div> <div>
<h1>Welcome to Tegaki Tuesday #{{ challenge }}!</h1> <h1>Welcome to Tegaki Tuesday #{{ challenge }}!</h1>

@ -1,5 +1,5 @@
<dialog> <dialog>
<h1>Welcome to {{ "title" | i18n }}!</h1> <h1>Welcome to {{ "title" | i18n(lang=lang) }}!</h1>
<p>In order to participate in challenges, you must be a member of a participating Discord server.</p> <p>In order to participate in challenges, you must be a member of a participating Discord server.</p>
<h2>Join a participating server</h2> <h2>Join a participating server</h2>
<div class="servers"> <div class="servers">

Loading…
Cancel
Save