sofie_engine/translations/
mod.rs1mod call;
2#[cfg(feature = "parse")]
3mod check;
4mod conventions;
5pub use conventions::CURRENCY_SYMBOLS;
6mod entries;
7mod language;
8#[cfg(feature = "parse")]
9mod localized;
10mod negotiate;
11#[cfg(feature = "parse")]
12mod record;
13mod toml;
14#[cfg(feature = "parse")]
15mod uses;
16
17use std::collections::{HashMap, HashSet};
18use std::fmt;
19use std::sync::atomic::{AtomicU64, Ordering};
20
21use sofie_values::Locale;
22
23pub(crate) use self::call::{Argument, Call, CountedKeys, Format, label};
24#[cfg(feature = "parse")]
25pub use self::check::{Gap, GapKind};
26use self::entries::{Entries, Origin};
27pub use self::language::Language;
28#[cfg(feature = "clients")]
29pub(crate) use self::language::Texts;
30pub(crate) use self::language::write_instant;
31#[cfg(feature = "parse")]
32pub use self::localized::{check_translations, translation_keys};
33#[cfg(feature = "parse")]
34pub(crate) use self::record::record;
35#[cfg(feature = "parse")]
36pub(crate) use self::uses::key_uses;
37#[cfg(feature = "parse")]
38pub use self::uses::{KeyKind, KeyUse};
39use crate::label::humanize;
40pub(crate) use crate::message::{Message, Part};
41
42const ENGINE_MODULE: &str = "sofie_engine";
43
44const ENGINE_TRANSLATIONS: &[(&str, &str)] = &[
45 ("en", include_str!("../../lang/en.toml")),
46 ("de", include_str!("../../lang/de.toml")),
47 ("tr", include_str!("../../lang/tr.toml")),
48];
49
50#[derive(Debug)]
51pub struct Translations {
52 generation: u64,
54 languages: Vec<Locale>,
55 entries: Entries,
56 enums: HashMap<String, Vec<Variant>>,
58 builtin_enums: HashSet<String>,
60}
61
62#[derive(Debug)]
63pub(crate) struct Variant {
64 pub name: String,
65 pub tone: Tone,
66 pub key: String,
67 pub humanized: String,
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum Tone {
72 Neutral,
73 Info,
74 Success,
75 Warning,
76 Danger,
77}
78
79impl Tone {
80 pub(crate) fn class(self) -> &'static str {
81 match self {
82 Self::Neutral => "sofie-badge sofie-tone-neutral",
83 Self::Info => "sofie-badge sofie-tone-info",
84 Self::Success => "sofie-badge sofie-tone-success",
85 Self::Warning => "sofie-badge sofie-tone-warning",
86 Self::Danger => "sofie-badge sofie-tone-danger",
87 }
88 }
89}
90
91fn next_generation() -> u64 {
92 static GENERATIONS: AtomicU64 = AtomicU64::new(0);
93 GENERATIONS.fetch_add(1, Ordering::Relaxed)
94}
95
96impl Translations {
97 pub fn new(languages: &[&str]) -> Result<Self, TranslationError> {
98 let mut locales: Vec<Locale> = Vec::new();
99 for tag in languages {
100 let locale = Locale::parse(tag).map_err(|error| {
101 TranslationError::Declaration(format!("`{tag}` is not a language tag: {error:#}"))
102 })?;
103 if locales.contains(&locale) {
104 return Err(TranslationError::Declaration(format!(
105 "language `{tag}` is declared twice"
106 )));
107 }
108 locales.push(locale);
109 }
110 if locales.is_empty() {
111 return Err(TranslationError::Declaration(
112 "an app declares at least one language, the one its names are written in first"
113 .to_owned(),
114 ));
115 }
116 let mut translations = Self {
117 generation: next_generation(),
118 entries: Entries::new(locales.len()),
119 languages: locales,
120 enums: HashMap::new(),
121 builtin_enums: HashSet::new(),
122 };
123 translations.add_builtin(ENGINE_MODULE, ENGINE_TRANSLATIONS)?;
124 translations.add_weekdays()?;
125 Ok(translations)
126 }
127
128 pub fn add(
130 &mut self,
131 module: &str,
132 language: &str,
133 source: &str,
134 ) -> Result<(), TranslationError> {
135 self.generation = next_generation();
136 let file = file_name(module, language);
137 let locale = parse_language(&file, language)?;
138 let index = self
139 .languages
140 .iter()
141 .position(|declared| *declared == locale)
142 .ok_or_else(|| {
143 TranslationError::in_file(
144 &file,
145 1,
146 1,
147 format!(
148 "the app does not declare language `{language}`; it declares {}",
149 language_list(&self.languages)
150 ),
151 )
152 })?;
153 let origin = Origin {
154 module,
155 file: &file,
156 served: &[index],
157 is_builtin: false,
158 };
159 self.entries.add_file(&origin, source)
160 }
161
162 pub fn add_builtin(
164 &mut self,
165 module: &str,
166 files: &[(&str, &str)],
167 ) -> Result<(), TranslationError> {
168 self.generation = next_generation();
169 for (language, source) in files {
170 let file = file_name(module, language);
171 let locale = parse_language(&file, language)?;
172 let served: Vec<usize> = (0..self.languages.len())
173 .filter(|&index| {
174 let declared = &self.languages[index];
175 *declared == locale || declared.language() == locale.as_str()
176 })
177 .collect();
178 let origin = Origin {
179 module,
180 file: &file,
181 served: &served,
182 is_builtin: true,
183 };
184 self.entries.add_file(&origin, source)?;
185 }
186 Ok(())
187 }
188
189 pub fn add_enum(
190 &mut self,
191 name: &str,
192 variants: &[(&str, Tone)],
193 ) -> Result<(), TranslationError> {
194 self.generation = next_generation();
195 let refuse = |message: String| Err(TranslationError::Declaration(message));
196 if !is_name(name) {
197 return refuse(format!(
198 "enum name `{name}` is not a lowercase name such as `order_status`"
199 ));
200 }
201 if variants.is_empty()
202 || (self.enums.contains_key(name) && !self.builtin_enums.remove(name))
203 {
204 return refuse(format!(
205 "enum `{name}` is registered twice or without variants"
206 ));
207 }
208 let mut registered = Vec::new();
209 for (variant, tone) in variants {
210 let lowercase = variant.to_ascii_lowercase();
211 if !is_name(&lowercase) {
212 return refuse(format!(
213 "`{variant}` of enum `{name}` is not a variant name such as `PAID`"
214 ));
215 }
216 registered.push(Variant {
217 name: (*variant).to_owned(),
218 tone: *tone,
219 key: format!("enum.{name}.{lowercase}"),
220 humanized: humanize(&lowercase),
221 });
222 }
223 self.enums.insert(name.to_owned(), registered);
224 Ok(())
225 }
226
227 fn add_weekdays(&mut self) -> Result<(), TranslationError> {
229 let days: Vec<(&str, Tone)> = <sofie_values::Weekday as sofie_core::Variants>::VARIANTS
230 .iter()
231 .map(|(name, _)| (*name, Tone::Neutral))
232 .collect();
233 self.add_enum("weekday", &days)?;
234 self.builtin_enums.insert("weekday".to_owned());
235 Ok(())
236 }
237
238 #[must_use]
239 pub fn languages(&self) -> &[Locale] {
240 &self.languages
241 }
242
243 #[must_use]
244 pub fn keys(&self) -> Vec<&str> {
245 let mut keys: Vec<&str> = self.entries.keys().collect();
246 keys.sort_unstable();
247 keys
248 }
249}
250
251fn language_list(languages: &[Locale]) -> String {
252 let tags: Vec<&str> = languages.iter().map(Locale::as_str).collect();
253 tags.join(", ")
254}
255
256fn file_name(module: &str, language: &str) -> String {
257 format!("{module}/lang/{language}.toml")
258}
259
260fn parse_language(file: &str, language: &str) -> Result<Locale, TranslationError> {
261 Locale::parse(language).map_err(|error| {
262 TranslationError::in_file(
263 file,
264 1,
265 1,
266 format!("`{language}` is not a language tag: {error:#}"),
267 )
268 })
269}
270
271fn is_name(text: &str) -> bool {
272 !text.is_empty() && text.chars().all(toml::is_key_character)
273}
274
275#[derive(Debug, PartialEq, Eq)]
276pub enum TranslationError {
277 File {
278 file: String,
279 line: usize,
280 column: usize,
281 message: String,
282 },
283 Declaration(String),
284}
285
286impl TranslationError {
287 pub(super) fn in_file(file: &str, line: usize, column: usize, message: String) -> Self {
288 Self::File {
289 file: file.to_owned(),
290 line,
291 column,
292 message,
293 }
294 }
295}
296
297impl fmt::Display for TranslationError {
298 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299 match self {
300 Self::File {
301 file,
302 line,
303 column,
304 message,
305 } => write!(f, "{file}:{line}:{column}: {message}"),
306 Self::Declaration(message) => f.write_str(message),
307 }
308 }
309}
310
311impl std::error::Error for TranslationError {}