Skip to content

Commit b8b3fdb

Browse files
authored
Merge pull request #14 from WendellXY/wendell/xliff-1-2-support
Add XLIFF 1.2 support to core
2 parents 6d9849d + 36c2354 commit b8b3fdb

19 files changed

Lines changed: 2017 additions & 39 deletions

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
</p>
1010

1111
<p align="center">
12-
Convert, inspect, normalize, translate, annotate, and sync localization assets across Apple, Android, CSV, TSV, and Tolgee-backed pipelines.
12+
Convert, inspect, normalize, translate, annotate, and sync localization assets across Apple, XLIFF, Android, CSV, TSV, and Tolgee-backed pipelines.
1313
</p>
1414

1515
<p align="center">
@@ -47,7 +47,7 @@ Most localization workflows are a pile of one-off scripts, format-specific tools
4747
## Highlights
4848

4949
- Unified data model for singular and plural translations
50-
- Read and write support for Apple `.strings`, Apple `.xcstrings`, Android `strings.xml`, CSV, and TSV
50+
- Read and write support for Apple `.strings`, Apple `.xcstrings`, Apple/Xcode `.xliff`, Android `strings.xml`, CSV, and TSV
5151
- CLI commands for convert, diff, merge, sync, edit, normalize, view, stats, debug, translate, annotate, and Tolgee sync
5252
- Config-driven AI workflows with `langcodec.toml`
5353
- Rust library API for teams building custom localization pipelines
@@ -73,6 +73,12 @@ Try the workflow:
7373
# Convert Apple strings to Android XML
7474
langcodec convert -i Localizable.strings -o values/strings.xml
7575

76+
# Export an Apple/Xcode translation exchange file
77+
langcodec convert -i Localizable.xcstrings -o Localizable.xliff --output-lang fr
78+
79+
# Import XLIFF back into an Xcode string catalog
80+
langcodec convert -i Localizable.xliff -o Localizable.xcstrings
81+
7682
# Inspect work that still needs attention
7783
langcodec view -i Localizable.xcstrings --status new,needs_review --keys-only
7884

@@ -125,6 +131,7 @@ langcodec annotate \
125131
| --------------------- | :---: | :---: | :-----: | :---: | :-----: | :------: |
126132
| Apple `.strings` | yes | yes | yes | yes | no | yes |
127133
| Apple `.xcstrings` | yes | yes | yes | yes | yes | yes |
134+
| Apple `.xliff` | yes | yes | yes | no | no | yes |
128135
| Android `strings.xml` | yes | yes | yes | yes | yes | yes |
129136
| CSV | yes | yes | yes | yes | no | no |
130137
| TSV | yes | yes | yes | yes | no | no |

langcodec-cli/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Supported formats:
88

99
- Apple `.strings`
1010
- Apple `.xcstrings`
11+
- Apple/Xcode `.xliff`
1112
- Android `strings.xml`
1213
- CSV
1314
- TSV
@@ -100,8 +101,12 @@ langcodec tolgee --help
100101
```sh
101102
langcodec convert -i Localizable.xcstrings -o translations.csv
102103
langcodec convert -i translations.csv -o values/strings.xml
104+
langcodec convert -i Localizable.xcstrings -o Localizable.xliff --output-lang fr
105+
langcodec convert -i Localizable.xliff -o Localizable.xcstrings
103106
```
104107

108+
For `.xliff` output, pass `--output-lang` to choose the target language. Use `--source-language` when the source language is ambiguous.
109+
105110
### Find strings that still need work
106111

107112
```sh
@@ -122,6 +127,8 @@ langcodec edit set -i values/strings.xml -k welcome_title -v "Welcome"
122127
langcodec normalize -i 'locales/**/*.{strings,xml,csv,tsv,xcstrings}' --check
123128
```
124129

130+
`normalize`, `edit`, and `sync` intentionally do not operate on `.xliff` in v1; convert XLIFF into a project format first.
131+
125132
### Sync or merge existing translation assets
126133

127134
```sh

langcodec-cli/src/convert.rs

Lines changed: 187 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,117 @@ fn parse_standard_output_format(format: &str) -> Result<FormatType, String> {
2323
"strings" => Ok(FormatType::Strings(None)),
2424
"android" | "androidstrings" => Ok(FormatType::AndroidStrings(None)),
2525
"xcstrings" => Ok(FormatType::Xcstrings),
26+
"xliff" => Ok(FormatType::Xliff(None)),
2627
"csv" => Ok(FormatType::CSV),
2728
"tsv" => Ok(FormatType::TSV),
2829
_ => Err(format!(
29-
"Unsupported output format: '{}'. Supported formats: strings, android, xcstrings, csv, tsv",
30+
"Unsupported output format: '{}'. Supported formats: strings, android, xcstrings, xliff, csv, tsv",
3031
format
3132
)),
3233
}
3334
}
3435

36+
fn wants_named_output(
37+
output: &str,
38+
output_format_hint: Option<&String>,
39+
extension: &str,
40+
format_name: &str,
41+
) -> bool {
42+
output.ends_with(extension)
43+
|| output_format_hint.is_some_and(|hint| hint.eq_ignore_ascii_case(format_name))
44+
}
45+
46+
fn wants_xcstrings_output(output: &str, output_format_hint: Option<&String>) -> bool {
47+
wants_named_output(output, output_format_hint, ".xcstrings", "xcstrings")
48+
}
49+
50+
fn wants_xliff_output(output: &str, output_format_hint: Option<&String>) -> bool {
51+
wants_named_output(output, output_format_hint, ".xliff", "xliff")
52+
}
53+
54+
fn resolve_xliff_source_language(
55+
resources: &[langcodec::Resource],
56+
explicit_source_language: Option<&String>,
57+
target_language: &str,
58+
) -> Result<String, String> {
59+
if let Some(explicit_source_language) = explicit_source_language {
60+
let trimmed = explicit_source_language.trim();
61+
if trimmed.is_empty() {
62+
return Err("--source-language cannot be empty for .xliff output".to_string());
63+
}
64+
return Ok(trimmed.to_string());
65+
}
66+
67+
let metadata_source_languages = resources
68+
.iter()
69+
.filter_map(|resource| resource.metadata.custom.get("source_language"))
70+
.map(|value| value.trim())
71+
.filter(|value| !value.is_empty())
72+
.collect::<std::collections::BTreeSet<_>>();
73+
74+
let available_languages = resources
75+
.iter()
76+
.map(|resource| resource.metadata.language.trim())
77+
.filter(|language| !language.is_empty())
78+
.collect::<std::collections::BTreeSet<_>>();
79+
80+
if metadata_source_languages.len() > 1 {
81+
return Err(format!(
82+
"Conflicting source_language metadata found for .xliff output: {}. Pass --source-language.",
83+
metadata_source_languages
84+
.into_iter()
85+
.collect::<Vec<_>>()
86+
.join(", ")
87+
));
88+
}
89+
if let Some(source_language) = metadata_source_languages.iter().next() {
90+
let extras = available_languages
91+
.iter()
92+
.filter(|language| **language != *source_language && **language != target_language)
93+
.cloned()
94+
.collect::<Vec<_>>();
95+
96+
if *source_language != target_language && extras.is_empty() {
97+
return Ok((*source_language).to_string());
98+
}
99+
100+
return Err(format!(
101+
"source_language metadata '{}' is ambiguous for .xliff output with available languages ({}). Pass --source-language.",
102+
source_language,
103+
available_languages
104+
.iter()
105+
.cloned()
106+
.collect::<Vec<_>>()
107+
.join(", ")
108+
));
109+
}
110+
111+
if available_languages.is_empty() {
112+
return Err("XLIFF output requires language metadata on the input resources".to_string());
113+
}
114+
115+
if available_languages.len() == 1 {
116+
return Ok(available_languages.iter().next().unwrap().to_string());
117+
}
118+
119+
let non_target_languages = available_languages
120+
.iter()
121+
.filter(|language| **language != target_language)
122+
.cloned()
123+
.collect::<Vec<_>>();
124+
125+
match non_target_languages.as_slice() {
126+
[source_language] => Ok((*source_language).to_string()),
127+
_ => Err(format!(
128+
"Could not infer the XLIFF source language from available languages ({}). Pass --source-language.",
129+
available_languages
130+
.into_iter()
131+
.collect::<Vec<_>>()
132+
.join(", ")
133+
)),
134+
}
135+
}
136+
35137
fn infer_output_path_language(path: &str) -> Option<String> {
36138
match langcodec::infer_format_from_path(path) {
37139
Some(FormatType::Strings(Some(lang))) | Some(FormatType::AndroidStrings(Some(lang))) => {
@@ -73,10 +175,21 @@ fn resolve_convert_output_format(
73175
}
74176
Ok(output_format)
75177
}
178+
FormatType::Xliff(_) => {
179+
if let Some(language) = output_lang {
180+
output_format = output_format.with_language(Some(language.clone()));
181+
Ok(output_format)
182+
} else {
183+
Err(
184+
".xliff output requires --output-lang to select the target language"
185+
.to_string(),
186+
)
187+
}
188+
}
76189
FormatType::Xcstrings | FormatType::CSV | FormatType::TSV => {
77190
if let Some(language) = output_lang {
78191
Err(format!(
79-
"--output-lang '{}' is only supported for single-language outputs (.strings, strings.xml)",
192+
"--output-lang '{}' is only supported for .strings, strings.xml, or .xliff output",
80193
language
81194
))
82195
} else {
@@ -92,6 +205,65 @@ pub fn run_unified_convert_command(
92205
options: ConvertOptions,
93206
strict: bool,
94207
) {
208+
let wants_xliff = wants_xliff_output(&output, options.output_format.as_ref());
209+
if wants_xliff {
210+
println!(
211+
"{}",
212+
ui::status_line_stdout(
213+
ui::Tone::Info,
214+
"Converting to XLIFF 1.2 with explicit source/target language selection...",
215+
)
216+
);
217+
match read_resources_from_any_input(&input, options.input_format.as_ref(), strict).and_then(
218+
|mut resources| {
219+
let output_format = resolve_convert_output_format(
220+
&output,
221+
options.output_format.as_ref(),
222+
options.output_lang.as_ref(),
223+
)?;
224+
let target_language =
225+
match &output_format {
226+
FormatType::Xliff(Some(target_language)) => target_language.clone(),
227+
_ => return Err(
228+
".xliff output requires --output-lang to select the target language"
229+
.to_string(),
230+
),
231+
};
232+
let source_language = resolve_xliff_source_language(
233+
&resources,
234+
options.source_language.as_ref(),
235+
&target_language,
236+
)?;
237+
238+
for resource in &mut resources {
239+
resource
240+
.metadata
241+
.custom
242+
.insert("source_language".to_string(), source_language.clone());
243+
}
244+
245+
convert_resources_to_format(resources, &output, output_format)
246+
.map_err(|e| format!("Error converting to xliff: {}", e))
247+
},
248+
) {
249+
Ok(()) => {
250+
println!(
251+
"{}",
252+
ui::status_line_stdout(ui::Tone::Success, "Successfully converted to xliff",)
253+
);
254+
return;
255+
}
256+
Err(e) => {
257+
println!(
258+
"{}",
259+
ui::status_line_stdout(ui::Tone::Error, "Conversion to xliff failed")
260+
);
261+
eprintln!("Error: {}", e);
262+
std::process::exit(1);
263+
}
264+
}
265+
}
266+
95267
if let Some(output_lang) = options.output_lang.as_ref() {
96268
if output.ends_with(".langcodec") {
97269
eprintln!(
@@ -142,11 +314,7 @@ pub fn run_unified_convert_command(
142314

143315
// Special handling: when targeting xcstrings, ensure required metadata exists.
144316
// If source_language/version are missing, default to en/1.0 respectively.
145-
let wants_xcstrings = output.ends_with(".xcstrings")
146-
|| options
147-
.output_format
148-
.as_deref()
149-
.is_some_and(|s| s.eq_ignore_ascii_case("xcstrings"));
317+
let wants_xcstrings = wants_xcstrings_output(&output, options.output_format.as_ref());
150318
if wants_xcstrings {
151319
println!(
152320
"{}",
@@ -624,6 +792,7 @@ fn print_conversion_error(input: &str, output: &str) {
624792
eprintln!("- .strings (Apple strings files)");
625793
eprintln!("- .xml (Android strings files)");
626794
eprintln!("- .xcstrings (Apple xcstrings files)");
795+
eprintln!("- .xliff (Apple/Xcode XLIFF 1.2 files)");
627796
eprintln!("- .csv (CSV files)");
628797
eprintln!("- .tsv (TSV files)");
629798
eprintln!("- .langcodec (Resource JSON array)");
@@ -634,6 +803,7 @@ fn print_conversion_error(input: &str, output: &str) {
634803
eprintln!("- .strings (Apple strings files)");
635804
eprintln!("- .xml (Android strings files)");
636805
eprintln!("- .xcstrings (Apple xcstrings files)");
806+
eprintln!("- .xliff (Apple/Xcode XLIFF 1.2 files)");
637807
eprintln!("- .csv (CSV files)");
638808
eprintln!("- .tsv (TSV files)");
639809
eprintln!("- .langcodec (Resource JSON array)");
@@ -676,11 +846,12 @@ fn try_explicit_format_conversion(
676846
"strings" => langcodec::formats::FormatType::Strings(None),
677847
"android" | "androidstrings" => langcodec::formats::FormatType::AndroidStrings(None),
678848
"xcstrings" => langcodec::formats::FormatType::Xcstrings,
849+
"xliff" => langcodec::formats::FormatType::Xliff(None),
679850
"csv" => langcodec::formats::FormatType::CSV,
680851
"tsv" => langcodec::formats::FormatType::TSV,
681852
_ => {
682853
return Err(format!(
683-
"Unsupported input format: '{}'. Supported formats: strings, android, xcstrings, csv, tsv",
854+
"Unsupported input format: '{}'. Supported formats: strings, android, xcstrings, xliff, csv, tsv",
684855
input_format
685856
));
686857
}
@@ -758,6 +929,7 @@ pub fn read_resources_from_any_input(
758929
Some(langcodec::formats::FormatType::AndroidStrings(None))
759930
}
760931
"xcstrings" => Some(langcodec::formats::FormatType::Xcstrings),
932+
"xliff" => Some(langcodec::formats::FormatType::Xliff(None)),
761933
"csv" => Some(langcodec::formats::FormatType::CSV),
762934
"tsv" => Some(langcodec::formats::FormatType::TSV),
763935
_ => None,
@@ -783,6 +955,7 @@ pub fn read_resources_from_any_input(
783955
if input.ends_with(".strings")
784956
|| input.ends_with(".xml")
785957
|| input.ends_with(".xcstrings")
958+
|| input.ends_with(".xliff")
786959
|| input.ends_with(".csv")
787960
|| input.ends_with(".tsv")
788961
{
@@ -807,7 +980,7 @@ pub fn read_resources_from_any_input(
807980
}
808981

809982
return Err(format!(
810-
"Unsupported input format or file extension: '{}'. Supported formats: .strings, .xml, .xcstrings, .csv, .tsv, .json, .yaml, .yml, .langcodec",
983+
"Unsupported input format or file extension: '{}'. Supported formats: .strings, .xml, .xcstrings, .xliff, .csv, .tsv, .json, .yaml, .yml, .langcodec",
811984
input
812985
));
813986
}
@@ -821,6 +994,7 @@ pub fn read_resources_from_any_input(
821994
Some(langcodec::formats::FormatType::AndroidStrings(None))
822995
}
823996
"xcstrings" => Some(langcodec::formats::FormatType::Xcstrings),
997+
"xliff" => Some(langcodec::formats::FormatType::Xliff(None)),
824998
"csv" => Some(langcodec::formats::FormatType::CSV),
825999
"tsv" => Some(langcodec::formats::FormatType::TSV),
8261000
_ => None,
@@ -895,6 +1069,8 @@ pub fn read_resources_from_any_input(
8951069
Some(langcodec::formats::FormatType::AndroidStrings(Some(lang)))
8961070
} else if input.ends_with(".xcstrings") {
8971071
Some(langcodec::formats::FormatType::Xcstrings)
1072+
} else if input.ends_with(".xliff") {
1073+
Some(langcodec::formats::FormatType::Xliff(None))
8981074
} else if input.ends_with(".csv") {
8991075
Some(langcodec::formats::FormatType::CSV)
9001076
} else if input.ends_with(".tsv") {
@@ -908,6 +1084,7 @@ pub fn read_resources_from_any_input(
9081084
codec
9091085
.read_file_by_type(input, format_type)
9101086
.map_err(|e2| format!("{err_prefix}{e2}"))?;
1087+
return Ok(codec.resources);
9111088
}
9121089
} else {
9131090
eprintln!("Standard format detection failed: {}", e);
@@ -937,7 +1114,7 @@ pub fn read_resources_from_any_input(
9371114
}
9381115

9391116
Err(format!(
940-
"Unsupported input format or file extension: '{}'. Supported formats: .strings, .xml, .xcstrings, .csv, .tsv, .json, .yaml, .yml, .langcodec",
1117+
"Unsupported input format or file extension: '{}'. Supported formats: .strings, .xml, .xcstrings, .xliff, .csv, .tsv, .json, .yaml, .yml, .langcodec",
9411118
input
9421119
))
9431120
}

0 commit comments

Comments
 (0)