Skip to content

Commit df36447

Browse files
committed
Add XLIFF 2.0 plural support using group elements
Writer changes: - Add CreatePluralUnit20 method to create groups for plural entries - Update CreateXliff20Document to use CreatePluralUnit20 for plurals - Clean up CreateUnit20 to only handle non-plural entries Reader changes: - Add ExtractPluralCategoryWithBase to extract both base name and category - Add IsPluralGroup to detect plural groups (multiple units with same base key) - Add ParsePluralGroup20 to parse XLIFF 2.0 plural groups - Update ParseXliff20 to detect and handle plural groups This enables full round-trip preservation of plural structures in XLIFF 2.0 format.
1 parent b4ccd70 commit df36447

2 files changed

Lines changed: 194 additions & 40 deletions

File tree

LocalizationManager.Core/Backends/Xliff/XliffResourceReader.cs

Lines changed: 134 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,18 @@ public ResourceFile Read(TextReader reader, LanguageInfo metadata)
5757
{
5858
var content = reader.ReadToEnd();
5959

60-
// Parse the document - use XDocument.Parse for string content
61-
// This handles encoding properly for strings (avoids UTF-8/UTF-16 BOM issues)
62-
var doc = XDocument.Parse(content, LoadOptions.PreserveWhitespace);
60+
// Use secure XML settings to prevent XXE attacks
61+
var settings = new XmlReaderSettings
62+
{
63+
DtdProcessing = DtdProcessing.Prohibit,
64+
XmlResolver = null
65+
};
66+
67+
XDocument doc;
68+
using (var xmlReader = XmlReader.Create(new StringReader(content), settings))
69+
{
70+
doc = XDocument.Load(xmlReader, LoadOptions.PreserveWhitespace);
71+
}
6372

6473
// Detect version from the parsed document (more reliable than stream-based detection)
6574
var version = DetectVersionFromDocument(doc);
@@ -86,9 +95,9 @@ private string DetectVersionFromDocument(XDocument doc)
8695

8796
// Check namespace
8897
var ns = root.GetDefaultNamespace().NamespaceName;
89-
if (ns.Contains("2.0"))
98+
if (ns == XliffVersionDetector.Xliff20Namespace)
9099
return "2.0";
91-
if (ns.Contains("1.2"))
100+
if (ns == XliffVersionDetector.Xliff12Namespace)
92101
return "1.2";
93102

94103
// Check version attribute
@@ -105,7 +114,7 @@ private string DetectVersionFromDocument(XDocument doc)
105114
if (root.Attribute("srcLang") != null)
106115
return "2.0";
107116

108-
return "1.2"; // Default to 1.2
117+
return "unknown"; // Let caller decide how to handle
109118
}
110119

111120
/// <summary>
@@ -223,7 +232,7 @@ private List<ResourceEntry> ParseXliff12(XDocument doc, LanguageInfo metadata)
223232
// Always prefer target value (which contains the localized text)
224233
var pluralValue = target ?? source;
225234

226-
if (!string.IsNullOrEmpty(pluralId) && pluralValue != null)
235+
if (!string.IsNullOrEmpty(pluralId) && !string.IsNullOrEmpty(pluralValue))
227236
{
228237
// Extract plural category from id (e.g., "key[one]", "key[other]")
229238
var category = ExtractPluralCategory(pluralId);
@@ -244,7 +253,10 @@ private List<ResourceEntry> ParseXliff12(XDocument doc, LanguageInfo metadata)
244253
return new ResourceEntry
245254
{
246255
Key = id,
247-
Value = pluralForms.GetValueOrDefault("other") ?? pluralForms.Values.FirstOrDefault(),
256+
Value = pluralForms.GetValueOrDefault("other")
257+
?? pluralForms.GetValueOrDefault("one")
258+
?? pluralForms.Values.FirstOrDefault()
259+
?? "",
248260
Comment = string.IsNullOrEmpty(comment) ? null : comment,
249261
IsPlural = true,
250262
PluralForms = pluralForms
@@ -282,12 +294,24 @@ private List<ResourceEntry> ParseXliff20(XDocument doc, LanguageInfo metadata)
282294
var groups = fileElement.Elements(ns + "group");
283295
foreach (var group in groups)
284296
{
285-
var nestedUnits = group.Descendants(ns + "unit");
286-
foreach (var unit in nestedUnits)
297+
// Check if this is a plural group (has multiple units with same base key)
298+
var groupUnits = group.Elements(ns + "unit").ToList();
299+
if (groupUnits.Count > 1 && IsPluralGroup(groupUnits))
287300
{
288-
var entry = ParseUnit20(unit, ns, metadata);
289-
if (entry != null)
290-
entries.Add(entry);
301+
var pluralEntry = ParsePluralGroup20(group, ns, metadata);
302+
if (pluralEntry != null)
303+
entries.Add(pluralEntry);
304+
}
305+
else
306+
{
307+
// Regular group - parse nested units
308+
var nestedUnits = group.Descendants(ns + "unit");
309+
foreach (var unit in nestedUnits)
310+
{
311+
var entry = ParseUnit20(unit, ns, metadata);
312+
if (entry != null)
313+
entries.Add(entry);
314+
}
291315
}
292316
}
293317
}
@@ -356,26 +380,117 @@ private List<ResourceEntry> ParseXliff20(XDocument doc, LanguageInfo metadata)
356380
/// Extracts the plural category from an ID like "key[one]" or "key_plural_one".
357381
/// </summary>
358382
private static string? ExtractPluralCategory(string id)
383+
{
384+
return ExtractPluralCategoryWithBase(id).category;
385+
}
386+
387+
/// <summary>
388+
/// Extracts both the base name and plural category from an ID like "key[one]" or "key_plural_one".
389+
/// Returns (baseName, category) or (id, null) if not a plural suffix key.
390+
/// </summary>
391+
private static (string baseName, string? category) ExtractPluralCategoryWithBase(string id)
359392
{
360393
// Pattern 1: key[category]
361394
var bracketStart = id.LastIndexOf('[');
362395
var bracketEnd = id.LastIndexOf(']');
363-
if (bracketStart >= 0 && bracketEnd > bracketStart)
396+
// Validate: brackets must be in correct order and have content between them
397+
if (bracketStart >= 0 && bracketEnd > bracketStart + 1)
364398
{
365-
return id.Substring(bracketStart + 1, bracketEnd - bracketStart - 1);
399+
var baseName = id.Substring(0, bracketStart);
400+
var category = id.Substring(bracketStart + 1, bracketEnd - bracketStart - 1);
401+
return (baseName, category);
366402
}
367403

368404
// Pattern 2: key_plural_category
369405
var categories = new[] { "zero", "one", "two", "few", "many", "other" };
370406
foreach (var cat in categories)
371407
{
372-
if (id.EndsWith($"_{cat}", StringComparison.OrdinalIgnoreCase) ||
373-
id.EndsWith($"_plural_{cat}", StringComparison.OrdinalIgnoreCase))
408+
if (id.EndsWith($"_{cat}", StringComparison.OrdinalIgnoreCase))
409+
{
410+
return (id.Substring(0, id.Length - cat.Length - 1), cat);
411+
}
412+
if (id.EndsWith($"_plural_{cat}", StringComparison.OrdinalIgnoreCase))
374413
{
375-
return cat;
414+
return (id.Substring(0, id.Length - cat.Length - 8), cat);
376415
}
377416
}
378417

379-
return null;
418+
return (id, null);
419+
}
420+
421+
/// <summary>
422+
/// Checks if a group's units represent a plural entry.
423+
/// Returns true if all units share the same base key and there are multiple units.
424+
/// </summary>
425+
private bool IsPluralGroup(List<XElement> units)
426+
{
427+
if (units.Count <= 1)
428+
return false;
429+
430+
// Extract base keys (remove [category] suffix)
431+
var baseKeys = units
432+
.Select(u => u.Attribute("id")?.Value)
433+
.Where(id => !string.IsNullOrEmpty(id))
434+
.Select(id => ExtractPluralCategoryWithBase(id!).baseName)
435+
.Distinct()
436+
.ToList();
437+
438+
// If all units share the same base key and there are multiple units, it's a plural group
439+
return baseKeys.Count == 1 && units.Count > 1;
440+
}
441+
442+
/// <summary>
443+
/// Parses a plural group in XLIFF 2.0.
444+
/// </summary>
445+
private ResourceEntry? ParsePluralGroup20(XElement group, XNamespace ns, LanguageInfo metadata)
446+
{
447+
var id = group.Attribute("id")?.Value ?? group.Attribute("name")?.Value;
448+
if (string.IsNullOrEmpty(id))
449+
return null;
450+
451+
var pluralForms = new Dictionary<string, string>();
452+
var units = group.Elements(ns + "unit").ToList();
453+
454+
foreach (var unit in units)
455+
{
456+
var unitId = unit.Attribute("id")?.Value;
457+
if (string.IsNullOrEmpty(unitId))
458+
continue;
459+
460+
// Extract category from ID like "key[one]" or "key[other]"
461+
var (baseName, category) = ExtractPluralCategoryWithBase(unitId);
462+
if (string.IsNullOrEmpty(category))
463+
continue;
464+
465+
// Get target value from segment
466+
var segment = unit.Element(ns + "segment");
467+
var target = segment?.Element(ns + "target")?.Value;
468+
var source = segment?.Element(ns + "source")?.Value;
469+
470+
pluralForms[category] = target ?? source ?? "";
471+
}
472+
473+
if (pluralForms.Count == 0)
474+
return null;
475+
476+
// Get notes from group
477+
var notesElement = group.Element(ns + "notes");
478+
var notes = notesElement?.Elements(ns + "note")
479+
.Select(n => n.Value)
480+
.Where(n => !string.IsNullOrEmpty(n))
481+
?? Enumerable.Empty<string>();
482+
var comment = string.Join("\n", notes);
483+
484+
return new ResourceEntry
485+
{
486+
Key = id,
487+
Value = pluralForms.GetValueOrDefault("other")
488+
?? pluralForms.GetValueOrDefault("one")
489+
?? pluralForms.Values.FirstOrDefault()
490+
?? "",
491+
Comment = string.IsNullOrEmpty(comment) ? null : comment,
492+
IsPlural = true,
493+
PluralForms = pluralForms
494+
};
380495
}
381496
}

LocalizationManager.Core/Backends/Xliff/XliffResourceWriter.cs

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,19 @@ public async Task WriteAsync(ResourceFile file, CancellationToken ct = default)
4040
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
4141
Directory.CreateDirectory(directory);
4242

43-
// Atomic write
44-
var tempPath = file.Language.FilePath + ".tmp";
45-
await File.WriteAllTextAsync(tempPath, content, Encoding.UTF8, ct);
46-
File.Move(tempPath, file.Language.FilePath, overwrite: true);
43+
// Atomic write with unique temp file name to prevent race conditions
44+
var tempPath = file.Language.FilePath + $".tmp.{Guid.NewGuid()}";
45+
try
46+
{
47+
// Use UTF-8 without BOM for consistency with SerializeToString
48+
await File.WriteAllTextAsync(tempPath, content, new UTF8Encoding(false), ct);
49+
File.Move(tempPath, file.Language.FilePath, overwrite: true);
50+
}
51+
finally
52+
{
53+
if (File.Exists(tempPath))
54+
File.Delete(tempPath);
55+
}
4756
}
4857

4958
/// <inheritdoc />
@@ -233,7 +242,7 @@ private XElement CreatePluralGroup12(ResourceEntry entry)
233242
{
234243
var unit = new XElement(Ns12 + "trans-unit",
235244
new XAttribute("id", $"{entry.Key}[{category}]"),
236-
new XElement(Ns12 + "source", entry.Key),
245+
new XElement(Ns12 + "source", entry.SourcePluralText ?? entry.Key),
237246
new XElement(Ns12 + "target", value ?? ""));
238247
group.Add(unit);
239248
}
@@ -261,7 +270,14 @@ private XDocument CreateXliff20Document(ResourceFile file)
261270

262271
foreach (var entry in file.Entries)
263272
{
264-
fileElement.Add(CreateUnit20(entry, file.Language.IsDefault));
273+
if (entry.IsPlural && entry.PluralForms != null && entry.PluralForms.Count > 0)
274+
{
275+
fileElement.Add(CreatePluralUnit20(entry, file.Language.IsDefault));
276+
}
277+
else
278+
{
279+
fileElement.Add(CreateUnit20(entry, file.Language.IsDefault));
280+
}
265281
}
266282

267283
root.Add(fileElement);
@@ -272,42 +288,65 @@ private XDocument CreateXliff20Document(ResourceFile file)
272288
}
273289

274290
/// <summary>
275-
/// Creates a unit element for XLIFF 2.0.
276-
/// Always creates both source and target elements to preserve bilingual structure.
291+
/// Creates a group element for plural entries in XLIFF 2.0.
292+
/// Uses groups to preserve plural structure similar to XLIFF 1.2.
277293
/// </summary>
278-
private XElement CreateUnit20(ResourceEntry entry, bool isDefault)
294+
private XElement CreatePluralUnit20(ResourceEntry entry, bool isDefault)
279295
{
280-
var unit = new XElement(Ns20 + "unit",
281-
new XAttribute("id", entry.Key));
296+
var group = new XElement(Ns20 + "group",
297+
new XAttribute("id", entry.Key),
298+
new XAttribute("name", entry.Key));
282299

283300
if (!string.IsNullOrEmpty(entry.Comment))
284301
{
285302
var notes = new XElement(Ns20 + "notes",
286303
new XElement(Ns20 + "note", entry.Comment));
287-
unit.Add(notes);
304+
group.Add(notes);
288305
}
289306

290-
if (entry.IsPlural && entry.PluralForms != null)
307+
// Create a unit for each plural form
308+
if (entry.PluralForms != null)
291309
{
292-
// Create multiple segments for plurals
293310
foreach (var (category, value) in entry.PluralForms)
294311
{
312+
var unit = new XElement(Ns20 + "unit",
313+
new XAttribute("id", $"{entry.Key}[{category}]"));
314+
295315
var segment = new XElement(Ns20 + "segment",
296-
new XAttribute("id", category),
297316
new XElement(Ns20 + "source", entry.Key),
298317
new XElement(Ns20 + "target", value ?? ""));
318+
299319
unit.Add(segment);
320+
group.Add(unit);
300321
}
301322
}
302-
else
323+
324+
return group;
325+
}
326+
327+
/// <summary>
328+
/// Creates a unit element for XLIFF 2.0 (non-plural entries only).
329+
/// Always creates both source and target elements to preserve bilingual structure.
330+
/// For plural entries, use CreatePluralUnit20 instead.
331+
/// </summary>
332+
private XElement CreateUnit20(ResourceEntry entry, bool isDefault)
333+
{
334+
var unit = new XElement(Ns20 + "unit",
335+
new XAttribute("id", entry.Key));
336+
337+
if (!string.IsNullOrEmpty(entry.Comment))
303338
{
304-
// Always write both source (key) and target (value) for proper bilingual structure
305-
var segment = new XElement(Ns20 + "segment",
306-
new XElement(Ns20 + "source", entry.Key),
307-
new XElement(Ns20 + "target", entry.Value ?? ""));
308-
unit.Add(segment);
339+
var notes = new XElement(Ns20 + "notes",
340+
new XElement(Ns20 + "note", entry.Comment));
341+
unit.Add(notes);
309342
}
310343

344+
// Always write both source (key) and target (value) for proper bilingual structure
345+
var segment = new XElement(Ns20 + "segment",
346+
new XElement(Ns20 + "source", entry.Key),
347+
new XElement(Ns20 + "target", entry.Value ?? ""));
348+
unit.Add(segment);
349+
311350
return unit;
312351
}
313352

0 commit comments

Comments
 (0)