ferroui_protranslate_formats/
lib.rs1mod json;
9mod model;
10mod text;
11mod xcstrings;
12mod xml;
13pub use model::*;
14
15pub struct TranslationCatalogConverter;
17
18impl TranslationCatalogConverter {
19 pub fn detect_format(path: &str) -> TranslationFileFormat {
20 detect_format(path)
21 }
22 pub fn import(
23 content: &str,
24 format: TranslationFileFormat,
25 options: &TranslationFormatOptions,
26 path: Option<&str>,
27 ) -> TranslationFormatResult {
28 import(content, format, options, path)
29 }
30 pub fn export(
31 catalog: &TranslationCatalog,
32 format: TranslationFileFormat,
33 options: &TranslationFormatOptions,
34 ) -> TranslationExportResult {
35 export(catalog, format, options)
36 }
37}
38
39pub fn detect_format(path: &str) -> TranslationFileFormat {
41 use TranslationFileFormat::*;
42 let path = path.to_ascii_lowercase();
43 for (suffix, format) in [
44 (".xcstrings", AppleXcstrings),
45 (".stringsdict", AppleStringsdict),
46 (".strings", AppleStrings),
47 (".arb", FlutterArb),
48 (".resx", Resx),
49 (".po", GettextPo),
50 (".pot", GettextPo),
51 (".xlf", Xliff12),
52 (".xliff", Xliff12),
53 ("strings.xml", AndroidResources),
54 (".tsv", Tsv),
55 (".csv", Csv),
56 (".i18next.json", I18NextJson),
57 (".i18n.json", I18NextJson),
58 ] {
59 if path.ends_with(suffix) {
60 return format;
61 }
62 }
63 ProTranslateJson
64}
65
66pub fn import(
67 content: &str,
68 format: TranslationFileFormat,
69 options: &TranslationFormatOptions,
70 path: Option<&str>,
71) -> TranslationFormatResult {
72 let mut result = TranslationFormatResult {
73 catalog: TranslationCatalog {
74 name: options.name.clone().or_else(|| {
75 path.and_then(|p| std::path::Path::new(p).file_stem())
76 .map(|p| p.to_string_lossy().into_owned())
77 }),
78 source_culture: options.source_culture.clone(),
79 entries: Vec::new(),
80 metadata: Default::default(),
81 },
82 diagnostics: Vec::new(),
83 };
84 use TranslationFileFormat::*;
85 let content = content.strip_prefix('\u{feff}').unwrap_or(content);
86 let parsed = match format {
87 ProTranslateJson | I18NextJson | FlutterArb | AppleXcstrings => {
88 json::import(content, format, options, &mut result)
89 }
90 Resx | AndroidResources | AppleStringsdict | Xliff12 | Xliff20 => {
91 xml::import(content, format, options, path, &mut result)
92 }
93 AppleStrings | GettextPo | Csv | Tsv => {
94 text::import(content, format, options, path, &mut result)
95 }
96 };
97 if let Err(message) = parsed {
98 result.catalog.entries.clear();
99 result.diagnostics.push(TranslationFormatDiagnostic::new(
100 Severity::Error,
101 "PTF001",
102 message,
103 ));
104 }
105 result.diagnostics.extend(result.catalog.validate());
106 result
107}
108
109pub fn export(
110 catalog: &TranslationCatalog,
111 format: TranslationFileFormat,
112 options: &TranslationFormatOptions,
113) -> TranslationExportResult {
114 use TranslationFileFormat::*;
115 let mut result = TranslationExportResult {
116 content: String::new(),
117 diagnostics: catalog.validate(),
118 };
119 if result.has_errors() {
120 return result;
121 }
122 diagnose_losses(catalog, format, options, &mut result.diagnostics);
123 let output = match format {
124 ProTranslateJson | I18NextJson | FlutterArb | AppleXcstrings => {
125 json::export(catalog, format, options)
126 }
127 Resx | AndroidResources | AppleStringsdict | Xliff12 | Xliff20 => {
128 xml::export(catalog, format, options)
129 }
130 AppleStrings | GettextPo | Csv | Tsv => text::export(catalog, format, options),
131 };
132 match output {
133 Ok(content) => result.content = content,
134 Err(message) => result.diagnostics.push(TranslationFormatDiagnostic::new(
135 Severity::Error,
136 "PTF201",
137 message,
138 )),
139 }
140 result
141}
142
143fn diagnose_losses(
144 catalog: &TranslationCatalog,
145 format: TranslationFileFormat,
146 options: &TranslationFormatOptions,
147 diagnostics: &mut Vec<TranslationFormatDiagnostic>,
148) {
149 use TranslationFileFormat::*;
150 let multi = matches!(format, AppleXcstrings | Csv | Tsv | Xliff12);
151 if !multi && options.culture.is_none() && catalog.cultures().len() > 1 {
152 diagnostics.push(TranslationFormatDiagnostic::new(Severity::Warning,"PTF200","The target has one language per file; pass an explicit culture to avoid ambiguous selection."));
153 }
154 let lost_document_metadata: Vec<_> = catalog
155 .metadata
156 .keys()
157 .filter(|key| match format {
158 Csv | Tsv => false,
159 FlutterArb => !matches!(key.as_str(), "arb.globals" | "arb.orphan_metadata"),
160 GettextPo => !matches!(key.as_str(), "po.header_entry" | "po.obsolete"),
161 AppleXcstrings => !matches!(key.as_str(), "xcstrings.root" | "xcstrings.untranslated"),
162 _ => true,
163 })
164 .map(String::as_str)
165 .collect();
166 if !lost_document_metadata.is_empty() {
167 diagnostics.push(TranslationFormatDiagnostic::new(
168 Severity::Warning,
169 "PTF204",
170 format!(
171 "Target cannot preserve document metadata: {}.",
172 lost_document_metadata.join(", ")
173 ),
174 ));
175 }
176 let selected = options.culture.clone().or_else(|| {
177 (!multi)
178 .then(|| catalog.cultures().into_iter().next())
179 .flatten()
180 });
181 let entries: Vec<_> = if multi {
182 catalog
183 .entries
184 .iter()
185 .filter(|entry| {
186 selected.as_deref().is_none_or(|culture| {
187 entry
188 .culture
189 .as_deref()
190 .is_none_or(|tag| tag.eq_ignore_ascii_case(culture))
191 })
192 })
193 .collect()
194 } else {
195 model::select_entries(catalog, selected.as_deref())
196 };
197 for entry in entries {
198 let mut losses = Vec::new();
199 if entry.comment.is_some()
200 && (!options.include_comments
201 || matches!(
202 format,
203 ProTranslateJson | I18NextJson | AndroidResources | AppleStringsdict
204 ))
205 {
206 losses.push("comment");
207 }
208 if entry.source.is_some() && !matches!(format, GettextPo | Xliff12 | Xliff20 | Csv | Tsv) {
209 losses.push("source");
210 }
211 if entry.context.is_some() && !matches!(format, GettextPo | Csv | Tsv | Xliff12 | Xliff20) {
212 losses.push("context");
213 }
214 if entry.state.is_some()
215 && (!matches!(
216 format,
217 GettextPo | AppleXcstrings | Csv | Tsv | Xliff12 | Xliff20
218 ) || (format == GettextPo && entry.state.as_deref() != Some("fuzzy")))
219 {
220 losses.push("state");
221 }
222 if entry.plural_category.is_some()
223 && matches!(
224 format,
225 ProTranslateJson
226 | I18NextJson
227 | FlutterArb
228 | Resx
229 | AppleStrings
230 | Xliff12
231 | Xliff20
232 )
233 {
234 losses.push("plural category (value retained under its flattened key)");
235 }
236 if entry.format.is_some() && !matches!(format, Csv | Tsv) {
237 losses.push("format");
238 }
239 let unknown_metadata = entry.metadata.keys().any(|key| match format {
240 Csv | Tsv => false,
241 FlutterArb => key != "arb",
242 AppleXcstrings => !matches!(
243 key.as_str(),
244 "xcstrings.path" | "xcstrings.localization" | "xcstrings.record" | "xcstrings.unit"
245 ),
246 Xliff12 | Xliff20 => {
247 let compatible = entry
248 .metadata
249 .get("xliff.version")
250 .is_some_and(|version| version == if format == Xliff12 { "1" } else { "2" });
251 !compatible
252 || !matches!(
253 key.as_str(),
254 "xliff.version"
255 | "xliff.unit"
256 | "xliff.unit_id"
257 | "xliff.unit_attributes"
258 | "xliff.segment"
259 | "xliff.segment_id"
260 | "xliff.segment_order"
261 | "xliff.segment_attributes"
262 | "xliff.source_xml"
263 | "xliff.target_xml"
264 | "xliff.notes"
265 | "xliff.extras"
266 | "xliff.extra_positions"
267 | "xliff.untranslated"
268 )
269 }
270 GettextPo => !matches!(
271 key.as_str(),
272 "po.index"
273 | "po.msgid_plural"
274 | "po.plural_forms"
275 | "po.flags"
276 | "po.flags_prefix"
277 | "po.previous"
278 | "po.references"
279 | "po.translator_comment"
280 | "po.untranslated"
281 ),
282 AppleStringsdict => !matches!(
283 key.as_str(),
284 "apple.key"
285 | "apple.variable"
286 | "apple.localized_format"
287 | "apple.value_type"
288 | "apple.rule"
289 | "apple.variant"
290 ),
291 AndroidResources => !matches!(
292 key.as_str(),
293 "android.array" | "android.index" | "android.translatable" | "android.formatted"
294 ),
295 _ => true,
296 });
297 if unknown_metadata {
298 losses.push("metadata");
299 }
300 if format == AppleXcstrings && entry.culture.is_none() {
301 diagnostics.push(TranslationFormatDiagnostic::entry(
302 Severity::Warning,
303 "PTF203",
304 "A .xcstrings localization requires a culture; this neutral record is omitted.",
305 entry,
306 ));
307 }
308 if matches!(format, Csv | Tsv)
309 && [
310 entry.key.as_str(),
311 entry.value.as_str(),
312 entry.comment.as_deref().unwrap_or(""),
313 ]
314 .iter()
315 .any(|value| value.starts_with(['=', '+', '-', '@', '\t', '\r']))
316 {
317 diagnostics.push(TranslationFormatDiagnostic::entry(Severity::Warning,"PTF192","Formula-looking spreadsheet cell retained as text; review spreadsheet import settings.",entry));
318 }
319 if !losses.is_empty() {
320 diagnostics.push(TranslationFormatDiagnostic::entry(
321 Severity::Warning,
322 "PTF202",
323 format!("Target cannot preserve: {}.", losses.join(", ")),
324 entry,
325 ));
326 }
327 }
328}
329
330pub(crate) fn infer_culture(path: Option<&str>, suffix: &str) -> Option<String> {
331 let file = path?.rsplit(['/', '\\']).next()?;
332 let split = file.len().checked_sub(suffix.len())?;
333 if !file.get(split..)?.eq_ignore_ascii_case(suffix) {
334 return None;
335 }
336 let stem = &file[..split];
337 let candidate = stem.rsplit('.').next()?;
338 if candidate == stem
339 && !candidate.contains('-')
340 && !candidate.contains('_')
341 && candidate.len() > 3
342 {
343 return None;
344 }
345 if candidate.split(['-', '_']).next()?.len() > 3 {
346 return None;
347 }
348 Some(TranslationCatalog::normalize_culture_name(Some(candidate)))
349}
350
351pub(crate) fn infer_apple_culture(path: Option<&str>) -> Option<String> {
352 let path = path?;
353 path.split(['/', '\\'])
354 .find_map(|part| {
355 let split = part.len().checked_sub(6)?;
356 part.get(split..)?
357 .eq_ignore_ascii_case(".lproj")
358 .then(|| &part[..split])
359 })
360 .map(|tag| TranslationCatalog::normalize_culture_name(Some(tag)))
361 .or_else(|| infer_culture(Some(path), ".strings"))
362 .or_else(|| infer_culture(Some(path), ".stringsdict"))
363}
364
365#[cfg(test)]
366mod tests;