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
12pub 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 pub fn translate(&mut self, translations: Translations) {
47 self.translations = Some(translations);
48 }
49
50 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#[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}