Skip to main content

sofie_http/
translations.rs

1use http::header::ACCEPT_LANGUAGE;
2use http::request::Parts;
3use std::borrow::Cow;
4
5use sofie_core::{Error, FieldError, KeyedRule, Result};
6use sofie_db::File;
7use sofie_engine::rendering::Rendering;
8use sofie_engine::{Template, Tone, TranslationError, Translations};
9
10use crate::Screens;
11
12/// As `sofie gen` writes them: the source language first, then each module's
13/// `lang/<language>.toml` files; `enums` by their `enum.<name>.<variant>` names, so badges
14/// and selects show their variants translated.
15pub fn translations(
16    languages: &[&str],
17    files: &[(&str, File)],
18    builtin: &[(&str, &[File])],
19    enums: &[(&str, &[(&str, Tone)])],
20) -> Result<Translations> {
21    let refused =
22        |error: TranslationError| Error::internal("loading the app's translations", error);
23    let mut translations = Translations::new(languages).map_err(refused)?;
24    for (module, file) in files {
25        translations
26            .add(module, file.stem(), file.text()?)
27            .map_err(refused)?;
28    }
29    for (module, files) in builtin {
30        let sources = files
31            .iter()
32            .map(|file| Ok((file.stem(), file.text()?)))
33            .collect::<Result<Vec<_>>>()?;
34        translations
35            .add_builtin(module, &sources)
36            .map_err(refused)?;
37    }
38    for (name, variants) in enums {
39        translations.add_enum(name, variants).map_err(refused)?;
40    }
41    Ok(translations)
42}
43
44impl Screens {
45    /// `sofie check` refuses a screen using a key the source language lacks, as the app is built.
46    pub fn translate(&mut self, translations: Translations) {
47        self.translations = Some(translations);
48    }
49
50    /// The signed-in account's `preferred` language first, when the app has it.
51    pub(crate) fn language(&self, request: &Parts, preferred: Option<&str>) -> Language<'_> {
52        let accepted = request
53            .headers
54            .get(ACCEPT_LANGUAGE)
55            .and_then(|value| value.to_str().ok());
56        let preferred = preferred.and_then(|tag| sofie_values::Locale::parse(tag).ok());
57        match &self.translations {
58            Some(translations) => Language {
59                language: translations.negotiate(preferred.as_ref(), accepted),
60                negotiated: true,
61            },
62            None => Language::plain(),
63        }
64    }
65}
66
67/// `negotiated`: a cache must keep answers apart by `Accept-Language`.
68#[derive(Clone, Copy)]
69pub(crate) struct Language<'a> {
70    language: sofie_engine::Language<'a>,
71    negotiated: bool,
72}
73
74impl<'a> Language<'a> {
75    pub(crate) fn plain() -> Language<'static> {
76        Language {
77            language: sofie_engine::Language::plain(),
78            negotiated: false,
79        }
80    }
81
82    pub(crate) fn varies_by(self) -> &'static str {
83        if self.negotiated {
84            return "Accept, Accept-Language, HX-Request, HX-Target";
85        }
86        crate::screen::VARIES_BY
87    }
88
89    pub(crate) fn screen<'t>(self, template: &'t Template) -> Rendering<'t>
90    where
91        'a: 't,
92    {
93        Rendering::new(template).language(self.language)
94    }
95
96    pub(crate) fn tag(self) -> &'a str {
97        self.language.locale().as_str()
98    }
99
100    pub(crate) fn field_error(self, error: &FieldError) -> String {
101        self.text(&error.key, &error.arguments, &error.message)
102    }
103
104    pub(crate) fn rule(self, rule: &KeyedRule) -> String {
105        self.text(&rule.key, &rule.arguments, &rule.message)
106    }
107
108    fn text(self, key: &str, arguments: &[(Cow<'static, str>, String)], english: &str) -> String {
109        let arguments: Vec<(&str, String)> = arguments
110            .iter()
111            .map(|(name, value)| (name.as_ref(), value.clone()))
112            .collect();
113        self.language.field_error(key, &arguments, english)
114    }
115
116    pub(crate) fn label(self, field: &str) -> String {
117        let key = format!("label.{field}");
118        match self.language.field_message(&key) {
119            text if text == key => sofie_engine::humanize(field),
120            text => text.to_owned(),
121        }
122    }
123
124    pub(crate) fn script_notices(self) -> sofie_engine::ScriptNotices<'a> {
125        sofie_engine::ScriptNotices::new(self.language)
126    }
127
128    pub(crate) fn fields_notice(self, count: usize) -> String {
129        let summary = match count {
130            1 => self.language.field_error("notice.correct_field", &[], ""),
131            count => self.language.field_error(
132                "notice.correct_fields",
133                &[("count", count.to_string())],
134                "",
135            ),
136        };
137        sofie_engine::notice(&[summary])
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn turkish() -> Translations {
146        translations(
147            &["en", "tr"],
148            &[(
149                "order",
150                File::new("lang/tr.toml", b"[label]\nstatus = \"Durum\"\n"),
151            )],
152            &[],
153            &[],
154        )
155        .unwrap()
156    }
157
158    #[test]
159    fn the_fields_notice_counts_in_the_viewer_s_language() {
160        let translations = turkish();
161        let language = Language {
162            language: translations.negotiate(None, Some("tr-TR, en;q=0.5")),
163            negotiated: true,
164        };
165
166        assert_eq!(language.tag(), "tr");
167        assert_eq!(
168            language.fields_notice(3),
169            "<p>Lütfen işaretli 3 alanı düzeltin.</p>"
170        );
171        assert_eq!(
172            Language::plain().fields_notice(1),
173            "<p>Please correct the highlighted field.</p>"
174        );
175    }
176
177    #[test]
178    fn a_label_is_translated_or_humanized() {
179        let translations = turkish();
180        let language = Language {
181            language: translations.negotiate(None, Some("tr")),
182            negotiated: true,
183        };
184
185        assert_eq!(language.label("status"), "Durum");
186        assert_eq!(language.label("placed_at"), "Placed at");
187        let error =
188            FieldError::new("total", "validation.max", "must be at most 5").with("max", "5");
189        assert_eq!(language.field_error(&error), "en fazla 5 olmalı");
190    }
191
192    #[test]
193    fn a_json_refusal_reads_in_the_viewer_s_language() {
194        let translations = turkish();
195        let language = Language {
196            language: translations.negotiate(None, Some("tr")),
197            negotiated: true,
198        };
199        let refusal = FieldError::new(
200            "total.gt",
201            "validation.kind.whole_number",
202            "must be a whole number",
203        );
204
205        let translated = crate::answer::in_language(Err(Error::Invalid(vec![refusal])), language);
206
207        let Err(Error::Invalid(fields)) = translated else {
208            panic!("{:?}", translated.err());
209        };
210        assert_eq!(fields[0].field, "total.gt");
211        assert_eq!(fields[0].message, "tam sayı olmalı");
212    }
213
214    #[test]
215    fn a_keyed_rule_is_answered_in_the_viewer_s_language_with_its_key() {
216        let translations = translations(
217            &["en", "tr"],
218            &[(
219                "order",
220                File::new(
221                    "lang/tr.toml",
222                    "[order.rule]\nshipped = \"{number} gönderildi\"\n".as_bytes(),
223                ),
224            )],
225            &[],
226            &[],
227        )
228        .unwrap();
229        let language = Language {
230            language: translations.negotiate(None, Some("tr")),
231            negotiated: true,
232        };
233        let shipped = || {
234            Error::rule_keyed(
235                "order.rule.shipped",
236                [("number", "7".to_owned())],
237                "7 shipped",
238            )
239        };
240
241        let json = crate::answer::in_language(Err(shipped()), language).unwrap_err();
242        let json = crate::answer::failure(json, crate::caller::Caller::Browser);
243        let notice = crate::answer::Refusal::of(shipped()).messages(language);
244
245        assert_eq!(json.status(), http::StatusCode::UNPROCESSABLE_ENTITY);
246        let body = json.into_body().into_inner().unwrap();
247        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
248        assert_eq!(
249            body,
250            serde_json::json!({
251                "error": "7 gönderildi",
252                "key": "order.rule.shipped",
253                "arguments": { "number": "7" },
254            })
255        );
256        assert_eq!(notice, ["7 gönderildi"]);
257    }
258
259    #[test]
260    fn an_undeclared_language_file_is_refused() {
261        let error = translations(
262            &["en"],
263            &[("order", File::new("lang/tr.toml", b""))],
264            &[],
265            &[],
266        )
267        .unwrap_err();
268
269        assert!(
270            error.to_string().contains("loading the app's translations"),
271            "{error}"
272        );
273    }
274}