Skip to main content

sofie_engine/translations/
mod.rs

1mod 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    /// Changes with every addition, so what was derived from earlier texts is not reused.
53    generation: u64,
54    languages: Vec<Locale>,
55    entries: Entries,
56    /// Kept here because their variants' labels are translation keys.
57    enums: HashMap<String, Vec<Variant>>,
58    /// An app's enum of the same name replaces these, as its texts replace a framework crate's.
59    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    /// Its texts replace a framework crate's; two app modules may not define one key.
129    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    /// A language the app does not declare is not offered, and the app's own texts win.
163    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    /// `sofie_values::Weekday` is labelled like an app's enum, by `enum.weekday.<day>`.
228    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 {}