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
10pub 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 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#[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}