use std::collections::BTreeMap;
use crate::error::XcStringsError;
use crate::model::plural::required_plural_forms;
use crate::model::specifier::extract_specifiers;
use crate::model::translation::PluralUnit;
use crate::model::xcstrings::XcStringsFile;
pub fn get_untranslated_plurals(
file: &XcStringsFile,
locale: &str,
batch_size: usize,
offset: usize,
) -> Result<(Vec<PluralUnit>, usize), XcStringsError> {
if locale.is_empty() {
return Err(XcStringsError::LocaleNotFound("locale is empty".into()));
}
if batch_size == 0 || batch_size > 100 {
return Err(XcStringsError::InvalidBatchSize(format!(
"batch_size must be 1..=100, got {batch_size}"
)));
}
let required = required_plural_forms(locale);
let required_form_names: Vec<String> = required
.iter()
.map(|cat| cat.as_str().to_string())
.collect();
let mut results = Vec::new();
for (key, entry) in &file.strings {
if !entry.should_translate {
continue;
}
let source_loc = entry
.localizations
.as_ref()
.and_then(|locs| locs.get(&file.source_language));
let source_loc = match source_loc {
Some(loc) => loc,
None => continue,
};
let has_plural_variations = source_loc
.variations
.as_ref()
.is_some_and(|v| v.plural.is_some());
let has_device_variations = source_loc
.variations
.as_ref()
.is_some_and(|v| v.device.is_some());
let has_substitutions = source_loc.substitutions.is_some();
if !has_plural_variations && !has_substitutions && !has_device_variations {
continue;
}
let source_text = source_loc
.string_unit
.as_ref()
.map(|su| su.value.clone())
.unwrap_or_else(|| key.clone());
let format_specifiers: Vec<String> = extract_specifiers(&source_text)
.iter()
.map(|s| s.raw.clone())
.collect();
let mut source_forms = BTreeMap::new();
if let Some(variations) = &source_loc.variations
&& let Some(plural) = &variations.plural
{
for (form, var) in plural {
source_forms.insert(form.clone(), var.string_unit.value.clone());
}
}
let sub_plurals = source_loc
.substitutions
.as_ref()
.map(parse_substitution_plurals)
.unwrap_or_default();
if source_forms.is_empty()
&& !sub_plurals.is_empty()
&& let Some((_, forms)) = sub_plurals.first()
{
source_forms.clone_from(forms);
}
let device_forms: Vec<String> = if let Some(variations) = &source_loc.variations {
if let Some(device) = &variations.device {
device
.keys()
.map(|cat| {
serde_json::to_string(cat)
.unwrap_or_else(|_| "\"unknown\"".to_string())
.trim_matches('"')
.to_string()
})
.collect()
} else {
Vec::new()
}
} else {
Vec::new()
};
let target_loc = entry
.localizations
.as_ref()
.and_then(|locs| locs.get(locale));
let mut existing_translations = BTreeMap::new();
let mut target_has_all_forms = false;
if let Some(t_loc) = target_loc {
if let Some(variations) = &t_loc.variations {
if let Some(plural) = &variations.plural {
for (form, var) in plural {
existing_translations.insert(form.clone(), var.string_unit.value.clone());
}
}
if let Some(device) = &variations.device
&& !device_forms.is_empty()
&& device.len() >= device_forms.len()
{
if !has_plural_variations && !has_substitutions {
target_has_all_forms = true;
}
}
}
if has_plural_variations || has_substitutions {
target_has_all_forms = required_form_names
.iter()
.all(|form| existing_translations.contains_key(form));
}
}
if target_has_all_forms {
continue;
}
results.push(PluralUnit {
key: key.clone(),
source_text,
target_locale: locale.to_string(),
comment: entry.comment.clone(),
format_specifiers,
required_forms: required_form_names.clone(),
source_forms,
existing_translations,
has_substitutions,
device_forms,
});
}
let total = results.len();
let batch: Vec<PluralUnit> = results.into_iter().skip(offset).take(batch_size).collect();
Ok((batch, total))
}
fn parse_substitution_plurals(
subs: &BTreeMap<String, serde_json::Value>,
) -> Vec<(String, BTreeMap<String, String>)> {
let mut result = Vec::new();
for (name, value) in subs {
let mut forms = BTreeMap::new();
let plural = value
.get("variations")
.and_then(|v| v.get("plural"))
.and_then(|p| p.as_object());
if let Some(plural_map) = plural {
for (form, form_value) in plural_map {
if let Some(val) = form_value
.get("stringUnit")
.and_then(|su| su.get("value"))
.and_then(|v| v.as_str())
{
forms.insert(form.clone(), val.to_string());
}
}
}
if !forms.is_empty() {
result.push((name.clone(), forms));
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::xcstrings::XcStringsFile;
#[test]
fn test_empty_file() {
let json = r#"{
"sourceLanguage": "en",
"strings": {},
"version": "1.0"
}"#;
let file: XcStringsFile = serde_json::from_str(json).unwrap();
let (batch, total) = get_untranslated_plurals(&file, "de", 10, 0).unwrap();
assert!(batch.is_empty());
assert_eq!(total, 0);
}
#[test]
fn test_plural_key_needing_translation() {
let content = include_str!("../../tests/fixtures/with_plurals.xcstrings");
let file: XcStringsFile = serde_json::from_str(content).unwrap();
let (batch, total) = get_untranslated_plurals(&file, "uk", 100, 0).unwrap();
assert!(total > 0);
let days = batch.iter().find(|u| u.key == "days_remaining");
assert!(days.is_some(), "days_remaining should need translation");
let days = days.unwrap();
assert_eq!(days.target_locale, "uk");
assert!(days.required_forms.contains(&"one".to_string()));
assert!(days.required_forms.contains(&"few".to_string()));
assert!(days.required_forms.contains(&"many".to_string()));
assert!(days.required_forms.contains(&"other".to_string()));
assert_eq!(
days.source_forms.get("one"),
Some(&"%lld day remaining".to_string())
);
assert_eq!(
days.source_forms.get("other"),
Some(&"%lld days remaining".to_string())
);
}
#[test]
fn test_fully_translated_plural_excluded() {
let content = include_str!("../../tests/fixtures/with_plurals.xcstrings");
let file: XcStringsFile = serde_json::from_str(content).unwrap();
let (batch, _) = get_untranslated_plurals(&file, "uk", 100, 0).unwrap();
let items = batch.iter().find(|u| u.key == "items_count");
assert!(
items.is_none(),
"fully translated items_count should be excluded"
);
}
#[test]
fn test_partially_translated_included() {
let content = include_str!("../../tests/fixtures/with_plurals.xcstrings");
let file: XcStringsFile = serde_json::from_str(content).unwrap();
let (batch, _) = get_untranslated_plurals(&file, "de", 100, 0).unwrap();
let photos = batch.iter().find(|u| u.key == "photos_count");
assert!(
photos.is_some(),
"partially translated photos_count should be included"
);
let photos = photos.unwrap();
assert_eq!(
photos.existing_translations.get("other"),
Some(&"%lld Fotos".to_string())
);
assert!(!photos.existing_translations.contains_key("one"));
}
#[test]
fn test_substitution_key_parsed() {
let content = include_str!("../../tests/fixtures/with_substitutions.xcstrings");
let file: XcStringsFile = serde_json::from_str(content).unwrap();
let (batch, total) = get_untranslated_plurals(&file, "de", 100, 0).unwrap();
assert!(total > 0);
let bird = batch.iter().find(|u| u.key == "bird_sighting");
assert!(bird.is_some(), "bird_sighting should be returned");
let bird = bird.unwrap();
assert!(bird.has_substitutions);
assert_eq!(bird.source_text, "I saw %#@BIRDS@ in the park");
assert!(bird.source_forms.contains_key("one"));
assert!(bird.source_forms.contains_key("other"));
}
#[test]
fn test_device_variant_key() {
let content = include_str!("../../tests/fixtures/with_device_variants.xcstrings");
let file: XcStringsFile = serde_json::from_str(content).unwrap();
let (batch, total) = get_untranslated_plurals(&file, "de", 100, 0).unwrap();
assert!(total > 0);
let tap = batch.iter().find(|u| u.key == "tap_action");
assert!(tap.is_some(), "tap_action should be returned");
let tap = tap.unwrap();
assert!(!tap.device_forms.is_empty());
assert!(tap.device_forms.contains(&"iphone".to_string()));
assert!(tap.device_forms.contains(&"ipad".to_string()));
assert!(tap.device_forms.contains(&"mac".to_string()));
}
#[test]
fn test_should_not_translate_excluded() {
let content = include_str!("../../tests/fixtures/with_plurals.xcstrings");
let file: XcStringsFile = serde_json::from_str(content).unwrap();
let (batch, _) = get_untranslated_plurals(&file, "de", 100, 0).unwrap();
let no_translate = batch.iter().find(|u| u.key == "no_translate_plural");
assert!(
no_translate.is_none(),
"shouldTranslate=false key should be excluded"
);
}
#[test]
fn test_batch_pagination() {
let content = include_str!("../../tests/fixtures/with_plurals.xcstrings");
let file: XcStringsFile = serde_json::from_str(content).unwrap();
let (_, total) = get_untranslated_plurals(&file, "de", 100, 0).unwrap();
assert!(total > 1, "need at least 2 plural keys for pagination test");
let (batch1, total1) = get_untranslated_plurals(&file, "de", 1, 0).unwrap();
assert_eq!(batch1.len(), 1);
assert_eq!(total1, total);
let (batch2, total2) = get_untranslated_plurals(&file, "de", 1, 1).unwrap();
assert_eq!(total2, total);
assert_eq!(batch2.len(), 1);
assert_ne!(batch1[0].key, batch2[0].key);
let (batch_empty, _) = get_untranslated_plurals(&file, "de", 1, total).unwrap();
assert!(batch_empty.is_empty());
}
}