Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</p>

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

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

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

# Export an Apple/Xcode translation exchange file
langcodec convert -i Localizable.xcstrings -o Localizable.xliff --output-lang fr

# Import XLIFF back into an Xcode string catalog
langcodec convert -i Localizable.xliff -o Localizable.xcstrings

# Inspect work that still needs attention
langcodec view -i Localizable.xcstrings --status new,needs_review --keys-only

Expand Down Expand Up @@ -125,6 +131,7 @@ langcodec annotate \
| --------------------- | :---: | :---: | :-----: | :---: | :-----: | :------: |
| Apple `.strings` | yes | yes | yes | yes | no | yes |
| Apple `.xcstrings` | yes | yes | yes | yes | yes | yes |
| Apple `.xliff` | yes | yes | yes | no | no | yes |
| Android `strings.xml` | yes | yes | yes | yes | yes | yes |
| CSV | yes | yes | yes | yes | no | no |
| TSV | yes | yes | yes | yes | no | no |
Expand Down
7 changes: 7 additions & 0 deletions langcodec-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Supported formats:

- Apple `.strings`
- Apple `.xcstrings`
- Apple/Xcode `.xliff`
- Android `strings.xml`
- CSV
- TSV
Expand Down Expand Up @@ -100,8 +101,12 @@ langcodec tolgee --help
```sh
langcodec convert -i Localizable.xcstrings -o translations.csv
langcodec convert -i translations.csv -o values/strings.xml
langcodec convert -i Localizable.xcstrings -o Localizable.xliff --output-lang fr
langcodec convert -i Localizable.xliff -o Localizable.xcstrings
```

For `.xliff` output, pass `--output-lang` to choose the target language. Use `--source-language` when the source language is ambiguous.

### Find strings that still need work

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

`normalize`, `edit`, and `sync` intentionally do not operate on `.xliff` in v1; convert XLIFF into a project format first.

### Sync or merge existing translation assets

```sh
Expand Down
197 changes: 187 additions & 10 deletions langcodec-cli/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,117 @@ fn parse_standard_output_format(format: &str) -> Result<FormatType, String> {
"strings" => Ok(FormatType::Strings(None)),
"android" | "androidstrings" => Ok(FormatType::AndroidStrings(None)),
"xcstrings" => Ok(FormatType::Xcstrings),
"xliff" => Ok(FormatType::Xliff(None)),
"csv" => Ok(FormatType::CSV),
"tsv" => Ok(FormatType::TSV),
_ => Err(format!(
"Unsupported output format: '{}'. Supported formats: strings, android, xcstrings, csv, tsv",
"Unsupported output format: '{}'. Supported formats: strings, android, xcstrings, xliff, csv, tsv",
format
)),
}
}

fn wants_named_output(
output: &str,
output_format_hint: Option<&String>,
extension: &str,
format_name: &str,
) -> bool {
output.ends_with(extension)
|| output_format_hint.is_some_and(|hint| hint.eq_ignore_ascii_case(format_name))
}

fn wants_xcstrings_output(output: &str, output_format_hint: Option<&String>) -> bool {
wants_named_output(output, output_format_hint, ".xcstrings", "xcstrings")
}

fn wants_xliff_output(output: &str, output_format_hint: Option<&String>) -> bool {
wants_named_output(output, output_format_hint, ".xliff", "xliff")
}

fn resolve_xliff_source_language(
resources: &[langcodec::Resource],
explicit_source_language: Option<&String>,
target_language: &str,
) -> Result<String, String> {
if let Some(explicit_source_language) = explicit_source_language {
let trimmed = explicit_source_language.trim();
if trimmed.is_empty() {
return Err("--source-language cannot be empty for .xliff output".to_string());
}
return Ok(trimmed.to_string());
}
Comment on lines +59 to +65
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When --source-language is provided, this function returns it without checking that it actually exists in resources (i.e., among resource.metadata.language). That mismatch will only fail later inside the library with a less CLI-focused error. Consider validating the explicit source language against the available languages here and returning a clearer CLI error if it’s not present.

Copilot uses AI. Check for mistakes.

let metadata_source_languages = resources
.iter()
.filter_map(|resource| resource.metadata.custom.get("source_language"))
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.collect::<std::collections::BTreeSet<_>>();

let available_languages = resources
.iter()
.map(|resource| resource.metadata.language.trim())
.filter(|language| !language.is_empty())
.collect::<std::collections::BTreeSet<_>>();

if metadata_source_languages.len() > 1 {
return Err(format!(
"Conflicting source_language metadata found for .xliff output: {}. Pass --source-language.",
metadata_source_languages
.into_iter()
.collect::<Vec<_>>()
.join(", ")
));
}
if let Some(source_language) = metadata_source_languages.iter().next() {
let extras = available_languages
.iter()
.filter(|language| **language != *source_language && **language != target_language)
.cloned()
.collect::<Vec<_>>();

if *source_language != target_language && extras.is_empty() {
return Ok((*source_language).to_string());
}

return Err(format!(
"source_language metadata '{}' is ambiguous for .xliff output with available languages ({}). Pass --source-language.",
source_language,
available_languages
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}

if available_languages.is_empty() {
return Err("XLIFF output requires language metadata on the input resources".to_string());
}

if available_languages.len() == 1 {
return Ok(available_languages.iter().next().unwrap().to_string());
}

let non_target_languages = available_languages
.iter()
.filter(|language| **language != target_language)
.cloned()
.collect::<Vec<_>>();

match non_target_languages.as_slice() {
[source_language] => Ok((*source_language).to_string()),
_ => Err(format!(
"Could not infer the XLIFF source language from available languages ({}). Pass --source-language.",
available_languages
.into_iter()
.collect::<Vec<_>>()
.join(", ")
)),
}
}

fn infer_output_path_language(path: &str) -> Option<String> {
match langcodec::infer_format_from_path(path) {
Some(FormatType::Strings(Some(lang))) | Some(FormatType::AndroidStrings(Some(lang))) => {
Expand Down Expand Up @@ -73,10 +175,21 @@ fn resolve_convert_output_format(
}
Ok(output_format)
}
FormatType::Xliff(_) => {
if let Some(language) = output_lang {
output_format = output_format.with_language(Some(language.clone()));
Ok(output_format)
} else {
Err(
".xliff output requires --output-lang to select the target language"
.to_string(),
)
}
}
FormatType::Xcstrings | FormatType::CSV | FormatType::TSV => {
if let Some(language) = output_lang {
Err(format!(
"--output-lang '{}' is only supported for single-language outputs (.strings, strings.xml)",
"--output-lang '{}' is only supported for .strings, strings.xml, or .xliff output",
language
))
} else {
Expand All @@ -92,6 +205,65 @@ pub fn run_unified_convert_command(
options: ConvertOptions,
strict: bool,
) {
let wants_xliff = wants_xliff_output(&output, options.output_format.as_ref());
if wants_xliff {
println!(
"{}",
ui::status_line_stdout(
ui::Tone::Info,
"Converting to XLIFF 1.2 with explicit source/target language selection...",
)
);
match read_resources_from_any_input(&input, options.input_format.as_ref(), strict).and_then(
|mut resources| {
let output_format = resolve_convert_output_format(
&output,
options.output_format.as_ref(),
options.output_lang.as_ref(),
)?;
let target_language =
match &output_format {
FormatType::Xliff(Some(target_language)) => target_language.clone(),
_ => return Err(
".xliff output requires --output-lang to select the target language"
.to_string(),
),
};
let source_language = resolve_xliff_source_language(
&resources,
options.source_language.as_ref(),
&target_language,
)?;

for resource in &mut resources {
resource
.metadata
.custom
.insert("source_language".to_string(), source_language.clone());
}

convert_resources_to_format(resources, &output, output_format)
.map_err(|e| format!("Error converting to xliff: {}", e))
},
) {
Ok(()) => {
println!(
"{}",
ui::status_line_stdout(ui::Tone::Success, "Successfully converted to xliff",)
);
return;
}
Err(e) => {
println!(
"{}",
ui::status_line_stdout(ui::Tone::Error, "Conversion to xliff failed")
);
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}

if let Some(output_lang) = options.output_lang.as_ref() {
if output.ends_with(".langcodec") {
eprintln!(
Expand Down Expand Up @@ -142,11 +314,7 @@ pub fn run_unified_convert_command(

// Special handling: when targeting xcstrings, ensure required metadata exists.
// If source_language/version are missing, default to en/1.0 respectively.
let wants_xcstrings = output.ends_with(".xcstrings")
|| options
.output_format
.as_deref()
.is_some_and(|s| s.eq_ignore_ascii_case("xcstrings"));
let wants_xcstrings = wants_xcstrings_output(&output, options.output_format.as_ref());
if wants_xcstrings {
println!(
"{}",
Expand Down Expand Up @@ -624,6 +792,7 @@ fn print_conversion_error(input: &str, output: &str) {
eprintln!("- .strings (Apple strings files)");
eprintln!("- .xml (Android strings files)");
eprintln!("- .xcstrings (Apple xcstrings files)");
eprintln!("- .xliff (Apple/Xcode XLIFF 1.2 files)");
eprintln!("- .csv (CSV files)");
eprintln!("- .tsv (TSV files)");
eprintln!("- .langcodec (Resource JSON array)");
Expand All @@ -634,6 +803,7 @@ fn print_conversion_error(input: &str, output: &str) {
eprintln!("- .strings (Apple strings files)");
eprintln!("- .xml (Android strings files)");
eprintln!("- .xcstrings (Apple xcstrings files)");
eprintln!("- .xliff (Apple/Xcode XLIFF 1.2 files)");
eprintln!("- .csv (CSV files)");
eprintln!("- .tsv (TSV files)");
eprintln!("- .langcodec (Resource JSON array)");
Expand Down Expand Up @@ -676,11 +846,12 @@ fn try_explicit_format_conversion(
"strings" => langcodec::formats::FormatType::Strings(None),
"android" | "androidstrings" => langcodec::formats::FormatType::AndroidStrings(None),
"xcstrings" => langcodec::formats::FormatType::Xcstrings,
"xliff" => langcodec::formats::FormatType::Xliff(None),
"csv" => langcodec::formats::FormatType::CSV,
"tsv" => langcodec::formats::FormatType::TSV,
_ => {
return Err(format!(
"Unsupported input format: '{}'. Supported formats: strings, android, xcstrings, csv, tsv",
"Unsupported input format: '{}'. Supported formats: strings, android, xcstrings, xliff, csv, tsv",
input_format
));
}
Expand Down Expand Up @@ -758,6 +929,7 @@ pub fn read_resources_from_any_input(
Some(langcodec::formats::FormatType::AndroidStrings(None))
}
"xcstrings" => Some(langcodec::formats::FormatType::Xcstrings),
"xliff" => Some(langcodec::formats::FormatType::Xliff(None)),
"csv" => Some(langcodec::formats::FormatType::CSV),
"tsv" => Some(langcodec::formats::FormatType::TSV),
_ => None,
Expand All @@ -783,6 +955,7 @@ pub fn read_resources_from_any_input(
if input.ends_with(".strings")
|| input.ends_with(".xml")
|| input.ends_with(".xcstrings")
|| input.ends_with(".xliff")
|| input.ends_with(".csv")
|| input.ends_with(".tsv")
{
Expand All @@ -807,7 +980,7 @@ pub fn read_resources_from_any_input(
}

return Err(format!(
"Unsupported input format or file extension: '{}'. Supported formats: .strings, .xml, .xcstrings, .csv, .tsv, .json, .yaml, .yml, .langcodec",
"Unsupported input format or file extension: '{}'. Supported formats: .strings, .xml, .xcstrings, .xliff, .csv, .tsv, .json, .yaml, .yml, .langcodec",
input
));
}
Expand All @@ -821,6 +994,7 @@ pub fn read_resources_from_any_input(
Some(langcodec::formats::FormatType::AndroidStrings(None))
}
"xcstrings" => Some(langcodec::formats::FormatType::Xcstrings),
"xliff" => Some(langcodec::formats::FormatType::Xliff(None)),
"csv" => Some(langcodec::formats::FormatType::CSV),
"tsv" => Some(langcodec::formats::FormatType::TSV),
_ => None,
Expand Down Expand Up @@ -895,6 +1069,8 @@ pub fn read_resources_from_any_input(
Some(langcodec::formats::FormatType::AndroidStrings(Some(lang)))
} else if input.ends_with(".xcstrings") {
Some(langcodec::formats::FormatType::Xcstrings)
} else if input.ends_with(".xliff") {
Some(langcodec::formats::FormatType::Xliff(None))
} else if input.ends_with(".csv") {
Some(langcodec::formats::FormatType::CSV)
} else if input.ends_with(".tsv") {
Expand All @@ -908,6 +1084,7 @@ pub fn read_resources_from_any_input(
codec
.read_file_by_type(input, format_type)
.map_err(|e2| format!("{err_prefix}{e2}"))?;
return Ok(codec.resources);
}
} else {
eprintln!("Standard format detection failed: {}", e);
Expand Down Expand Up @@ -937,7 +1114,7 @@ pub fn read_resources_from_any_input(
}

Err(format!(
"Unsupported input format or file extension: '{}'. Supported formats: .strings, .xml, .xcstrings, .csv, .tsv, .json, .yaml, .yml, .langcodec",
"Unsupported input format or file extension: '{}'. Supported formats: .strings, .xml, .xcstrings, .xliff, .csv, .tsv, .json, .yaml, .yml, .langcodec",
input
))
}
Loading
Loading