Skip to main content

opys_bundle/
localized.rs

1//! Text a player reads, in one language or several.
2//!
3//! An option's title, subtitle, unit and placeholder and a choice's label are
4//! each a string or a map from a language to the string in it:
5//! `"RAM"` or `{ "en": "RAM", "uk": "Пам'ять" }`. A string is the same text
6//! in every language. Which entry of a map a player gets, and what stands in
7//! for a language the map does not hold, is the launcher's to choose: a
8//! bundle says what there is and names no language of its own. It once did,
9//! in the head's `locale`, which every map had to hold; that was the
10//! launcher's fallback decided in somebody else's file.
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15
16/// Text a player reads. Kept as it was written, a string or a map, so that
17/// what is read back is what was written.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(try_from = "LocalizedWire", into = "LocalizedWire")]
20pub enum Localized {
21    /// The same in every language: a name, a URL, or text nobody translated.
22    Plain(String),
23    /// Language to text. Never empty, and every key is a language tag.
24    Translated(BTreeMap<String, String>),
25}
26
27impl Localized {
28    /// The languages a map names, and none for a string, which is in all
29    /// of them.
30    pub fn languages(&self) -> impl Iterator<Item = &str> {
31        let map = match self {
32            Localized::Plain(_) => None,
33            Localized::Translated(map) => Some(map.keys().map(String::as_str)),
34        };
35        map.into_iter().flatten()
36    }
37}
38
39impl From<&str> for Localized {
40    fn from(text: &str) -> Self {
41        Localized::Plain(text.to_owned())
42    }
43}
44
45/// Told apart by its JSON type, a string or an object, which is the whole
46/// difference between the two spellings.
47#[derive(Serialize, Deserialize)]
48#[serde(untagged)]
49pub(crate) enum LocalizedWire {
50    Plain(String),
51    Translated(BTreeMap<String, String>),
52}
53
54impl TryFrom<LocalizedWire> for Localized {
55    type Error = String;
56
57    fn try_from(raw: LocalizedWire) -> Result<Self, Self::Error> {
58        match raw {
59            LocalizedWire::Plain(text) => Ok(Localized::Plain(text)),
60            LocalizedWire::Translated(map) => {
61                if map.is_empty() {
62                    return Err("a translated text names no language".to_owned());
63                }
64                if let Some(bad) = map.keys().find(|tag| !is_language_tag(tag)) {
65                    return Err(format!("`{bad}` is not a language tag"));
66                }
67                Ok(Localized::Translated(map))
68            }
69        }
70    }
71}
72
73impl From<Localized> for LocalizedWire {
74    fn from(text: Localized) -> Self {
75        match text {
76            Localized::Plain(text) => LocalizedWire::Plain(text),
77            Localized::Translated(map) => LocalizedWire::Translated(map),
78        }
79    }
80}
81
82/// The shape of a BCP 47 tag, `uk` or `pt-BR`: a language of two or three
83/// letters, then subtags of one to eight letters or digits. Whether the
84/// language exists is not asked; a launcher that does not know it matches
85/// nothing and falls back as it would for any language a map lacks.
86pub fn is_language_tag(tag: &str) -> bool {
87    let mut parts = tag.split('-');
88    let language = parts.next().unwrap_or_default();
89    (2..=3).contains(&language.len())
90        && language.bytes().all(|b| b.is_ascii_alphabetic())
91        && parts.all(|part| {
92            (1..=8).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_alphanumeric())
93        })
94}