Skip to main content

xapi_data/
language_map.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{Canonical, DataError, MultiLingual, MyLanguageTag};
4use core::fmt;
5use serde::{Deserialize, Serialize};
6use std::{
7    collections::{BTreeMap, btree_map::Keys},
8    mem,
9};
10
11/// A dictionary where the _key_ is an [RFC 5646][1] _Language Tag_, and the
12/// _value_ is a string in the language indicated by the tag. This map is
13/// supposed to be populated as fully as possible.
14/// 
15/// **IMPLEMENTATION NOTE** - This implementation uses a B-Tree based natural
16/// language ordered map (`BTreeMap`), in preference to a `HashMap` b/c it
17/// [seems to be][2] faster when deserializing, as well easier when finding
18/// correct candidate for a language-tag w/ a country variant; for example
19/// deciding on the appropriate label when the dictionary contains entries for
20/// `en-US`, `en`, and `en-AU`.
21/// 
22/// # Requirements for _Canonical_ format
23/// [xAPI requirements][3] for producing resources that have a [LanguageMap]
24/// property when **`canonical`** format is specified state:
25/// 
26/// * [Activity][4] objects —more specifically their [ActivityDefinition][5]
27///   parts— contain [LanguageMap] objects within their `name`, `description`
28///   and various [InteractionComponent][6]s. The LRS **shall return only one
29///   language in each of these maps**.
30/// * The LRS _may_ maintain canonical versions of language maps against any
31///   IRI identifying an object containing language maps. This includes the
32///   language map stored in the [Verb][7]'s `display` property and potentially
33///   some language maps used within extensions.
34/// * The LRS _may_ maintain a canonical version of any language map and return
35///   this when **`canonical`** format is used to retrieve Statements. The LRS
36///   shall return only one language within each language map for which it
37///   returns a canonical map.
38/// * In order to choose the most relevant language, the LRS shall apply the
39///   **`Accept-Language`** header as described in RFC-2616, except that this
40///   logic shall be applied to each language map individually to select which
41///   language entry to include, rather than to the resource (list of Statements)
42///   as a whole.
43/// 
44/// [1]: https://www.rfc-editor.org/rfc/rfc5646.html
45/// [2]: https://users.rust-lang.org/t/hashmap-vs-btreemap/13804
46/// [3]: https://opensource.ieee.org/xapi/xapi-base-standard-documentation/-/blob/main/9274.1.xAPI%20Base%20Standard%20for%20LRSs.md#language-filtering-requirements-for-canonical-format-statements
47/// [4]: crate::Activity
48/// [5]: crate::ActivityDefinition
49/// [6]: crate::InteractionComponent
50/// [7]: crate::Verb
51/// 
52#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
53pub struct LanguageMap(BTreeMap<MyLanguageTag, String>);
54
55/// The empty [LanguageMap] singleton.
56pub const EMPTY_LANGUAGE_MAP: LanguageMap = LanguageMap(BTreeMap::new());
57
58impl LanguageMap {
59    /// Create an empty [LanguageMap] instance.
60    pub fn new() -> Self {
61        LanguageMap(BTreeMap::new())
62    }
63
64    /// Return the number of entries in this dictionary.
65    pub fn len(&self) -> usize {
66        self.0.len()
67    }
68
69    /// Return a reference to the label keyed by `k` if it exists, or `None`
70    /// otherwise.
71    pub fn get(&self, k: &MyLanguageTag) -> Option<&str> {
72        self.0.get(k).map(|x| x.as_str())
73    }
74
75    /// Return TRUE if this dictionary is empty; FALSE otherwise.
76    pub fn is_empty(&self) -> bool {
77        self.0.is_empty()
78    }
79
80    /// Move all elements from `other` into self, leaving `other` empty.
81    pub fn append(&mut self, other: &mut Self) {
82        if other.is_empty() {
83            return;
84        }
85
86        if self.is_empty() {
87            mem::swap(self, other);
88            return;
89        }
90
91        self.0.append(&mut other.0)
92    }
93
94    /// Insert `v` keyed by `k` and return the previous `v` if `k` was already
95    /// known, or `None` otherwise.
96    pub fn insert(&mut self, k: &MyLanguageTag, v: &str) -> Option<String> {
97        self.0.insert(k.to_owned(), v.to_owned())
98    }
99
100    /// Return an iterator over this dictionary's keys.
101    pub fn keys(&self) -> Keys<'_, MyLanguageTag, String> {
102        self.0.keys()
103    }
104
105    /// Return TRUE if `k` is a known key of this dictionary; FALSE otherwise.
106    pub fn contains_key(&self, k: &MyLanguageTag) -> bool {
107        self.0.contains_key(k)
108    }
109
110    /// Retain entries in this that satisfy the given predicate.
111    pub fn retain<F>(&mut self, mut f: F)
112    where
113        F: FnMut(&MyLanguageTag, &mut String) -> bool,
114    {
115        self.0.retain(|k, v| f(k, v))
116    }
117
118    /// Extend this w/ the contents of `other` without modifying the latter.
119    pub fn extend(&mut self, other: Self) {
120        self.0.extend(other.0)
121    }
122}
123
124impl fmt::Display for LanguageMap {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        write!(f, "{}", serde_json::to_string(self).unwrap())
127    }
128}
129
130impl MultiLingual for LanguageMap {
131    fn add_label(&mut self, tag: &MyLanguageTag, label: &str) -> Result<&mut Self, DataError> {
132        self.insert(tag, label);
133
134        Ok(self)
135    }
136}
137
138impl Canonical for LanguageMap {
139    fn canonicalize(&mut self, tags: &[MyLanguageTag]) {
140        if !self.is_empty() {
141            if !tags.is_empty() {
142                for tag in tags {
143                    if self.contains_key(tag) {
144                        // retain entry for this key...
145                        self.retain(|k, _| k == tag);
146                        return;
147                    }
148                }
149                // if we're still here then we found no common tag...
150            }
151            // pick a random entry... but only if the map contains more than 1...
152            if self.len() > 1 {
153                let t = self.keys().next().unwrap().clone();
154                self.retain(|k, _| k == t)
155            }
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::DataError;
164    use std::str::FromStr;
165    use tracing_test::traced_test;
166
167    #[test]
168    fn test_und_langtag() -> Result<(), DataError> {
169        let _ = MyLanguageTag::from_str("und")?;
170
171        Ok(())
172    }
173
174    #[traced_test]
175    #[test]
176    fn test_multilingual_trait() -> Result<(), DataError> {
177        let en = MyLanguageTag::from_str("en")?;
178        let de = MyLanguageTag::from_str("de")?;
179
180        let mut lm = LanguageMap::new();
181        lm.add_label(&en, "Good morning").unwrap();
182        lm.add_label(&de, "Gutten morgen").unwrap();
183        assert_eq!(lm.len(), 2);
184
185        lm.add_label(&de, "Gutten tag").unwrap();
186        assert_eq!(lm.len(), 2);
187
188        Ok(())
189    }
190
191    #[traced_test]
192    #[test]
193    fn test_canonicalize_trait() -> Result<(), DataError> {
194        let en = MyLanguageTag::from_str("en")?;
195        let de = MyLanguageTag::from_str("de")?;
196        let fr = MyLanguageTag::from_str("fr")?;
197
198        let language_tags = &[
199            MyLanguageTag::from_str("en-AU")?,
200            MyLanguageTag::from_str("en-US")?,
201            MyLanguageTag::from_str("en-GB")?,
202            en.clone(),
203        ];
204
205        let mut lm = LanguageMap::new();
206        lm.insert(&fr, "larry");
207        lm.insert(&en, "curly");
208        lm.insert(&de, "moe");
209        assert_eq!(lm.len(), 3);
210
211        lm.canonicalize(language_tags);
212
213        assert_eq!(lm.len(), 1);
214        assert_eq!(lm.get(&en).unwrap(), "curly");
215
216        Ok(())
217    }
218
219    #[traced_test]
220    #[test]
221    fn test_bad_json() {
222        const JSON: &str = r#"{"a12345678":"should error"}"#;
223
224        let res = serde_json::from_str::<LanguageMap>(JSON);
225        assert!(res.is_err());
226    }
227}