1use crate::{
7 ConflictStrategy,
8 error::Error,
9 formats::{
10 AndroidStringsFormat, CSVFormat, FormatType, StringsFormat, TSVFormat, XcstringsFormat,
11 },
12 placeholder::normalize_placeholders,
13 traits::Parser,
14 types::Resource,
15};
16use std::path::Path;
17
18pub fn convert_resources_to_format(
51 resources: Vec<Resource>,
52 output_path: &str,
53 output_format: FormatType,
54) -> Result<(), Error> {
55 match output_format {
56 FormatType::AndroidStrings(_) => {
57 if let Some(resource) = resources.into_iter().next() {
58 AndroidStringsFormat::from(resource)
59 .write_to(Path::new(output_path))
60 .map_err(|e| {
61 Error::conversion_error(
62 format!("Error writing AndroidStrings output: {}", e),
63 None,
64 )
65 })
66 } else {
67 Err(Error::InvalidResource(
68 "No resources to convert".to_string(),
69 ))
70 }
71 }
72 FormatType::Strings(_) => {
73 if let Some(resource) = resources.into_iter().next() {
74 StringsFormat::try_from(resource)
75 .and_then(|f| f.write_to(Path::new(output_path)))
76 .map_err(|e| {
77 Error::conversion_error(
78 format!("Error writing Strings output: {}", e),
79 None,
80 )
81 })
82 } else {
83 Err(Error::InvalidResource(
84 "No resources to convert".to_string(),
85 ))
86 }
87 }
88 FormatType::Xcstrings => XcstringsFormat::try_from(resources)
89 .and_then(|f| f.write_to(Path::new(output_path)))
90 .map_err(|e| {
91 Error::conversion_error(format!("Error writing Xcstrings output: {}", e), None)
92 }),
93 FormatType::CSV => CSVFormat::try_from(resources)
94 .and_then(|f| f.write_to(Path::new(output_path)))
95 .map_err(|e| Error::conversion_error(format!("Error writing CSV output: {}", e), None)),
96 FormatType::TSV => TSVFormat::try_from(resources)
97 .and_then(|f| f.write_to(Path::new(output_path)))
98 .map_err(|e| Error::conversion_error(format!("Error writing TSV output: {}", e), None)),
99 }
100}
101
102pub fn convert<P: AsRef<Path>>(
128 input: P,
129 input_format: FormatType,
130 output: P,
131 output_format: FormatType,
132) -> Result<(), Error> {
133 let output_format = if let Some(lang) = input_format.language() {
135 output_format.with_language(Some(lang.clone()))
136 } else {
137 output_format
138 };
139
140 if !input_format.matches_language_of(&output_format) {
141 return Err(Error::InvalidResource(
142 "Input and output formats must match in language.".to_string(),
143 ));
144 }
145
146 let mut resources = match input_format {
148 FormatType::AndroidStrings(_) => vec![AndroidStringsFormat::read_from(input)?.into()],
149 FormatType::Strings(_) => vec![StringsFormat::read_from(input)?.into()],
150 FormatType::Xcstrings => Vec::<Resource>::try_from(XcstringsFormat::read_from(input)?)?,
151 FormatType::CSV => Vec::<Resource>::try_from(CSVFormat::read_from(input)?)?,
152 FormatType::TSV => Vec::<Resource>::try_from(TSVFormat::read_from(input)?)?,
153 };
154
155 if let Some(l) = input_format.language().cloned() {
157 for res in &mut resources {
158 if res.metadata.language.is_empty() {
159 res.metadata.language = l.clone();
160 }
161 }
162 }
163
164 let pick_resource = |lang: Option<String>| -> Option<Resource> {
166 match lang {
167 Some(l) => resources.iter().find(|r| r.metadata.language == l).cloned(),
168 None => resources.first().cloned(),
169 }
170 };
171
172 match output_format {
173 FormatType::AndroidStrings(lang) => {
174 let resource = pick_resource(lang);
175 if let Some(res) = resource {
176 AndroidStringsFormat::from(res).write_to(output)
177 } else {
178 Err(Error::InvalidResource(
179 "No matching resource for output language.".to_string(),
180 ))
181 }
182 }
183 FormatType::Strings(lang) => {
184 let resource = pick_resource(lang);
185 if let Some(res) = resource {
186 StringsFormat::try_from(res)?.write_to(output)
187 } else {
188 Err(Error::InvalidResource(
189 "No matching resource for output language.".to_string(),
190 ))
191 }
192 }
193 FormatType::Xcstrings => XcstringsFormat::try_from(resources)?.write_to(output),
194 FormatType::CSV => CSVFormat::try_from(resources)?.write_to(output),
195 FormatType::TSV => TSVFormat::try_from(resources)?.write_to(output),
196 }
197}
198
199pub fn convert_with_normalization<P: AsRef<Path>>(
219 input: P,
220 input_format: FormatType,
221 output: P,
222 output_format: FormatType,
223 normalize: bool,
224) -> Result<(), Error> {
225 let input = input.as_ref();
226 let output = output.as_ref();
227
228 let output_format = if let Some(lang) = input_format.language() {
230 output_format.with_language(Some(lang.clone()))
231 } else {
232 output_format
233 };
234
235 if !input_format.matches_language_of(&output_format) {
236 return Err(Error::InvalidResource(
237 "Input and output formats must match in language.".to_string(),
238 ));
239 }
240
241 let mut resources = match input_format {
243 FormatType::AndroidStrings(_) => vec![AndroidStringsFormat::read_from(input)?.into()],
244 FormatType::Strings(_) => vec![StringsFormat::read_from(input)?.into()],
245 FormatType::Xcstrings => Vec::<Resource>::try_from(XcstringsFormat::read_from(input)?)?,
246 FormatType::CSV => Vec::<Resource>::try_from(CSVFormat::read_from(input)?)?,
247 FormatType::TSV => Vec::<Resource>::try_from(TSVFormat::read_from(input)?)?,
248 };
249
250 if let Some(l) = input_format.language().cloned() {
252 for res in &mut resources {
253 if res.metadata.language.is_empty() {
254 res.metadata.language = l.clone();
255 }
256 }
257 }
258
259 if normalize {
260 for res in &mut resources {
261 for entry in &mut res.entries {
262 match &mut entry.value {
263 crate::types::Translation::Singular(v) => {
264 *v = normalize_placeholders(v);
265 }
266 crate::types::Translation::Plural(p) => {
267 for (_c, v) in p.forms.iter_mut() {
268 *v = normalize_placeholders(v);
269 }
270 }
271 }
272 }
273 }
274 }
275
276 let pick_resource = |lang: Option<String>| -> Option<Resource> {
278 match lang {
279 Some(l) => resources.iter().find(|r| r.metadata.language == l).cloned(),
280 None => resources.first().cloned(),
281 }
282 };
283
284 match output_format {
285 FormatType::AndroidStrings(lang) => {
286 let resource = pick_resource(lang);
287 if let Some(res) = resource {
288 AndroidStringsFormat::from(res).write_to(output)
289 } else {
290 Err(Error::InvalidResource(
291 "No matching resource for output language.".to_string(),
292 ))
293 }
294 }
295 FormatType::Strings(lang) => {
296 let resource = pick_resource(lang);
297 if let Some(res) = resource {
298 StringsFormat::try_from(res)?.write_to(output)
299 } else {
300 Err(Error::InvalidResource(
301 "No matching resource for output language.".to_string(),
302 ))
303 }
304 }
305 FormatType::Xcstrings => XcstringsFormat::try_from(resources)?.write_to(output),
306 FormatType::CSV => CSVFormat::try_from(resources)?.write_to(output),
307 FormatType::TSV => TSVFormat::try_from(resources)?.write_to(output),
308 }
309}
310
311pub fn convert_auto<P: AsRef<Path>>(input: P, output: P) -> Result<(), Error> {
333 let input_format = infer_format_from_path(&input).ok_or_else(|| {
334 Error::UnknownFormat(format!(
335 "Cannot infer input format from extension: {:?}",
336 input.as_ref().extension()
337 ))
338 })?;
339 let output_format = infer_format_from_path(&output).ok_or_else(|| {
340 Error::UnknownFormat(format!(
341 "Cannot infer output format from extension: {:?}",
342 output.as_ref().extension()
343 ))
344 })?;
345 convert(input, input_format, output, output_format)
346}
347
348#[cfg(test)]
349mod normalize_tests {
350 use super::*;
351 use std::fs;
352
353 #[test]
354 fn test_convert_strings_to_android_with_normalization() {
355 let tmp = tempfile::tempdir().unwrap();
356 let strings = tmp.path().join("en.strings");
357 let xml = tmp.path().join("strings.xml");
358
359 fs::write(&strings, "\n\"g\" = \"Hello %@ and %1$@ and %ld\";\n").unwrap();
360
361 convert(
363 &strings,
364 FormatType::Strings(Some("en".into())),
365 &xml,
366 FormatType::AndroidStrings(Some("en".into())),
367 )
368 .unwrap();
369 let content = fs::read_to_string(&xml).unwrap();
370 assert!(content.contains("Hello %"));
371
372 convert_with_normalization(
374 &strings,
375 FormatType::Strings(Some("en".into())),
376 &xml,
377 FormatType::AndroidStrings(Some("en".into())),
378 true,
379 )
380 .unwrap();
381 let content = fs::read_to_string(&xml).unwrap();
382 assert!(content.contains("%s"));
383 assert!(content.contains("%1$s"));
384 assert!(content.contains("%d"));
385 }
386}
387
388pub fn convert_auto_with_normalization<P: AsRef<Path>>(
402 input: P,
403 output: P,
404 normalize: bool,
405) -> Result<(), Error> {
406 let input_format = infer_format_from_path(&input).ok_or_else(|| {
407 Error::UnknownFormat(format!(
408 "Cannot infer input format from extension: {:?}",
409 input.as_ref().extension()
410 ))
411 })?;
412 let output_format = infer_format_from_path(&output).ok_or_else(|| {
413 Error::UnknownFormat(format!(
414 "Cannot infer output format from extension: {:?}",
415 output.as_ref().extension()
416 ))
417 })?;
418 convert_with_normalization(input, input_format, output, output_format, normalize)
419}
420
421pub fn infer_format_from_extension<P: AsRef<Path>>(path: P) -> Option<FormatType> {
456 let path = path.as_ref();
457 let extension = path.extension()?.to_str()?;
458
459 match extension.to_lowercase().as_str() {
460 "strings" => Some(FormatType::Strings(None)),
461 "xml" => Some(FormatType::AndroidStrings(None)),
462 "xcstrings" => Some(FormatType::Xcstrings),
463 "csv" => Some(FormatType::CSV),
464 "tsv" => Some(FormatType::TSV),
465 _ => None,
466 }
467}
468
469pub fn infer_format_from_path<P: AsRef<Path>>(path: P) -> Option<FormatType> {
501 match infer_format_from_extension(&path) {
502 Some(format) => match format {
503 FormatType::Xcstrings | FormatType::CSV | FormatType::TSV => Some(format),
505 FormatType::AndroidStrings(_) | FormatType::Strings(_) => {
506 let lang = infer_language_from_path(&path, &format).ok().flatten();
507 Some(format.with_language(lang))
508 }
509 },
510 None => None,
511 }
512}
513
514pub fn infer_language_from_path<P: AsRef<Path>>(
566 path: P,
567 format: &FormatType,
568) -> Result<Option<String>, Error> {
569 use std::str::FromStr;
570 use unic_langid::LanguageIdentifier;
571
572 let path = path.as_ref();
573
574 fn normalize_lang(candidate: &str) -> Option<String> {
576 let canonical = candidate.replace('_', "-");
577 LanguageIdentifier::from_str(&canonical).ok()?;
578 Some(canonical)
579 }
580
581 fn parse_android_values_lang(values_component: &str) -> Option<String> {
583 if let Some(rest) = values_component.strip_prefix("values-") {
585 if rest.is_empty() {
586 return None;
587 }
588 if let Some(b_rest) = rest.strip_prefix("b+") {
589 let parts: Vec<&str> = b_rest.split('+').collect();
591 if parts.is_empty() {
592 return None;
593 }
594 let lang = parts.join("-");
595 return normalize_lang(&lang);
596 }
597 let mut lang: Option<String> = None;
599 let mut region: Option<String> = None;
600 for token in rest.split('-') {
601 if token.is_empty() {
602 continue;
603 }
604 if let Some(r) = token.strip_prefix('r') {
605 if !r.is_empty() {
606 region = Some(r.to_string());
607 }
608 } else if lang.is_none() {
609 lang = Some(token.to_string());
610 }
611 }
612 if let Some(l) = lang {
613 let mut tag = l;
614 if let Some(r) = region {
615 tag = format!("{}-{}", tag, r);
616 }
617 return normalize_lang(&tag);
618 }
619 }
620 None
621 }
622
623 let mut components: Vec<String> = path
625 .components()
626 .map(|c| c.as_os_str().to_string_lossy().into_owned())
627 .collect();
628 components.reverse();
629
630 for comp in components {
631 match format {
632 FormatType::Strings(_) => {
633 if let Some(lang_dir) = comp.strip_suffix(".lproj")
635 && let Some(lang) = normalize_lang(lang_dir)
636 {
637 return Ok(Some(lang));
638 }
639 if comp.ends_with(".strings")
640 && let Some(stem) = Path::new(&comp).file_stem().and_then(|s| s.to_str())
641 {
642 let looks_like_lang = (stem.len() == 2
643 && stem.chars().all(|c| c.is_ascii_lowercase()))
644 || stem.contains('-');
645 if looks_like_lang && let Some(lang) = normalize_lang(stem) {
646 return Ok(Some(lang));
647 }
648 }
649 }
650 FormatType::AndroidStrings(_) => {
651 if comp == "values" {
653 return Ok(Some("en".to_string()));
655 }
656 if let Some(lang) = parse_android_values_lang(&comp) {
657 return Ok(Some(lang));
658 }
659 }
660 _ => {}
661 }
662 }
663
664 Ok(None)
665}
666
667pub fn write_resources_to_file(resources: &[Resource], file_path: &String) -> Result<(), Error> {
680 let path = Path::new(&file_path);
681
682 if let Some(first) = resources.first() {
683 match first.metadata.custom.get("format").map(String::as_str) {
684 Some("AndroidStrings") => AndroidStringsFormat::from(first.clone()).write_to(path)?,
685 Some("Strings") => StringsFormat::try_from(first.clone())?.write_to(path)?,
686 Some("Xcstrings") => XcstringsFormat::try_from(resources.to_vec())?.write_to(path)?,
687 Some("CSV") => CSVFormat::try_from(resources.to_vec())?.write_to(path)?,
688 Some("TSV") => TSVFormat::try_from(resources.to_vec())?.write_to(path)?,
689 _ => Err(Error::UnsupportedFormat(format!(
690 "Unsupported format: {:?}",
691 first.metadata.custom.get("format")
692 )))?,
693 }
694 }
695
696 Ok(())
697}
698
699pub fn merge_resources(
749 resources: &[Resource],
750 conflict_strategy: &ConflictStrategy,
751) -> Result<Resource, Error> {
752 if resources.is_empty() {
753 return Err(Error::InvalidResource("No resources to merge".to_string()));
754 }
755
756 let first_language = &resources[0].metadata.language;
758 for (i, resource) in resources.iter().enumerate() {
759 if resource.metadata.language != *first_language {
760 return Err(Error::InvalidResource(format!(
761 "Cannot merge resources with different languages: resource {} has language '{}', but first resource has language '{}'",
762 i + 1,
763 resource.metadata.language,
764 first_language
765 )));
766 }
767 }
768
769 let mut merged = resources[0].clone();
770 let mut all_entries = std::collections::HashMap::new();
771
772 for resource in resources {
774 for entry in &resource.entries {
775 match conflict_strategy {
778 crate::types::ConflictStrategy::First => {
779 all_entries
780 .entry(&entry.id)
781 .or_insert_with(|| entry.clone());
782 }
783 crate::types::ConflictStrategy::Last => {
784 all_entries.insert(&entry.id, entry.clone());
785 }
786 crate::types::ConflictStrategy::Skip => {
787 if all_entries.contains_key(&entry.id) {
788 all_entries.remove(&entry.id);
790 continue;
791 }
792 all_entries.insert(&entry.id, entry.clone());
793 }
794 }
795 }
796 }
797
798 merged.entries = all_entries.into_values().collect();
800 merged.entries.sort_by(|a, b| a.id.cmp(&b.id));
801
802 Ok(merged)
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808 use crate::types::{Entry, EntryStatus, Metadata, Plural, PluralCategory, Translation};
809 use std::collections::{BTreeMap, HashMap};
810
811 #[test]
812 fn test_convert_csv_to_android_strings_en() {
813 let tmp = tempfile::tempdir().unwrap();
814 let input = tmp.path().join("in.csv");
815 let output = tmp.path().join("strings.xml");
816
817 std::fs::write(
819 &input,
820 "key,en,fr\nhello,Hello,Bonjour\nbye,Goodbye,Au revoir\n",
821 )
822 .unwrap();
823
824 convert(
825 &input,
826 FormatType::CSV,
827 &output,
828 FormatType::AndroidStrings(Some("en".into())),
829 )
830 .unwrap();
831
832 let android = crate::formats::AndroidStringsFormat::read_from(&output).unwrap();
834 assert_eq!(android.strings.len(), 2);
836 let mut names: Vec<&str> = android.strings.iter().map(|s| s.name.as_str()).collect();
837 names.sort();
838 assert_eq!(names, vec!["bye", "hello"]);
839 let hello = android.strings.iter().find(|s| s.name == "hello").unwrap();
840 assert_eq!(hello.value, "Hello");
841 let bye = android.strings.iter().find(|s| s.name == "bye").unwrap();
842 assert_eq!(bye.value, "Goodbye");
843 }
844
845 #[test]
846 fn test_convert_xcstrings_plurals_to_android() {
847 let tmp = tempfile::tempdir().unwrap();
848 let input = tmp.path().join("in.xcstrings");
849 let output = tmp.path().join("strings.xml");
850
851 let mut custom = HashMap::new();
853 custom.insert("source_language".into(), "en".into());
854 custom.insert("version".into(), "1.0".into());
855
856 let mut forms = BTreeMap::new();
857 forms.insert(PluralCategory::One, "One apple".to_string());
858 forms.insert(PluralCategory::Other, "%d apples".to_string());
859
860 let res = Resource {
861 metadata: Metadata {
862 language: "en".into(),
863 domain: "domain".into(),
864 custom,
865 },
866 entries: vec![Entry {
867 id: "apples".into(),
868 value: Translation::Plural(Plural {
869 id: "apples".into(),
870 forms,
871 }),
872 comment: Some("Count apples".into()),
873 status: EntryStatus::Translated,
874 custom: HashMap::new(),
875 }],
876 };
877
878 let xc = crate::formats::XcstringsFormat::try_from(vec![res]).unwrap();
880 xc.write_to(&input).unwrap();
881
882 convert(
884 &input,
885 FormatType::Xcstrings,
886 &output,
887 FormatType::AndroidStrings(Some("en".into())),
888 )
889 .unwrap();
890
891 let android = crate::formats::AndroidStringsFormat::read_from(&output).unwrap();
893 assert_eq!(android.plurals.len(), 1);
894 let p = android.plurals.into_iter().next().unwrap();
895 assert_eq!(p.name, "apples");
896 let mut qs: Vec<_> = p
898 .items
899 .into_iter()
900 .map(|i| match i.quantity {
901 PluralCategory::One => ("one", i.value),
902 PluralCategory::Other => ("other", i.value),
903 PluralCategory::Zero => ("zero", i.value),
904 PluralCategory::Two => ("two", i.value),
905 PluralCategory::Few => ("few", i.value),
906 PluralCategory::Many => ("many", i.value),
907 })
908 .collect();
909 qs.sort_by(|a, b| a.0.cmp(b.0));
910 assert!(qs.iter().any(|(q, v)| *q == "one" && v == "One apple"));
911 assert!(qs.iter().any(|(q, v)| *q == "other" && v == "%d apples"));
912 }
913}