Skip to main content

sofie_http/
translations.rs

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