-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathbasicintl.rs
259 lines (242 loc) · 10.2 KB
/
basicintl.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
/// basicintl.rs -- Very basic internationalization system.
//
// Suitable for use with "egui", which needs a lookup on every frame.
// Very low overhead.
//
// Animats
// June, 2022
//
use anyhow::{anyhow, Context, Error};
use oxilangtag::LanguageTag;
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::Read;
use std::path::PathBuf;
// If no locale info is available, pick one of these, in order, as available.
const IMPERIAL_LANGUAGES: [&str; 3] = ["en", "cn", "ru"]; // should support at least one of these. May support more
///# Translate with memoization
//
// For each item to be translated, write
//
// t!("key", lang)
//
// which will return a reference to a static string with the translation of "key".
// This is a simple word lookup only. There is no substitution.
// Translations cannot be changed after first use.
#[macro_export]
macro_rules! t {
($s:expr,$dict:expr) => {
// macro expands this
{
static MSG: once_cell::sync::OnceCell<&str> = once_cell::sync::OnceCell::new();
let s: &str = MSG.get_or_init(|| {
$dict.translate($s) // first time only
});
s
}
};
}
/// Format of the translations dictionary for a single language
type TranslationDictionary = HashMap<&'static str, &'static str>;
/// Format of the translations file, in JSON
type TranslationFile = HashMap<String, HashMap<String, String>>; // File contents { "key" : {"lang", "key in lang" }}
/// Language dictionary. Constructed from a JSON file.
pub struct Dictionary {
translations: TranslationDictionary, // translations for chosen language
}
impl Dictionary {
/// Create the dictionary for one language.
pub fn new(files: &[PathBuf], langid: &str) -> Result<Dictionary, Error> {
let mut translations = HashMap::new();
// Add translations from all JSON files
let mut languages = HashSet::new(); // list of languages
for file in files {
let translation_file = Self::read_translation_file(file)
.with_context(|| format!("Translation file: {:?}", file))?;
Self::validate_translation_file(&translation_file, &mut languages)
.with_context(|| format!("Translation file: {:?}", file))?;
Self::add_translations(&mut translations, &translation_file, langid)?;
log::info!("Loaded translations from {:?}", file); // note translations loaded
}
Ok(Dictionary { translations })
}
/// Get list of available languages.
// Reading the first entry will tell us this, because all entries have to match.
pub fn get_language_list(files: &[PathBuf]) -> Result<HashSet<String>, Error> {
if files.is_empty() {
return Ok(HashSet::new()); // empty list, no translations available
}
let file = &files[0]; // we have at least one
let translation_file = Self::read_translation_file(file)
.with_context(|| format!("Translation file: {:?}", file))?;
// Get the unordered list of available translations from the first entry.
// They all have to be the same, and that's checked.
if let Some((_, v)) = translation_file.into_iter().next() {
Ok(v.iter().map(|(k, _)| k.clone()).collect())
} else {
Ok(HashSet::new()) // empty list, no translations available
}
}
// Make static string, which we must do so we can create strings which can be memoized in static variables.
fn string_to_static_str(s: String) -> &'static str {
Box::leak(s.into_boxed_str())
}
/// Read the JSON translation file tnto a Translationfile structure.
fn read_translation_file(filename: &PathBuf) -> Result<TranslationFile, Error> {
// Read one translations file
let file = File::open(filename)
.with_context(|| anyhow!("Failed to open the translations file: {:?}", filename))?;
let mut reader = std::io::BufReader::new(file);
let mut content = String::new();
reader
.read_to_string(&mut content)
.context("Failed to read the translations file")?;
serde_json::from_str(&content).context("Failed to parse translations file")
}
/// Add translations from a JSON file.
/// Add only for one language, which cannot be changed once initialized.
fn add_translations(
res: &mut TranslationDictionary,
translation_file: &TranslationFile,
langid: &str,
) -> Result<(), Error> {
// Select desired language from translations file
for (key, value) in translation_file {
if let Some(v) = value.get(langid) {
// We have a translation for this key for this language
res.insert(
Self::string_to_static_str(key.to_string()),
Self::string_to_static_str(v.to_string()),
); // add to translations
} else {
// Translation file needs repair
return Err(anyhow!(
"No translation for key {}, language {}",
key,
langid
));
};
}
Ok(())
}
/// Validate entire translation file for having a translation for every language mentioned
fn validate_translation_file(
res: &TranslationFile,
languages: &mut HashSet<String>,
) -> Result<(), Error> {
for (key, value) in res {
Self::validate_translation_set(key, value, languages)?; // check that all translations are present
}
Ok(())
}
/// Check that each translation has all the languages
fn validate_translation_set(
key: &str,
value: &HashMap<String, String>,
languages: &mut HashSet<String>,
) -> Result<(), Error> {
let this_set: HashSet<String> = value.iter().map(|(k, _v)| k.clone()).collect(); // all the languages
// Language list from first language becomes the master
if languages.is_empty() {
*languages = this_set.clone();
}
if this_set != *languages {
let missing = languages.difference(&this_set);
return Err(anyhow!(
"Translation dictionary is missing a translation to {:?} for \"{}\"",
missing,
key
));
}
Ok(())
}
// Lookup, only done once per t! macro expansion
pub fn translate(&self, s: &str) -> &'static str {
if let Some(st) = self.translations.get(s) {
st
} else {
log::error!("No translation is available for \"{}\"", s); // non-fatal error.
Self::string_to_static_str(s.to_string()) // use the key as the result
}
}
/// Get translation dictionary
pub fn get_translation(locale_files: &[PathBuf]) -> Result<Dictionary, Error> {
fn pick_default_language(available: &HashSet<String>) -> Result<String, Error> {
for lang in IMPERIAL_LANGUAGES.iter() {
if available.contains(&lang.to_string()) {
return Ok(lang.to_string());
}
}
// No major languages available. Pick at random from available translations.
// Probably means someone substituted an unusual translations file that
// contains none of the major languages and does not match the system locale.
if let Some(lang) = available.iter().next() {
log::error!(
"No default language choices available. Picking \"{}\"",
lang
);
Ok(lang.clone())
} else {
// We give up.
Err(anyhow!("No language translations are available"))
}
}
// Get list of languages for which we have a translation
let lang_list = Dictionary::get_language_list(locale_files)?; // get list of supported languages.
let locale_opt = sys_locale::get_locale(); // system locale
log::info!(
"System locale: {:?}. Available language translations: {:?}",
locale_opt,
lang_list
);
let lang_tag = if let Some(locale) = locale_opt {
let locale = locale.replace('_', "-"); // Workaround for https://github.com/1Password/sys-locale/issues/3
let locale = terminate_at(&locale,"@"); // Workaround for deprecated "@euro" ending.
let language_tag = LanguageTag::parse(locale)?; // system locale is garbled if this doesn't parse.
let tag = language_tag.primary_language(); // two-letter tag
if lang_list.contains(tag) {
// if matches locale
tag.to_string() // use it
} else {
pick_default_language(&lang_list)? // pick some default
}
} else {
log::error!("System did not provide a locale.");
pick_default_language(&lang_list)? // pick some default
};
Self::new(locale_files, &lang_tag) // build the translations dictionary
}
}
/// Terminate string at delimiter.
/// Used to get rid of obsolete LANG extensions which use "@".
/// Notably, "@euro", a pre-UTF8 hack.
fn terminate_at<'a>(s: &'a str, delimiter: &str) -> &'a str {
match s.rsplit_once(delimiter) {
Some((first, _last)) => first,
None => s
}
}
#[test]
fn test_terminate_at() {
let s1 = "foo@baz";
let s2 = "foobaz";
println!("{} {}", terminate_at(s1,"@"), terminate_at(s2,"@"));
assert_eq!(terminate_at(s1, "@"), "foo");
assert_eq!(terminate_at(s2, "@"), "foobaz");
}
#[test]
fn test_translation() {
use std::str::FromStr;
// Initialize the dictionary
let locale_file = PathBuf::from_str(concat!(
env!["CARGO_MANIFEST_DIR"],
"/src/assets/locales/menus.json"
))
.unwrap(); // test only
let dictionary: Dictionary = Dictionary::new(&[locale_file], "fr").unwrap(); // build translations for "fr".
// Demonstrate that it only does the lookup once
for _ in 1..5 {
println!("menu.file => {}", t!("menu.file", &dictionary));
}
assert_eq!("Fichier", t!("menu.file", &dictionary)); // consistency check
}