Skip to content

Commit f80df5a

Browse files
committed
Improve PO file format preservation
- Preserve fuzzy flags (#, fuzzy, c-format, etc.) during read-write cycles - Preserve obsolete entries (#~ prefixed entries) instead of skipping them - Fix UTF-8 BOM issue: write PO files without BOM (per gettext standard) - Fix extra newlines in header: trim trailing newlines when building header - Correctly prefix all obsolete entry lines with #~ including flags - Add Flags and IsObsolete properties to ResourceEntry model - Update tests for new preservation behavior
1 parent 798c8aa commit f80df5a

File tree

9 files changed

+523
-19
lines changed

9 files changed

+523
-19
lines changed

Commands/TranslateCommand.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,7 @@ private async Task TranslateKeysAsync(
547547
}
548548

549549
translated++;
550+
targetFile.IsModified = true; // Mark file as modified
550551
}
551552
catch (TranslationException ex)
552553
{
@@ -558,16 +559,16 @@ private async Task TranslateKeysAsync(
558559
}
559560
}
560561

561-
// Create backup before saving
562-
if (!settings.DryRun && !settings.NoBackup)
562+
// Create backup before saving (only if file was modified)
563+
if (!settings.DryRun && !settings.NoBackup && targetFile.IsModified)
563564
{
564565
var backupManager = new BackupVersionManager(10);
565566
var basePath = System.IO.Path.GetDirectoryName(targetLanguageInfo.FilePath) ?? Environment.CurrentDirectory;
566567
await backupManager.CreateBackupAsync(targetLanguageInfo.FilePath, "translate", basePath);
567568
}
568569

569-
// Save the updated resource file
570-
if (!settings.DryRun)
570+
// Save the updated resource file (only if modified)
571+
if (!settings.DryRun && targetFile.IsModified)
571572
{
572573
settings.WriteResourceFile(targetFile);
573574
}

Commands/UpdateCommand.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ public override int Execute(CommandContext context, Settings settings, Cancellat
364364
}
365365
// Update Value to match 'other' form
366366
entry.Value = entry.PluralForms.GetValueOrDefault("other") ?? entry.PluralForms.Values.FirstOrDefault();
367+
rf.IsModified = true; // Mark file as modified
367368
updatedCount++;
368369
}
369370
}
@@ -380,6 +381,7 @@ public override int Execute(CommandContext context, Settings settings, Cancellat
380381
if (entry != null)
381382
{
382383
entry.Value = kvp.Value;
384+
rf.IsModified = true; // Mark file as modified
383385
updatedCount++;
384386
}
385387
}
@@ -395,12 +397,13 @@ public override int Execute(CommandContext context, Settings settings, Cancellat
395397
if (entry != null)
396398
{
397399
entry.Comment = settings.Comment;
400+
rf.IsModified = true; // Mark file as modified
398401
}
399402
}
400403
}
401404

402-
// Save changes
403-
foreach (var rf in resourceFiles)
405+
// Save changes (only modified files)
406+
foreach (var rf in resourceFiles.Where(f => f.IsModified))
404407
{
405408
settings.WriteResourceFile(rf);
406409
}

LocalizationManager.Core/Backends/Po/PoEntry.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ internal class PoEntry
3535
/// </summary>
3636
public Dictionary<int, string>? MsgStrPlural { get; set; }
3737

38+
/// <summary>
39+
/// Original raw msgstr lines as they appeared in the PO file.
40+
/// Used to preserve formatting when writing back unchanged translations.
41+
/// </summary>
42+
public List<string>? OriginalMsgStrLines { get; set; }
43+
3844
/// <summary>
3945
/// Translator comment (# comment).
4046
/// </summary>

LocalizationManager.Core/Backends/Po/PoResourceReader.cs

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,20 +68,19 @@ private async Task<ResourceFile> ReadAsyncCore(TextReader reader, LanguageInfo m
6868
{
6969
var entries = new List<ResourceEntry>();
7070
PoHeader? header = null;
71+
string? originalHeader = null;
7172
var poEntries = await ParseEntriesAsync(reader, ct);
7273

7374
foreach (var poEntry in poEntries)
7475
{
7576
ct.ThrowIfCancellationRequested();
7677

77-
// Skip obsolete entries
78-
if (poEntry.IsObsolete)
79-
continue;
80-
8178
// Parse header entry
8279
if (poEntry.IsHeader)
8380
{
8481
header = PoHeader.Parse(poEntry.MsgStr ?? "");
82+
// Capture original header for preservation
83+
originalHeader = BuildOriginalHeader(poEntry);
8584
continue;
8685
}
8786

@@ -93,7 +92,8 @@ private async Task<ResourceFile> ReadAsyncCore(TextReader reader, LanguageInfo m
9392
return new ResourceFile
9493
{
9594
Language = metadata,
96-
Entries = entries
95+
Entries = entries,
96+
OriginalHeader = originalHeader
9797
};
9898
}
9999

@@ -174,6 +174,9 @@ private async Task<List<PoEntry>> ParseEntriesAsync(TextReader reader, Cancellat
174174
{
175175
currentEntry.MsgStr = UnescapeString(match.Groups[1].Value);
176176
currentField = PoField.MsgStr;
177+
178+
// Capture original formatting
179+
currentEntry.OriginalMsgStrLines = new List<string> { line.Trim() };
177180
continue;
178181
}
179182

@@ -185,6 +188,10 @@ private async Task<List<PoEntry>> ParseEntriesAsync(TextReader reader, Cancellat
185188
currentEntry.MsgStrPlural ??= new Dictionary<int, string>();
186189
currentEntry.MsgStrPlural[index] = UnescapeString(match.Groups[2].Value);
187190
currentField = PoField.MsgStrPlural;
191+
192+
// Capture original formatting (for plural forms, append to existing list)
193+
currentEntry.OriginalMsgStrLines ??= new List<string>();
194+
currentEntry.OriginalMsgStrLines.Add(line.Trim());
188195
continue;
189196
}
190197

@@ -194,6 +201,12 @@ private async Task<List<PoEntry>> ParseEntriesAsync(TextReader reader, Cancellat
194201
{
195202
var value = UnescapeString(match.Groups[1].Value);
196203
AppendToCurrent(currentEntry, currentField, value);
204+
205+
// Capture original formatting for continuation lines
206+
if (currentField is PoField.MsgStr or PoField.MsgStrPlural)
207+
{
208+
currentEntry.OriginalMsgStrLines?.Add(line.Trim());
209+
}
197210
continue;
198211
}
199212
}
@@ -306,18 +319,76 @@ private ResourceEntry ConvertToResourceEntry(PoEntry poEntry, string languageCod
306319
IsPlural = true,
307320
PluralForms = pluralForms,
308321
// Store msgid_plural for translation (source plural text)
309-
SourcePluralText = poEntry.MsgIdPlural
322+
SourcePluralText = poEntry.MsgIdPlural,
323+
References = poEntry.References, // Transfer references
324+
// Preserve original formatting for plural forms
325+
OriginalFormatting = poEntry.OriginalMsgStrLines != null && poEntry.OriginalMsgStrLines.Any()
326+
? string.Join("\n", poEntry.OriginalMsgStrLines)
327+
: null,
328+
Flags = poEntry.Flags, // Transfer flags (fuzzy, etc.)
329+
IsObsolete = poEntry.IsObsolete // Transfer obsolete status
310330
};
311331
}
312332

313333
return new ResourceEntry
314334
{
315335
Key = key,
316336
Value = poEntry.MsgStr,
317-
Comment = poEntry.GetCombinedComment()
337+
Comment = poEntry.GetCombinedComment(),
338+
References = poEntry.References, // Transfer references
339+
// Preserve original formatting
340+
OriginalFormatting = poEntry.OriginalMsgStrLines != null && poEntry.OriginalMsgStrLines.Any()
341+
? string.Join("\n", poEntry.OriginalMsgStrLines)
342+
: null,
343+
Flags = poEntry.Flags, // Transfer flags (fuzzy, etc.)
344+
IsObsolete = poEntry.IsObsolete // Transfer obsolete status
318345
};
319346
}
320347

348+
/// <summary>
349+
/// Builds the original header text from a header PoEntry for preservation.
350+
/// This maintains the exact original header format including comments and metadata.
351+
/// </summary>
352+
private string BuildOriginalHeader(PoEntry headerEntry)
353+
{
354+
var sb = new StringBuilder();
355+
356+
// Add translator comments
357+
if (!string.IsNullOrEmpty(headerEntry.TranslatorComment))
358+
{
359+
foreach (var line in headerEntry.TranslatorComment.Split('\n'))
360+
{
361+
sb.AppendLine(string.IsNullOrEmpty(line) ? "#" : $"# {line}");
362+
}
363+
}
364+
365+
// Add extracted comments
366+
if (!string.IsNullOrEmpty(headerEntry.ExtractedComment))
367+
{
368+
foreach (var line in headerEntry.ExtractedComment.Split('\n'))
369+
{
370+
sb.AppendLine($"#. {line}");
371+
}
372+
}
373+
374+
// Add the header msgid and msgstr
375+
sb.AppendLine("msgid \"\"");
376+
sb.AppendLine("msgstr \"\"");
377+
378+
// Add header fields (these are already escaped in MsgStr)
379+
if (!string.IsNullOrEmpty(headerEntry.MsgStr))
380+
{
381+
// Trim trailing newlines to avoid adding empty "\n" lines
382+
var headerContent = headerEntry.MsgStr.TrimEnd('\n', '\r');
383+
foreach (var line in headerContent.Split('\n'))
384+
{
385+
sb.AppendLine($"\"{line}\\n\"");
386+
}
387+
}
388+
389+
return sb.ToString();
390+
}
391+
321392
private static bool HasContent(PoEntry entry)
322393
{
323394
return !string.IsNullOrEmpty(entry.MsgId) ||

LocalizationManager.Core/Backends/Po/PoResourceWriter.cs

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ public async Task WriteAsync(ResourceFile file, CancellationToken ct = default)
3838

3939
// Atomic write: write to temp file then rename
4040
var tempPath = file.Language.FilePath + ".tmp";
41-
await File.WriteAllTextAsync(tempPath, content, Encoding.UTF8, ct);
41+
// Use UTF-8 without BOM (PO file standard)
42+
await File.WriteAllTextAsync(tempPath, content, new UTF8Encoding(false), ct);
4243
File.Move(tempPath, file.Language.FilePath, overwrite: true);
4344
}
4445

@@ -122,6 +123,15 @@ public string SerializeToString(ResourceFile file)
122123

123124
private void WriteHeader(StringBuilder sb, ResourceFile file)
124125
{
126+
// Use preserved original header if available
127+
if (!string.IsNullOrEmpty(file.OriginalHeader))
128+
{
129+
sb.Append(file.OriginalHeader);
130+
sb.AppendLine();
131+
return;
132+
}
133+
134+
// Fall back to generating new header (for new files)
125135
// PO header is an entry with empty msgid
126136
sb.AppendLine("# PO file generated by LRM");
127137
sb.AppendLine("#");
@@ -152,6 +162,28 @@ private void WriteHeader(StringBuilder sb, ResourceFile file)
152162
}
153163

154164
private void WriteEntry(StringBuilder sb, ResourceEntry entry, string languageCode)
165+
{
166+
// If obsolete, write to a temporary buffer and prefix all lines with #~
167+
if (entry.IsObsolete)
168+
{
169+
var tempSb = new StringBuilder();
170+
WriteEntryContent(tempSb, entry, languageCode);
171+
172+
// Prefix all lines with #~
173+
var lines = tempSb.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries);
174+
foreach (var line in lines)
175+
{
176+
sb.AppendLine($"#~ {line}");
177+
}
178+
sb.AppendLine();
179+
return;
180+
}
181+
182+
WriteEntryContent(sb, entry, languageCode);
183+
sb.AppendLine();
184+
}
185+
186+
private void WriteEntryContent(StringBuilder sb, ResourceEntry entry, string languageCode)
155187
{
156188
// Write comment if present
157189
if (!string.IsNullOrEmpty(entry.Comment))
@@ -162,6 +194,21 @@ private void WriteEntry(StringBuilder sb, ResourceEntry entry, string languageCo
162194
}
163195
}
164196

197+
// Write reference comments (#: file.cs:line)
198+
if (entry.References != null && entry.References.Count > 0)
199+
{
200+
foreach (var reference in entry.References)
201+
{
202+
sb.AppendLine($"#: {reference}");
203+
}
204+
}
205+
206+
// Write flags (#, fuzzy, c-format, etc.)
207+
if (entry.Flags != null && entry.Flags.Count > 0)
208+
{
209+
sb.AppendLine($"#, {string.Join(", ", entry.Flags)}");
210+
}
211+
165212
// Determine if we have context (key contains |)
166213
string? context = null;
167214
var msgId = entry.Key;
@@ -200,20 +247,26 @@ private void WriteEntry(StringBuilder sb, ResourceEntry entry, string languageCo
200247
{
201248
var category = categories[i];
202249
var value = entry.PluralForms.GetValueOrDefault(category) ?? "";
203-
WritePoString(sb, $"msgstr[{i}]", value);
250+
WritePoString(sb, $"msgstr[{i}]", value, entry);
204251
}
205252
}
206253
else
207254
{
208255
// Write msgstr
209-
WritePoString(sb, "msgstr", entry.Value ?? "");
256+
WritePoString(sb, "msgstr", entry.Value ?? "", entry);
210257
}
211-
212-
sb.AppendLine();
213258
}
214259

215-
private void WritePoString(StringBuilder sb, string keyword, string value)
260+
private void WritePoString(StringBuilder sb, string keyword, string value, ResourceEntry? entry = null)
216261
{
262+
// Check if we can use original formatting
263+
if (CanUseOriginalFormatting(entry, value, keyword))
264+
{
265+
sb.AppendLine(entry!.OriginalFormatting);
266+
return;
267+
}
268+
269+
// Otherwise, regenerate formatting
217270
var escaped = EscapeString(value);
218271
var prefix = $"{keyword} ";
219272

@@ -385,4 +438,52 @@ private string GetLanguageDisplayName(string cultureCode)
385438
return cultureCode;
386439
}
387440
}
441+
442+
/// <summary>
443+
/// Reconstructs the unescaped value from original formatting to verify it matches the current value.
444+
/// </summary>
445+
private string ReconstructValueFromFormatting(string originalFormatting)
446+
{
447+
var lines = originalFormatting.Split('\n');
448+
var value = new StringBuilder();
449+
450+
foreach (var line in lines)
451+
{
452+
// Extract string content between quotes
453+
var match = System.Text.RegularExpressions.Regex.Match(
454+
line.Trim(), @"^(?:msgstr(?:\[\d+\])?\s+)?""(.*)""$");
455+
if (match.Success)
456+
{
457+
// Unescape and concatenate
458+
value.Append(PoResourceReader.UnescapeString(match.Groups[1].Value));
459+
}
460+
}
461+
462+
return value.ToString();
463+
}
464+
465+
/// <summary>
466+
/// Checks if we can use the original formatting for this entry.
467+
/// Returns true only if the value hasn't changed from the original.
468+
/// </summary>
469+
private bool CanUseOriginalFormatting(ResourceEntry? entry, string currentValue, string keyword)
470+
{
471+
try
472+
{
473+
if (entry == null || string.IsNullOrEmpty(entry.OriginalFormatting))
474+
return false;
475+
476+
var lines = entry.OriginalFormatting.Split('\n');
477+
if (lines.Length == 0 || !lines[0].StartsWith(keyword))
478+
return false;
479+
480+
var reconstructedValue = ReconstructValueFromFormatting(entry.OriginalFormatting);
481+
return reconstructedValue == currentValue;
482+
}
483+
catch
484+
{
485+
// If reconstruction fails, fall back to regenerating
486+
return false;
487+
}
488+
}
388489
}

0 commit comments

Comments
 (0)