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}