summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorJanito Vaqueiro Ferreira Filho <janito@mullvad.net>2020-12-10 16:21:41 -0300
committerJanito Vaqueiro Ferreira Filho <janito@mullvad.net>2020-12-10 16:21:41 -0300
commitbe5f4a478feaec59cfd953cbf9e77545da1b3b5c (patch)
tree2776977b8437fd72464fe0cfcbe329b7396b8a70 /android
parenta688883883a567b6be0d331fac4006d2b610eb9c (diff)
parent82932ef2a930889adf761ccc2d7f37017d81025f (diff)
downloadmullvadvpn-be5f4a478feaec59cfd953cbf9e77545da1b3b5c.tar.xz
mullvadvpn-be5f4a478feaec59cfd953cbf9e77545da1b3b5c.zip
Merge branch 'import-plural-translations-into-android'
Diffstat (limited to 'android')
-rw-r--r--android/translations-converter/src/android.rs76
-rw-r--r--android/translations-converter/src/gettext.rs200
-rw-r--r--android/translations-converter/src/main.rs136
3 files changed, 360 insertions, 52 deletions
diff --git a/android/translations-converter/src/android.rs b/android/translations-converter/src/android.rs
index ca759ffb1e..5329d95ea4 100644
--- a/android/translations-converter/src/android.rs
+++ b/android/translations-converter/src/android.rs
@@ -193,11 +193,13 @@ pub struct PluralVariant {
}
/// A valid quantity for a plural variant.
-#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PluralQuantity {
Zero,
One,
+ Few,
+ Many,
Other,
}
@@ -223,3 +225,75 @@ impl IntoIterator for PluralResources {
self.entries.into_iter()
}
}
+
+impl PluralResources {
+ /// Create an empty list of plural resources.
+ pub fn new() -> Self {
+ PluralResources {
+ entries: Vec::new(),
+ }
+ }
+}
+
+impl PluralResource {
+ /// Create a plural resource representation.
+ ///
+ /// The resource has a name, used as the identifier, and a list of items. Each item contains
+ /// the message and the quantity it should be used for.
+ pub fn new(name: String, values: impl Iterator<Item = (PluralQuantity, String)>) -> Self {
+ let items = values
+ .map(|(quantity, string)| PluralVariant { quantity, string })
+ .collect();
+
+ PluralResource { name, items }
+ }
+}
+
+impl Display for PluralResources {
+ fn fmt(&self, formatter: &mut Formatter) -> fmt::Result {
+ writeln!(formatter, r#"<?xml version="1.0" encoding="utf-8"?>"#)?;
+ writeln!(formatter, "<resources>")?;
+
+ for entry in &self.entries {
+ write!(formatter, "{}", entry)?;
+ }
+
+ writeln!(formatter, "</resources>")
+ }
+}
+
+impl Display for PluralResource {
+ fn fmt(&self, formatter: &mut Formatter) -> fmt::Result {
+ writeln!(formatter, r#" <plurals name="{}">"#, self.name)?;
+
+ for item in &self.items {
+ writeln!(formatter, " {}", item)?;
+ }
+
+ writeln!(formatter, " </plurals>")
+ }
+}
+
+impl Display for PluralVariant {
+ fn fmt(&self, formatter: &mut Formatter) -> fmt::Result {
+ write!(
+ formatter,
+ r#"<item quantity="{}">{}</item>"#,
+ self.quantity, self.string
+ )
+ }
+}
+
+impl Display for PluralQuantity {
+ fn fmt(&self, formatter: &mut Formatter) -> fmt::Result {
+ let quantity = match self {
+ PluralQuantity::Zero => "zero",
+ PluralQuantity::One => "one",
+ PluralQuantity::Few => "few",
+ PluralQuantity::Many => "many",
+ PluralQuantity::Other => "other",
+ };
+
+ write!(formatter, "{}", quantity)
+ }
+}
diff --git a/android/translations-converter/src/gettext.rs b/android/translations-converter/src/gettext.rs
index 26abdbd577..b4962ab834 100644
--- a/android/translations-converter/src/gettext.rs
+++ b/android/translations-converter/src/gettext.rs
@@ -1,8 +1,10 @@
use lazy_static::lazy_static;
use regex::Regex;
use std::{
+ collections::BTreeMap,
fs::{File, OpenOptions},
io::{self, BufRead, BufReader, BufWriter, Write},
+ mem,
path::Path,
};
@@ -11,6 +13,22 @@ lazy_static! {
static ref PARAMETERS: Regex = Regex::new(r"%\([^)]*\)").unwrap();
}
+/// A parsed gettext translation file.
+pub struct Translation {
+ pub plural_form: Option<PluralForm>,
+ entries: Vec<MsgEntry>,
+}
+
+/// Known plural forms.
+#[derive(Clone, Copy, Debug)]
+pub enum PluralForm {
+ Single,
+ SingularForOne,
+ SingularForZeroAndOne,
+ Polish,
+ Russian,
+}
+
/// A message entry in a gettext translation file.
#[derive(Clone, Debug)]
pub struct MsgEntry {
@@ -28,41 +46,175 @@ pub enum MsgValue {
},
}
-impl From<String> for MsgValue {
- fn from(string: String) -> Self {
- MsgValue::Invariant(string)
- }
+/// A helper macro to match a string to various prefix and suffix combinations.
+macro_rules! match_str {
+ (
+ ( $string:expr )
+ $( [$start:expr, $middle:ident, $end:expr] => $body:tt )*
+ _ => $else:expr $(,)*
+ ) => {
+ $(
+ if let Some($middle) = parse_line($string, $start, $end) {
+ $body
+ } else
+ )* {
+ $else
+ }
+ };
}
-/// Load message entries from a gettext translation file.
-///
-/// The messages are normalized into a common format so that they can be compared to Android string
-/// resource entries.
-pub fn load_file(file_path: impl AsRef<Path>) -> Vec<MsgEntry> {
- let mut entries = Vec::new();
- let mut current_id = None;
- let file = BufReader::new(File::open(file_path).expect("Failed to open gettext file"));
+impl Translation {
+ /// Load message entries from a gettext translation file.
+ ///
+ /// The messages are normalized into a common format so that they can be compared to Android
+ /// string resource entries.
+ ///
+ /// The only metadata that is parsed from the file is the "Plural-Form" header. It is assumed
+ /// that the header value is one of some hard-coded values, so if new languages that have new
+ /// plurals are added, the code will have to be updated.
+ ///
+ /// An gettext translation file has the format in the example below:
+ ///
+ /// ```
+ /// # The start of the file can contain empty entries to include some header with meta
+ /// # information. Below is the header indicating the plural format.
+ /// msgid ""
+ /// msgstr ""
+ /// "Plural-Forms: nplurals=2; plural=(n != 1);"
+ ///
+ /// # Simple translated messages
+ /// msgid "Message in original language"
+ /// msgstr "Mesaĝo en tradukita lingvo"
+ ///
+ /// # Plural translated messages (with two forms)
+ /// msgid "One translated message"
+ /// msgid_plural "%d translated messages"
+ /// msgstr[0] "Unu tradukita mesaĝo"
+ /// msgstr[1] "%d tradukitaj mesaĝoj"
+ /// ```
+ pub fn from_file(file_path: impl AsRef<Path>) -> Self {
+ let mut parsing_header = false;
+ let mut entries = Vec::new();
+ let mut current_id = None;
+ let mut current_plural_id = None;
+ let mut plural_form = None;
+ let mut variants = BTreeMap::new();
+ let file = BufReader::new(File::open(file_path).expect("Failed to open gettext file"));
+
+ for line in file.lines() {
+ let line = line.expect("Failed to read from gettext file");
+ let line = line.trim();
+
+ match_str! { (line)
+ ["msgid \"", msg_id, "\""] => {
+ current_id = Some(normalize(msg_id));
+ }
+ ["msgstr \"", translation, "\""] => {
+ if let Some(id) = current_id.take() {
+ let value = MsgValue::from(normalize(translation));
+
+ parsing_header = id.is_empty() && translation.is_empty();
+
+ entries.push(MsgEntry { id, value });
+ }
+
+ current_id = None;
+ current_plural_id = None;
+ }
+ ["msgid_plural \"", plural_id, "\""] => {
+ current_plural_id = Some(normalize(plural_id));
+ parsing_header = false;
+ }
+ ["msgstr[", plural_translation, "\""] => {
+ let variant_id_end = plural_translation
+ .chars()
+ .position(|character| character == ']')
+ .expect("Invalid plural msgstr");
+ let variant_id: usize = plural_translation[..variant_id_end]
+ .parse()
+ .expect("Invalid variant index");
+ let variant_msg = parse_line(&plural_translation[variant_id_end..], "] \"", "")
+ .expect("Invalid plural msgstr");
- for line in file.lines() {
- let line = line.expect("Failed to read from gettext file");
- let line = line.trim();
+ variants.insert(variant_id, normalize(variant_msg));
+ parsing_header = false;
+ }
+ ["\"", header, "\\n\""] => {
+ if parsing_header {
+ if let Some(plural_formula) = parse_line(header, "Plural-Forms: ", ";") {
+ plural_form = Some(PluralForm::from_formula(plural_formula));
+ }
+ }
+ }
+ _ => {
+ if let Some(plural_id) = current_plural_id.take() {
+ let id = current_id.take().expect("Missing msgid for plural message");
+ let values = mem::replace(&mut variants, BTreeMap::new())
+ .into_iter()
+ .enumerate()
+ .inspect(|(index, (variant_id, _))| {
+ assert_eq!(
+ index, variant_id,
+ "Unexpected variant ID for plural msgstr"
+ )
+ })
+ .map(|(_, (_, value))| value)
+ .collect();
+ let value = MsgValue::Plural { plural_id, values };
- if let Some(msg_id) = parse_line(line, "msgid \"", "\"") {
- current_id = Some(normalize(msg_id));
- } else {
- if let Some(translation) = parse_line(line, "msgstr \"", "\"") {
- if let Some(id) = current_id.take() {
- let value = MsgValue::from(normalize(translation));
+ entries.push(MsgEntry { id, value });
+ }
- entries.push(MsgEntry { id, value });
+ current_id = None;
+ current_plural_id = None;
+ variants.clear();
+ parsing_header = false;
}
}
+ }
+
+ Self {
+ entries,
+ plural_form,
+ }
+ }
+}
+
+impl IntoIterator for Translation {
+ type Item = MsgEntry;
+ type IntoIter = std::vec::IntoIter<Self::Item>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.entries.into_iter()
+ }
+}
- current_id = None;
+impl PluralForm {
+ /// Obtain an instance based on a known plural formula.
+ ///
+ /// Plural variants need to be obtained using a formula. However, some locales have known
+ /// formulas, so they can be represented as a known plural form. This constructor can return a
+ /// plural form based on the formulas that are known to be used in the project.
+ pub fn from_formula(formula: &str) -> Self {
+ match formula {
+ "nplurals=1; plural=0" => PluralForm::Single,
+ "nplurals=2; plural=(n != 1)" => PluralForm::SingularForOne,
+ "nplurals=2; plural=(n > 1)" => PluralForm::SingularForZeroAndOne,
+ "nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3)" => {
+ PluralForm::Polish
+ }
+ "nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3))" => {
+ PluralForm::Russian
+ }
+ other => panic!("Unknown plural formula: {}", other),
}
}
+}
- entries
+impl From<String> for MsgValue {
+ fn from(string: String) -> Self {
+ MsgValue::Invariant(string)
+ }
}
/// Append message entries to a translation file.
diff --git a/android/translations-converter/src/main.rs b/android/translations-converter/src/main.rs
index 36a1970c2d..3f4499dd39 100644
--- a/android/translations-converter/src/main.rs
+++ b/android/translations-converter/src/main.rs
@@ -16,12 +16,16 @@
//! order when only named parameters are used, and Android strings only supported numbered
//! parameters.
//!
-//! Android's plural resources aren't currently translated, but this tool will convert them to
-//! gettext message templates and append them to the message template file. It's important to note
-//! that the first quantity item for a plural will be used as the `msgid`, so it shouldn't
-//! have any parameters. The last quantity item for a plural will be used as the `msgid_plural`,
-//! and it can contain parameters. This assumes a plural resource will have at least two items.
-//! While it would still work with a single item, this is an unlikely case for a plural resource.
+//! Android's plural resources are also translated using the same principle. It's important to note
+//! that the singular quantity item (i.e., the item where `quantity="one"`) for each Android plural
+//! resource will be used as the `msgid` to be search for in the gettext translations file.
+//!
+//! Missing translations are appended to the gettext messages template file (`messages.pot`). These
+//! are the entries for which no translation in any locale was found. When missing plurals are
+//! appended to the template file, the new message entries are created using the singular quantity
+//! item as the `msgid` and the other quantity item as the `msgid_plural`. Because of this, it is
+//! important to note that the former can't have parameters, while the latter can. Otherwise, the
+//! entries will have to be manually added.
//!
//! Note that this conversion procedure is very raw and likely very brittle, so while it works for
//! most cases, it is important to keep in mind that this is just a helper tool and manual steps are
@@ -68,13 +72,29 @@ fn main() {
}
}
- let mut missing_translations = known_strings.clone();
-
let plurals_file = File::open(resources_dir.join("values/plurals.xml"))
.expect("Failed to open plurals resources file");
let plural_resources: android::PluralResources =
serde_xml_rs::from_reader(plurals_file).expect("Failed to read plural resources file");
+ let known_plurals: HashMap<_, _> = plural_resources
+ .iter()
+ .map(|plural| {
+ let name = plural.name.clone();
+ let singular = plural
+ .items
+ .iter()
+ .find(|variant| variant.quantity == android::PluralQuantity::One)
+ .map(|variant| variant.string.clone())
+ .expect("Missing singular plural variant");
+
+ (singular, name)
+ })
+ .collect();
+
+ let mut missing_translations = known_strings.clone();
+ let mut missing_plurals = known_plurals.clone();
+
let locale_dir = Path::new("../../gui/locales");
let locale_files = fs::read_dir(&locale_dir)
.expect("Failed to open root locale directory")
@@ -101,9 +121,12 @@ fn main() {
locale,
known_urls.clone(),
known_strings.clone(),
- gettext::load_file(&locale_file),
+ known_plurals.clone(),
+ gettext::Translation::from_file(&locale_file),
destination_dir.join("strings.xml"),
+ destination_dir.join("plurals.xml"),
&mut missing_translations,
+ &mut missing_plurals,
);
}
@@ -125,20 +148,37 @@ fn main() {
.expect("Failed to append missing translations to message template file");
}
- if !plural_resources.is_empty() {
+ if !missing_plurals.is_empty() {
+ println!("Appending missing plural translations to template file:");
+
gettext::append_to_template(
&template_path,
plural_resources
.into_iter()
.inspect(|plural| {
- let last_item = &plural.items.last().expect("Plural items are empty").string;
+ let other_item = &plural
+ .items
+ .iter()
+ .find(|plural| plural.quantity == android::PluralQuantity::Other)
+ .expect("Plural items are empty")
+ .string;
- println!(" {}: {}", plural.name, last_item);
+ println!(" {}: {}", plural.name, other_item);
})
.map(|mut plural| {
- let plural_id = plural.items.pop().expect("Plural items are empty").string;
- plural.items.truncate(1);
- let id = plural.items.remove(0).string;
+ let singular_position = plural
+ .items
+ .iter()
+ .position(|plural| plural.quantity == android::PluralQuantity::One)
+ .expect("Missing singular variant to use as msgid");
+ let id = plural.items.remove(singular_position).string;
+
+ let other_position = plural
+ .items
+ .iter()
+ .position(|plural| plural.quantity == android::PluralQuantity::Other)
+ .expect("Missing other variant to use as msgid_plural");
+ let plural_id = plural.items.remove(other_position).string;
gettext::MsgEntry {
id,
@@ -188,19 +228,39 @@ fn generate_translations(
locale: &str,
known_urls: HashMap<String, String>,
mut known_strings: HashMap<String, String>,
- translations: Vec<gettext::MsgEntry>,
- output_path: impl AsRef<Path>,
+ mut known_plurals: HashMap<String, String>,
+ translations: gettext::Translation,
+ strings_output_path: impl AsRef<Path>,
+ plurals_output_path: impl AsRef<Path>,
missing_translations: &mut HashMap<String, String>,
+ missing_plurals: &mut HashMap<String, String>,
) {
- let mut localized_resource = android::StringResources::new();
+ let mut localized_strings = android::StringResources::new();
+ let mut localized_plurals = android::PluralResources::new();
+
+ let plural_quantities = android_plural_quantities_from_gettext_plural_form(
+ translations
+ .plural_form
+ .expect("Missing plural form for translation"),
+ );
for translation in translations {
- if let gettext::MsgValue::Invariant(translation_value) = translation.value {
- if let Some(android_key) = known_strings.remove(&translation.id) {
- localized_resource.push(android::StringResource::new(
- android_key,
- &translation_value,
- ));
+ match translation.value {
+ gettext::MsgValue::Invariant(translation_value) => {
+ if let Some(android_key) = known_strings.remove(&translation.id) {
+ localized_strings.push(android::StringResource::new(
+ android_key,
+ &translation_value,
+ ));
+ }
+ }
+ gettext::MsgValue::Plural { values, .. } => {
+ if let Some(android_key) = known_plurals.remove(&translation.id) {
+ localized_plurals.push(android::PluralResource::new(
+ android_key,
+ plural_quantities.clone().zip(values),
+ ));
+ }
}
}
}
@@ -209,19 +269,41 @@ fn generate_translations(
let locale_path = format!("/{}/", web_locale);
for (url, android_key) in known_urls {
- localized_resource.push(android::StringResource::new(
+ localized_strings.push(android::StringResource::new(
android_key,
&url.replacen("/en/", &locale_path, 1),
));
}
}
- localized_resource.sort();
+ localized_strings.sort();
- fs::write(output_path, localized_resource.to_string())
+ fs::write(strings_output_path, localized_strings.to_string())
.expect("Failed to create Android locale file");
+ fs::write(plurals_output_path, localized_plurals.to_string())
+ .expect("Failed to create Android plurals file");
+
missing_translations.retain(|translation, _| known_strings.contains_key(translation));
+ missing_plurals.retain(|translation, _| known_plurals.contains_key(translation));
+}
+
+/// Converts a gettext plural form into the plural quantities used by Android.
+///
+/// Returns an iterator that can be zipped with the gettext plural variants to produce the Android
+/// plural string items.
+fn android_plural_quantities_from_gettext_plural_form(
+ plural_form: gettext::PluralForm,
+) -> impl Iterator<Item = android::PluralQuantity> + Clone {
+ use android::PluralQuantity::*;
+ use gettext::PluralForm;
+
+ match plural_form {
+ PluralForm::Single => vec![Other],
+ PluralForm::SingularForOne | PluralForm::SingularForZeroAndOne => vec![One, Other],
+ PluralForm::Polish | PluralForm::Russian => vec![One, Few, Many, Other],
+ }
+ .into_iter()
}
/// Tries to map a translation locale to a locale used on the Mullvad website.