veloren_client_i18n/
lib.rs

1mod error;
2mod raw;
3
4use error::ResourceErr;
5
6#[cfg(any(feature = "bin", feature = "stat", test))]
7pub mod analysis;
8
9use fluent_bundle::{FluentResource, bundle::FluentBundle};
10use intl_memoizer::concurrent::IntlLangMemoizer;
11use unic_langid::LanguageIdentifier;
12
13use hashbrown::HashMap;
14use serde::{Deserialize, Serialize};
15use std::borrow::Cow;
16
17use assets::{
18    AssetCache, AssetExt, AssetHandle, AssetReadGuard, BoxedError, ReloadWatcher, SharedString,
19};
20use common_assets as assets;
21use common_i18n::{Content, LocalizationArg};
22use tracing::warn;
23
24// Re-export for argument creation
25pub use fluent::{FluentValue, fluent_args};
26pub use fluent_bundle::FluentArgs;
27
28/// The reference language, aka the more up-to-date localization data.
29/// Also the default language at first startup.
30pub const REFERENCE_LANG: &str = "en";
31
32/// How a language can be described
33#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34pub struct LanguageMetadata {
35    /// A human friendly language name (e.g. "English (US)")
36    pub language_name: String,
37
38    /// A short text identifier for this language (e.g. "en_US")
39    ///
40    /// On the opposite of `language_name` that can change freely,
41    /// `language_identifier` value shall be stable in time as it
42    /// is used by setting components to store the language
43    /// selected by the user.
44    pub language_identifier: String,
45}
46
47/// Store font metadata
48#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
49pub struct Font {
50    /// Key to retrieve the font in the asset system
51    pub asset_key: String,
52
53    /// Scale ratio to resize the UI text dynamically
54    scale_ratio: f32,
55}
56
57impl Font {
58    /// Scale input size to final UI size
59    #[must_use]
60    pub fn scale(&self, value: u32) -> u32 { (value as f32 * self.scale_ratio).round() as u32 }
61}
62
63/// Store font metadata
64pub type Fonts = HashMap<String, Font>;
65
66/// Store internationalization data
67struct Language {
68    /// The bundle storing all localized texts
69    pub(crate) bundle: FluentBundle<FluentResource, IntlLangMemoizer>,
70
71    /// Font configuration is stored here
72    pub(crate) fonts: Fonts,
73    pub(crate) metadata: LanguageMetadata,
74}
75
76impl Language {
77    fn try_msg<'a>(&'a self, key: &str, args: Option<&'a FluentArgs>) -> Option<Cow<'a, str>> {
78        let bundle = &self.bundle;
79        let msg = bundle.get_message(key)?;
80        let mut errs = Vec::new();
81        let msg = bundle.format_pattern(msg.value()?, args, &mut errs);
82        for err in errs {
83            tracing::error!("err: {err} for {key}");
84        }
85
86        Some(msg)
87    }
88
89    fn try_attr<'a>(
90        &'a self,
91        key: &str,
92        attr: &str,
93        args: Option<&'a FluentArgs>,
94    ) -> Option<Cow<'a, str>> {
95        let bundle = &self.bundle;
96        let msg = bundle.get_message(key)?;
97        let attr = msg.get_attribute(attr)?;
98        let attr = attr.value();
99
100        let mut errs = Vec::new();
101        let msg = bundle.format_pattern(attr, args, &mut errs);
102        for err in errs {
103            tracing::error!("err: {err} for {key}");
104        }
105
106        Some(msg)
107    }
108
109    /// NOTE: Exists for legacy reasons, avoid.
110    // Read more in the issue on get_variation at Gitlab
111    fn try_variation<'a>(
112        &'a self,
113        key: &str,
114        seed: u16,
115        args: Option<&'a FluentArgs>,
116    ) -> Option<Cow<'a, str>> {
117        let bundle = &self.bundle;
118        let msg = bundle.get_message(key)?;
119
120        let mut errs = Vec::new();
121
122        let attrs: Vec<_> = msg.attributes().collect();
123        let msg = if !attrs.is_empty() {
124            let idx = usize::from(seed) % attrs.len();
125            // unwrap is ok here, because idx is bound to attrs.len()
126            // by using modulo operator.
127            //
128            // For example:
129            // (I)
130            // * attributes = [.x = 5, .y = 7, z. = 4]
131            // * len = 3
132            // * seed can be 12, 50, 1
133            // 12 % 3 = 0, attrs.skip(0) => first element
134            // 50 % 3 = 2, attrs.skip(2) => third element
135            // 1 % 3 = 1, attrs.skip(1) => second element
136            // (II)
137            // * attributes = []
138            // * len = 0
139            // * no matter what seed is, we return None in code above
140            let variation = &attrs[idx];
141            bundle.format_pattern(variation.value(), args, &mut errs)
142        } else {
143            // Fall back to single message if there are no attributes
144            bundle.format_pattern(msg.value()?, args, &mut errs)
145        };
146
147        for err in errs {
148            tracing::error!("err: {err} for {key}");
149        }
150
151        Some(msg)
152    }
153}
154impl assets::Asset for Language {
155    fn load(cache: &AssetCache, path: &SharedString) -> Result<Self, BoxedError> {
156        let manifest = cache
157            .load::<raw::Manifest>(&[path, ".", "_manifest"].concat())?
158            .cloned();
159        let raw::Manifest {
160            mut fonts,
161            metadata,
162        } = manifest;
163
164        let lang_id: LanguageIdentifier = metadata.language_identifier.parse()?;
165        let mut bundle = FluentBundle::new_concurrent(vec![lang_id]);
166
167        // Here go dragons
168        for id in cache.load_rec_dir::<raw::Resource>(path)?.read().ids() {
169            match cache.load(id) {
170                Ok(handle) => {
171                    let source: &raw::Resource = &handle.read();
172                    let src = source.src.clone();
173
174                    let resource = FluentResource::try_new(src).map_err(|(_ast, errs)| {
175                        ResourceErr::parsing_error(errs, id.to_string(), &source.src)
176                    })?;
177
178                    bundle
179                        .add_resource(resource)
180                        .map_err(|e| ResourceErr::BundleError(format!("{e:?}")))?;
181                },
182                Err(err) => {
183                    // TODO: shouldn't we just panic here?
184                    warn!("Unable to load asset {id}, error={err:?}");
185                },
186            }
187        }
188
189        bundle
190            .add_function("HEAD", |positional, _named| match positional.first() {
191                Some(FluentValue::String(s)) => FluentValue::String(
192                    s.trim_start()
193                        .split_once(char::is_whitespace)
194                        .map(|(h, _)| h)
195                        .unwrap_or(s)
196                        .to_string()
197                        .into(),
198                ),
199                _ => FluentValue::Error,
200            })
201            .expect("Failed to add the HEAD function.");
202
203        bundle
204            .add_function("TAIL", |positional, _named| match positional.first() {
205                Some(FluentValue::String(s)) => FluentValue::String(
206                    s.trim_start()
207                        .split_once(char::is_whitespace)
208                        .map(|(_, t)| t.trim_start())
209                        .unwrap_or(s)
210                        .to_string()
211                        .into(),
212                ),
213                _ => FluentValue::Error,
214            })
215            .expect("Failed to add the TAIL function.");
216
217        // NOTE:
218        // Basically a hack, but conrod can't use isolation marks yet.
219        // Veloren Issue 1649
220        bundle.set_use_isolating(false);
221
222        // Add a universal fallback-ish font, that's supposed to cover all
223        // languages.
224        // Use it for language menu, chat, etc.
225        //
226        // At the moment, covers all languages except Korean, so Korean uses
227        // different font here.
228        fonts.entry("universal".to_owned()).or_insert(Font {
229            asset_key: "voxygen.font.GoNotoCurrent".to_owned(),
230            scale_ratio: 1.0,
231        });
232
233        Ok(Self {
234            bundle,
235            fonts,
236            metadata,
237        })
238    }
239}
240
241/// The central data structure to handle localization in Veloren
242// inherit Copy + Clone from AssetHandle (what?)
243#[derive(Copy, Clone)]
244pub struct LocalizationHandle {
245    active: AssetHandle<Language>,
246    watcher: ReloadWatcher,
247    fallback: Option<AssetHandle<Language>>,
248    pub use_english_fallback: bool,
249}
250
251/// Read [`LocalizationGuard`]
252// arbitrary choice to minimize changing all of veloren
253pub type Localization = LocalizationGuard;
254
255/// RAII guard returned from [`LocalizationHandle::read()`], resembles
256/// [`AssetReadGuard`]
257pub struct LocalizationGuard {
258    active: AssetReadGuard<Language>,
259    fallback: Option<AssetReadGuard<Language>>,
260}
261
262impl LocalizationGuard {
263    /// Get a localized text from the given key in the fallback language.
264    pub fn try_fallback_msg(&self, key: &str) -> Option<Cow<'_, str>> {
265        self.fallback.as_ref().and_then(|fb| fb.try_msg(key, None))
266    }
267
268    /// Get a localized text from the given key
269    ///
270    /// First lookup is done in the active language, second in
271    /// the fallback (if present).
272    pub fn try_msg(&self, key: &str) -> Option<Cow<'_, str>> {
273        self.active
274            .try_msg(key, None)
275            .or_else(|| self.try_fallback_msg(key))
276    }
277
278    /// Get a localized text from the given key
279    ///
280    /// First lookup is done in the active language, second in
281    /// the fallback (if present).
282    /// If the key is not present in the localization object
283    /// then the key itself is returned.
284    pub fn get_msg(&self, key: &str) -> Cow<'_, str> {
285        // NOTE: we clone the key if translation was missing
286        // We could use borrowed version, but it would mean that
287        // `key`, `self`, and result should have the same lifetime.
288        // Which would make it way more awkward to use with runtime generated keys.
289        self.try_msg(key)
290            .unwrap_or_else(|| Cow::Owned(key.to_owned()))
291    }
292
293    /// Get a localized text from the given key using given arguments
294    ///
295    /// First lookup is done in the active language, second in
296    /// the fallback (if present).
297    pub fn try_msg_ctx<'a>(&'a self, key: &str, args: &'a FluentArgs) -> Option<Cow<'static, str>> {
298        // NOTE: as after using args we get our result owned (because you need
299        // to clone pattern during forming value from args), this conversion
300        // to Cow::Owned is no-op.
301        // We could use String here, but using Cow everywhere in i18n API is
302        // prefered for consistency.
303        self.active
304            .try_msg(key, Some(args))
305            .or_else(|| {
306                self.fallback
307                    .as_ref()
308                    .and_then(|fb| fb.try_msg(key, Some(args)))
309            })
310            .map(|res| Cow::Owned(res.into_owned()))
311    }
312
313    /// Get a localized text from the given key using given arguments
314    ///
315    /// First lookup is done in the active language, second in
316    /// the fallback (if present).
317    /// If the key is not present in the localization object
318    /// then the key itself is returned.
319    pub fn get_msg_ctx<'a>(&'a self, key: &str, args: &'a FluentArgs) -> Cow<'static, str> {
320        self.try_msg_ctx(key, args)
321            .unwrap_or_else(|| Cow::Owned(key.to_owned()))
322    }
323
324    /// NOTE: Exists for legacy reasons, avoid.
325    ///
326    /// Get a localized text from the variation of given key
327    ///
328    /// First lookup is done in the active language, second in
329    /// the fallback (if present).
330    // Read more in the issue on get_variation at Gitlab
331    pub fn try_variation(&self, key: &str, seed: u16) -> Option<Cow<'_, str>> {
332        self.active.try_variation(key, seed, None).or_else(|| {
333            self.fallback
334                .as_ref()
335                .and_then(|fb| fb.try_variation(key, seed, None))
336        })
337    }
338
339    /// NOTE: Exists for legacy reasons, avoid.
340    ///
341    /// Get a localized text from the variation of given key
342    ///
343    /// First lookup is done in the active language, second in
344    /// the fallback (if present).
345    /// If the key is not present in the localization object
346    /// then the key itself is returned.
347    // Read more in the issue on get_variation at Gitlab
348    pub fn get_variation(&self, key: &str, seed: u16) -> Cow<'_, str> {
349        self.try_variation(key, seed)
350            .unwrap_or_else(|| Cow::Owned(key.to_owned()))
351    }
352
353    /// NOTE: Exists for legacy reasons, avoid.
354    ///
355    /// Get a localized text from the variation of given key with given
356    /// arguments
357    ///
358    /// First lookup is done in the active language, second in
359    /// the fallback (if present).
360    // Read more in the issue on get_variation at Gitlab
361    pub fn try_variation_ctx<'a>(
362        &'a self,
363        key: &str,
364        seed: u16,
365        args: &'a FluentArgs,
366    ) -> Option<Cow<'a, str>> {
367        self.active
368            .try_variation(key, seed, Some(args))
369            .or_else(|| {
370                self.fallback
371                    .as_ref()
372                    .and_then(|fb| fb.try_variation(key, seed, Some(args)))
373            })
374    }
375
376    // Function to localize content for given language.
377    //
378    // Returns Ok(localized_text) if found no errors.
379    // Returns Err(broken_text) on failure.
380    //
381    // broken_text will have i18n keys in it, just i18n key if it was instant miss
382    // or text with missed keys inlined if it was missed down the chain.
383    fn get_content_for_lang(lang: &Language, content: &Content) -> Result<String, String> {
384        match content {
385            Content::Plain(text) => Ok(text.clone()),
386            Content::Key(key) => lang
387                .try_msg(key, None)
388                .map(Cow::into_owned)
389                .ok_or_else(|| key.to_string()),
390            Content::Attr(key, attr) => lang
391                .try_attr(key, attr, None)
392                .map(Cow::into_owned)
393                .ok_or_else(|| format!("{key}.{attr}")),
394            Content::Localized { key, seed, args } => {
395                // flag to detect failure down the chain
396                let mut is_arg_failure = false;
397
398                let mut fargs = FluentArgs::new();
399                for (k, arg) in args {
400                    let arg_val = match arg {
401                        LocalizationArg::Content(content) => {
402                            let arg_res = Self::get_content_for_lang(lang, content)
403                                .unwrap_or_else(|broken_text| {
404                                    is_arg_failure = true;
405                                    broken_text
406                                })
407                                .into();
408
409                            FluentValue::String(arg_res)
410                        },
411                        LocalizationArg::Nat(n) => FluentValue::from(n),
412                    };
413                    fargs.set(k, arg_val);
414                }
415
416                lang.try_variation(key, *seed, Some(&fargs))
417                    .map(Cow::into_owned)
418                    .ok_or_else(|| key.clone())
419                    .and_then(|text| if is_arg_failure { Err(text) } else { Ok(text) })
420            },
421            Content::WithFallback(content_true, content_fb) => {
422                Self::get_content_for_lang(lang, content_true)
423                    .or_else(|_| Self::get_content_for_lang(lang, content_fb))
424            },
425        }
426    }
427
428    /// Tries its best to localize a message sent from the server, can be
429    /// compound.
430    ///
431    /// # Example
432    /// ```text
433    /// Content::Localized { "npc-speech-tell_site", seed, {
434    ///     "dir" => Content::Localized("npc-speech-dir_north", seed, {})
435    ///     "dist" => Content::Localized("npc-speech-dist_very_far", seed, {})
436    ///     "site" => Content::Plain(site)
437    /// }}
438    /// ```
439    /// ```fluent
440    /// npc-speech-tell_site =
441    ///    .a0 = Have you visited { $site }? It's just { $dir } of here!
442    ///    .a1 = You should visit { $site } some time.
443    ///    .a2 = If you travel { $dist } to the { $dir }, you can get to { $site }.
444    ///    .a3 = To the { $dir } you'll find { $site }, it's { $dist }.
445    ///
446    /// npc-speech-dir_north = north
447    /// # ... other keys
448    ///
449    /// npc-speech-dist_very_far = very far away
450    /// # ... other keys
451    /// ```
452    ///
453    /// 1) Because content we want is localized itself and has arguments, we
454    ///    iterate over them and localize, recursively. Having that, we localize
455    ///    our content.
456    /// 2) Now there is a chance that some of args have missing internalization.
457    ///    In that case, we insert arg name as placeholder and mark it as
458    ///    broken. Then we repeat *whole* procedure on fallback language if we
459    ///    have it.
460    /// 3) Otherwise, return result from (1).
461    // NOTE: it's important that we only use one language at the time, because
462    // otherwise we will get partially-translated message.
463    //
464    // TODO: return Cow<str>?
465    pub fn get_content(&self, content: &Content) -> String {
466        match Self::get_content_for_lang(&self.active, content) {
467            Ok(text) => text,
468            // If localisation or some part of it failed, repeat with fallback.
469            // If it did fail as well, it's probably because fallback was disabled,
470            // so we don't have better option other than returning broken text
471            // we produced earlier.
472            Err(broken_text) => self
473                .fallback
474                .as_ref()
475                .and_then(|fb| Self::get_content_for_lang(fb, content).ok())
476                .unwrap_or(broken_text),
477        }
478    }
479
480    pub fn get_content_fallback(&self, content: &Content) -> String {
481        self.fallback
482            .as_ref()
483            .map(|fb| Self::get_content_for_lang(fb, content))
484            .transpose()
485            .map(|msg| msg.unwrap_or_default())
486            .unwrap_or_else(|e| e)
487    }
488
489    /// NOTE: Exists for legacy reasons, avoid.
490    ///
491    /// Get a localized text from the variation of given key with given
492    /// arguments
493    ///
494    /// First lookup is done in the active language, second in
495    /// the fallback (if present).
496    /// If the key is not present in the localization object
497    /// then the key itself is returned.
498    // Read more in the issue on get_variation at Gitlab
499    pub fn get_variation_ctx<'a>(
500        &'a self,
501        key: &str,
502        seed: u16,
503        args: &'a FluentArgs,
504    ) -> Cow<'a, str> {
505        self.try_variation_ctx(key, seed, args)
506            .unwrap_or_else(|| Cow::Owned(key.to_owned()))
507    }
508
509    /// Get a localized text from the given key by given attribute
510    ///
511    /// First lookup is done in the active language, second in
512    /// the fallback (if present).
513    pub fn try_attr(&self, key: &str, attr: &str) -> Option<Cow<'_, str>> {
514        self.active.try_attr(key, attr, None).or_else(|| {
515            self.fallback
516                .as_ref()
517                .and_then(|fb| fb.try_attr(key, attr, None))
518        })
519    }
520
521    /// Get a localized text from the given key by given attribute
522    ///
523    /// First lookup is done in the active language, second in
524    /// the fallback (if present).
525    /// If the key is not present in the localization object
526    /// then the key itself is returned.
527    pub fn get_attr(&self, key: &str, attr: &str) -> Cow<'_, str> {
528        self.try_attr(key, attr)
529            .unwrap_or_else(|| Cow::Owned(format!("{key}.{attr}")))
530    }
531
532    /// Get a localized text from the given key by given attribute and arguments
533    ///
534    /// First lookup is done in the active language, second in
535    /// the fallback (if present).
536    pub fn try_attr_ctx(
537        &self,
538        key: &str,
539        attr: &str,
540        args: &FluentArgs,
541    ) -> Option<Cow<'static, str>> {
542        // NOTE: we explicitly Own result, because in 99.999% cases it got
543        // owned during formatting of arguments, hence it's a no-op, but makes
544        // using this function much easier
545        self.active
546            .try_attr(key, attr, Some(args))
547            .or_else(|| {
548                self.fallback
549                    .as_ref()
550                    .and_then(|fb| fb.try_attr(key, attr, Some(args)))
551            })
552            .map(|res| Cow::Owned(res.into_owned()))
553    }
554
555    /// Get a localized text from the given key by given attribute and arguments
556    ///
557    /// First lookup is done in the active language, second in
558    /// the fallback (if present).
559    /// If the key is not present in the localization object
560    /// then the key itself is returned.
561    pub fn get_attr_ctx(&self, key: &str, attr: &str, args: &FluentArgs) -> Cow<'static, str> {
562        self.try_attr_ctx(key, attr, args)
563            .unwrap_or_else(|| Cow::Owned(format!("{key}.{attr}")))
564    }
565
566    #[must_use]
567    pub fn fonts(&self) -> &Fonts { &self.active.fonts }
568
569    #[must_use]
570    pub fn metadata(&self) -> &LanguageMetadata { &self.active.metadata }
571}
572
573impl LocalizationHandle {
574    pub fn set_english_fallback(&mut self, use_english_fallback: bool) {
575        self.use_english_fallback = use_english_fallback;
576    }
577
578    #[must_use]
579    pub fn read(&self) -> LocalizationGuard {
580        LocalizationGuard {
581            active: self.active.read(),
582            fallback: if self.use_english_fallback {
583                self.fallback.map(|f| f.read())
584            } else {
585                None
586            },
587        }
588    }
589
590    /// # Errors
591    /// Returns error if active of fallback language can't be loaded
592    pub fn load(specifier: &str) -> Result<Self, assets::Error> {
593        let default_key = ["voxygen.i18n.", REFERENCE_LANG].concat();
594        let language_key = ["voxygen.i18n.", specifier].concat();
595        let is_default = language_key == default_key;
596        let active = Language::load(&language_key)?;
597        Ok(Self {
598            active,
599            watcher: active.reload_watcher(),
600            fallback: if is_default {
601                None
602            } else {
603                Some(Language::load(&default_key)?)
604            },
605            use_english_fallback: false,
606        })
607    }
608
609    #[must_use]
610    pub fn load_expect(specifier: &str) -> Self {
611        Self::load(specifier).expect("Can't load language files")
612    }
613
614    pub fn reloaded(&mut self) -> bool { self.watcher.reloaded() }
615}
616
617#[derive(Clone, Debug)]
618struct LocalizationList(Vec<LanguageMetadata>);
619
620impl assets::Asset for LocalizationList {
621    fn load(cache: &AssetCache, specifier: &SharedString) -> Result<Self, BoxedError> {
622        // List language directories
623        let languages = assets::load_rec_dir::<raw::Manifest>(specifier)
624            .unwrap_or_else(|e| panic!("Failed to get manifests from {}: {:?}", specifier, e))
625            .read()
626            .ids()
627            .filter_map(|spec| cache.load::<raw::Manifest>(spec).ok())
628            .map(|localization| localization.read().metadata.clone())
629            .collect();
630
631        Ok(LocalizationList(languages))
632    }
633}
634
635/// Load all the available languages located in the voxygen asset directory
636#[must_use]
637pub fn list_localizations() -> Vec<LanguageMetadata> {
638    let LocalizationList(list) = LocalizationList::load_expect_cloned("voxygen.i18n");
639    list
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    #[test]
647    // Test that localization list is loaded (not empty)
648    fn check_localization_list() {
649        let list = list_localizations();
650        assert!(!list.is_empty());
651    }
652
653    #[test]
654    // Test that reference language can be loaded
655    fn validate_reference_language() { let _ = LocalizationHandle::load_expect(REFERENCE_LANG); }
656
657    #[test]
658    // Test to verify that all languages are valid and loadable
659    fn validate_all_localizations() {
660        let list = list_localizations();
661        for meta in list {
662            let _ = LocalizationHandle::load_expect(&meta.language_identifier);
663        }
664    }
665
666    #[test]
667    fn test_strict_all_localizations() {
668        use analysis::{Language, ReferenceLanguage};
669
670        let root = assets::find_root().unwrap();
671        let i18n_directory = root.join("assets/voxygen/i18n");
672        let reference = ReferenceLanguage::at(&i18n_directory.join(REFERENCE_LANG));
673
674        let list = list_localizations();
675
676        for meta in list {
677            let code = meta.language_identifier;
678            let lang = Language {
679                code: code.clone(),
680                path: i18n_directory.join(code.clone()),
681            };
682            // TODO: somewhere here should go check that all needed
683            // versions are given
684            reference.compare_with(&lang);
685        }
686    }
687}