Skip to content

Commit bc94a04

Browse files
committed
Add i18n support and German translations
Fixes #27.
1 parent db847f5 commit bc94a04

30 files changed

Lines changed: 486 additions & 138 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
- Use the paste key to correctly set the window target, i.e. going to raw view
1010
from Markdown view works.
1111

12+
### Added
13+
14+
- Internationalization (i18n) support and initial German translation.
15+
1216

1317
## 3.6.1
1418

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/wastebin_server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ hex = "0.4"
1616
hostname = "0.4.0"
1717
http = "1.3"
1818
mime = "0.3"
19+
phf = { version = "0.11", features = ["macros"] }
1920
qrcodegen = "1"
2021
sha2 = "0.11"
2122
serde = { workspace = true }

crates/wastebin_server/src/handlers/delete/form.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use axum::response::Redirect;
33

44
use crate::handlers::extract::{Theme, Uid};
55
use crate::handlers::html::{ErrorResponse, make_error};
6+
use crate::i18n::Lang;
67
use crate::{Database, Page};
78

89
pub async fn delete(
@@ -11,14 +12,15 @@ pub async fn delete(
1112
State(page): State<Page>,
1213
Uid(uid): Uid,
1314
theme: Option<Theme>,
15+
lang: Lang,
1416
) -> Result<Redirect, ErrorResponse> {
1517
async {
1618
let id = id.parse()?;
1719
db.delete_for(id, uid).await?;
1820
Ok(Redirect::to("/"))
1921
}
2022
.await
21-
.map_err(|err| make_error(err, page.clone(), theme))
23+
.map_err(|err| make_error(err, page.clone(), theme, lang))
2224
}
2325

2426
#[cfg(test)]

crates/wastebin_server/src/handlers/download.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::Page;
99
use crate::cache::Key;
1010
use crate::handlers::extract::{Password, Theme};
1111
use crate::handlers::html::{ErrorResponse, PasswordInput, make_error};
12+
use crate::i18n::Lang;
1213
use wastebin_core::db::read::{Data, Entry};
1314
use wastebin_core::db::{self, Database};
1415

@@ -18,6 +19,7 @@ pub async fn get(
1819
State(db): State<Database>,
1920
State(page): State<Page>,
2021
theme: Option<Theme>,
22+
lang: Lang,
2123
password: Option<Password>,
2224
) -> Result<Response, ErrorResponse> {
2325
async {
@@ -31,14 +33,15 @@ pub async fn get(
3133
Err(db::Error::NoPassword) => Ok(PasswordInput {
3234
page: page.clone(),
3335
theme: theme.clone(),
36+
lang,
3437
id: key.id.to_string(),
3538
}
3639
.into_response()),
3740
Err(err) => Err(err.into()),
3841
}
3942
}
4043
.await
41-
.map_err(|err| make_error(err, page, theme))
44+
.map_err(|err| make_error(err, page, theme, lang))
4245
}
4346

4447
fn make_content_disposition(filename: &str) -> HeaderValue {

crates/wastebin_server/src/handlers/extract.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use serde::Deserialize;
1212

1313
use wastebin_core::crypto;
1414

15+
use crate::i18n::Lang;
16+
1517
/// A safe redirect back to the referer.
1618
///
1719
/// Extracts the `Referer` header and strips it down to just the path (and query string),
@@ -192,3 +194,84 @@ where
192194
.map(|data| Password(data.password.as_bytes().to_vec().into())))
193195
}
194196
}
197+
198+
/// Map a single language tag (e.g. `en`, `de-AT`) to a supported [`Lang`].
199+
fn lang_from_tag(tag: &str) -> Option<Lang> {
200+
match tag.split('-').next()?.trim() {
201+
"en" | "eN" | "En" | "EN" => Some(Lang::En),
202+
"de" | "dE" | "De" | "DE" => Some(Lang::De),
203+
_ => None,
204+
}
205+
}
206+
207+
/// Pick the best supported language from an `Accept-Language` header value,
208+
/// honoring `q=` weights. Falls back to the default language if nothing
209+
/// matches.
210+
fn lang_from_accept_language(header: &str) -> Lang {
211+
header
212+
.split(',')
213+
.enumerate()
214+
.filter_map(|(idx, entry)| {
215+
let mut parts = entry.split(';');
216+
let tag = parts.next().map(str::trim).filter(|t| !t.is_empty())?;
217+
let lang = lang_from_tag(tag)?;
218+
219+
let q = parts
220+
.find_map(|p| {
221+
let p = p.trim();
222+
p.strip_prefix("q=").or_else(|| p.strip_prefix("Q="))
223+
})
224+
.and_then(|s| s.parse::<f32>().ok())
225+
.unwrap_or(1.0);
226+
227+
// Use position as a tie-breaker so the first listed entry wins
228+
// when weights are equal.
229+
#[expect(clippy::cast_precision_loss)]
230+
let weighted = q - (idx as f32) * 1e-6;
231+
Some((weighted, lang))
232+
})
233+
.max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
234+
.map_or(Lang::default(), |(_, l)| l)
235+
}
236+
237+
impl<S> FromRequestParts<S> for Lang
238+
where
239+
S: Send + Sync,
240+
{
241+
type Rejection = Infallible;
242+
243+
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
244+
Ok(parts
245+
.headers
246+
.get(http::header::ACCEPT_LANGUAGE)
247+
.and_then(|v| v.to_str().ok())
248+
.map_or_else(Lang::default, lang_from_accept_language))
249+
}
250+
}
251+
252+
#[cfg(test)]
253+
mod tests {
254+
use super::*;
255+
256+
#[test]
257+
fn picks_highest_q() {
258+
assert_eq!(lang_from_accept_language("en;q=0.5,de;q=0.9"), Lang::De);
259+
}
260+
261+
#[test]
262+
fn defaults_to_english_when_unsupported() {
263+
assert_eq!(lang_from_accept_language("ja,fr;q=0.7"), Lang::En);
264+
}
265+
266+
#[test]
267+
fn handles_region_subtags() {
268+
assert_eq!(lang_from_accept_language("de-AT"), Lang::De);
269+
}
270+
271+
#[test]
272+
fn first_listed_wins_on_tie() {
273+
// Both implicit q=1.0; first listed should win.
274+
assert_eq!(lang_from_accept_language("de,en"), Lang::De);
275+
assert_eq!(lang_from_accept_language("en,de"), Lang::En);
276+
}
277+
}

crates/wastebin_server/src/handlers/html/burn.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ use crate::cache::Key;
66
use crate::handlers::extract::Theme;
77
use crate::handlers::html::qr::{code_from, dark_modules};
88
use crate::handlers::html::{ErrorResponse, make_error};
9+
use crate::i18n::Lang;
910
use crate::{Error, Page};
1011

1112
/// GET handler for the burn page.
1213
pub async fn get(
1314
Path(id): Path<String>,
1415
State(page): State<Page>,
1516
theme: Option<Theme>,
17+
lang: Lang,
1618
) -> Result<Burn, ErrorResponse> {
1719
async {
1820
let key: Key = id.parse()?;
@@ -29,10 +31,11 @@ pub async fn get(
2931
key,
3032
code,
3133
theme: theme.clone(),
34+
lang,
3235
})
3336
}
3437
.await
35-
.map_err(|err| make_error(err, page, theme))
38+
.map_err(|err| make_error(err, page, theme, lang))
3639
}
3740

3841
/// Burn page shown if "burn-after-reading" was selected during insertion.
@@ -43,6 +46,7 @@ pub(crate) struct Burn {
4346
key: Key,
4447
code: qrcodegen::QrCode,
4548
theme: Option<Theme>,
49+
lang: Lang,
4650
}
4751

4852
impl Burn {

crates/wastebin_server/src/handlers/html/index.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@ use askama::Template;
22
use askama_web::WebTemplate;
33
use axum::extract::State;
44

5+
use crate::i18n::Lang;
56
use crate::{Highlighter, Page, handlers::extract::Theme};
67

78
/// GET handler for the index page.
89
pub async fn get(
910
State(page): State<Page>,
1011
State(highlighter): State<Highlighter>,
1112
theme: Option<Theme>,
13+
lang: Lang,
1214
) -> Index {
1315
Index {
1416
page,
1517
theme,
18+
lang,
1619
highlighter,
1720
}
1821
}
@@ -23,5 +26,6 @@ pub async fn get(
2326
pub(crate) struct Index {
2427
page: Page,
2528
theme: Option<Theme>,
29+
lang: Lang,
2630
highlighter: Highlighter,
2731
}

crates/wastebin_server/src/handlers/html/mod.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ use axum::http::StatusCode;
1010

1111
use crate::Page;
1212
use crate::handlers::extract::Theme;
13+
use crate::i18n::Lang;
1314

1415
/// Error page showing a message.
1516
#[derive(Template, WebTemplate)]
1617
#[template(path = "error.html")]
1718
pub(crate) struct Error {
1819
pub page: Page,
1920
pub theme: Option<Theme>,
21+
pub lang: Lang,
2022
pub description: String,
2123
}
2224

@@ -26,6 +28,7 @@ pub(crate) struct Error {
2628
pub(crate) struct PasswordInput {
2729
pub page: Page,
2830
pub theme: Option<Theme>,
31+
pub lang: Lang,
2932
pub id: String,
3033
}
3134

@@ -35,6 +38,7 @@ pub(crate) struct PasswordInput {
3538
pub(crate) struct BurnConfirmation {
3639
pub page: Page,
3740
pub theme: Option<Theme>,
41+
pub lang: Lang,
3842
pub id: String,
3943
pub title: Option<String>,
4044
}
@@ -44,13 +48,19 @@ pub(crate) type ErrorResponse = (StatusCode, Error);
4448

4549
/// Create an error response from `error` consisting of [`StatusCode`] derive from `error` as well
4650
/// as a rendered page with a description.
47-
pub fn make_error(error: crate::Error, page: Page, theme: Option<Theme>) -> ErrorResponse {
51+
pub fn make_error(
52+
error: crate::Error,
53+
page: Page,
54+
theme: Option<Theme>,
55+
lang: Lang,
56+
) -> ErrorResponse {
4857
let description = error.to_string();
4958
(
5059
error.into(),
5160
Error {
5261
page,
5362
theme,
63+
lang,
5464
description,
5565
},
5666
)

crates/wastebin_server/src/handlers/html/paste.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use serde::Deserialize;
77
use crate::cache::{Key, Mode};
88
use crate::handlers::extract::{Theme, Uid};
99
use crate::handlers::html::{BurnConfirmation, ErrorResponse, PasswordInput, make_error};
10+
use crate::i18n::Lang;
1011
use crate::{Cache, Database, Highlighter, Page};
1112
use wastebin_core::crypto::Password;
1213
use wastebin_core::db;
@@ -33,6 +34,7 @@ pub(crate) struct Paste {
3334
page: Page,
3435
key: Key,
3536
theme: Option<Theme>,
37+
lang: Lang,
3638
can_delete: bool,
3739
/// If the paste still in the database and can be fetched with another request.
3840
is_available: bool,
@@ -58,6 +60,7 @@ pub async fn get<E>(
5860
Path(id): Path<String>,
5961
uid: Option<Uid>,
6062
theme: Option<Theme>,
63+
lang: Lang,
6164
form: Result<Form<PasteForm>, E>,
6265
) -> Result<Response, ErrorResponse> {
6366
async {
@@ -80,6 +83,7 @@ pub async fn get<E>(
8083
return Ok(BurnConfirmation {
8184
page: page.clone(),
8285
theme: theme.clone(),
86+
lang,
8387
id,
8488
title: metadata.title.clone(),
8589
}
@@ -93,6 +97,7 @@ pub async fn get<E>(
9397
return Ok(PasswordInput {
9498
page: page.clone(),
9599
theme: theme.clone(),
100+
lang,
96101
id,
97102
}
98103
.into_response());
@@ -134,6 +139,7 @@ pub async fn get<E>(
134139
page: page.clone(),
135140
key,
136141
theme: theme.clone(),
142+
lang,
137143
can_delete,
138144
is_available,
139145
expiration,
@@ -145,7 +151,7 @@ pub async fn get<E>(
145151
Ok(paste.into_response())
146152
}
147153
.await
148-
.map_err(|err| make_error(err, page, theme))
154+
.map_err(|err| make_error(err, page, theme, lang))
149155
}
150156

151157
#[cfg(test)]

0 commit comments

Comments
 (0)