diff --git a/BetterGenshinImpact/Helpers/TranslatingSerilogLoggerProvider.cs b/BetterGenshinImpact/Helpers/TranslatingSerilogLoggerProvider.cs index bbb26661..6b72fc44 100644 --- a/BetterGenshinImpact/Helpers/TranslatingSerilogLoggerProvider.cs +++ b/BetterGenshinImpact/Helpers/TranslatingSerilogLoggerProvider.cs @@ -66,7 +66,7 @@ public sealed class TranslatingSerilogLoggerProvider : ILoggerProvider } var (template, values) = ExtractTemplateAndValues(state, formatter, exception); - var translatedTemplate = _translationService.Translate(template, MissingTextSource.Log); + var translatedTemplate = _translationService.Translate(template, TranslationSourceInfo.From(MissingTextSource.Log)); if (values.Length == 0) { diff --git a/BetterGenshinImpact/Service/Interface/ITranslationService.cs b/BetterGenshinImpact/Service/Interface/ITranslationService.cs index 18a16b4c..c6974321 100644 --- a/BetterGenshinImpact/Service/Interface/ITranslationService.cs +++ b/BetterGenshinImpact/Service/Interface/ITranslationService.cs @@ -10,10 +10,30 @@ public enum MissingTextSource Unknown } +public sealed class TranslationSourceInfo +{ + public MissingTextSource Source { get; set; } = MissingTextSource.Unknown; + public string? ViewXamlPath { get; set; } + public string? ViewType { get; set; } + public string? ElementType { get; set; } + public string? ElementName { get; set; } + public string? PropertyName { get; set; } + public string? BindingPath { get; set; } + public string? Notes { get; set; } + + public static TranslationSourceInfo From(MissingTextSource source) + { + return new TranslationSourceInfo + { + Source = source + }; + } +} + public interface ITranslationService { string Translate(string text); - string Translate(string text, MissingTextSource source); + string Translate(string text, TranslationSourceInfo sourceInfo); CultureInfo GetCurrentCulture(); } diff --git a/BetterGenshinImpact/Service/JsonTranslationService.cs b/BetterGenshinImpact/Service/JsonTranslationService.cs index a24661bd..a02fbc8d 100644 --- a/BetterGenshinImpact/Service/JsonTranslationService.cs +++ b/BetterGenshinImpact/Service/JsonTranslationService.cs @@ -20,7 +20,7 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable { private readonly IConfigService _configService; private readonly object _sync = new(); - private readonly ConcurrentDictionary _missingKeys = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _missingKeys = new(StringComparer.Ordinal); private readonly Timer _flushTimer; private readonly OtherConfig _otherConfig; @@ -56,10 +56,10 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable public string Translate(string text) { - return Translate(text, MissingTextSource.Unknown); + return Translate(text, TranslationSourceInfo.From(MissingTextSource.Unknown)); } - public string Translate(string text, MissingTextSource source) + public string Translate(string text, TranslationSourceInfo sourceInfo) { if (string.IsNullOrEmpty(text)) { @@ -84,10 +84,11 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable return translated; } + var normalizedSource = NormalizeSourceInfo(sourceInfo); _missingKeys.AddOrUpdate( text, - source, - (_, existingSource) => existingSource == MissingTextSource.Unknown ? source : existingSource); + normalizedSource, + (_, existingSource) => MergeSourceInfo(existingSource, normalizedSource)); Interlocked.Exchange(ref _dirtyMissing, 1); return text; } @@ -189,18 +190,45 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable foreach (var pair in missingSnapshot) { var key = pair.Key; - var source = pair.Value; + var sourceInfo = pair.Value; + var source = SourceToString(sourceInfo.Source); if (!existing.TryGetValue(key, out var existingItem)) { - existing[key] = new MissingItem(key, string.Empty, SourceToString(source)); + existing[key] = new MissingItem + { + Key = key, + Value = string.Empty, + Source = source, + SourceInfo = sourceInfo + }; updated = true; continue; } if (string.IsNullOrWhiteSpace(existingItem.Source) || string.Equals(existingItem.Source, SourceToString(MissingTextSource.Unknown), StringComparison.Ordinal)) { - existing[key] = new MissingItem(key, existingItem.Value ?? string.Empty, SourceToString(source)); + existing[key] = new MissingItem + { + Key = key, + Value = existingItem.Value ?? string.Empty, + Source = source, + SourceInfo = sourceInfo + }; + updated = true; + continue; + } + + var mergedSourceInfo = MergeSourceInfo(existingItem.SourceInfo, sourceInfo); + if (!ReferenceEquals(mergedSourceInfo, existingItem.SourceInfo)) + { + existing[key] = new MissingItem + { + Key = key, + Value = existingItem.Value ?? string.Empty, + Source = existingItem.Source ?? source, + SourceInfo = mergedSourceInfo + }; updated = true; } } @@ -212,7 +240,13 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable var items = existing.Values .OrderBy(i => i.Key, StringComparer.Ordinal) - .Select(i => new MissingItem(i.Key, i.Value ?? string.Empty, i.Source ?? SourceToString(MissingTextSource.Unknown))) + .Select(i => new MissingItem + { + Key = i.Key, + Value = i.Value ?? string.Empty, + Source = i.Source ?? SourceToString(MissingTextSource.Unknown), + SourceInfo = NormalizeSourceInfo(i.SourceInfo) + }) .ToList(); var jsonOut = JsonConvert.SerializeObject(items, Formatting.Indented); WriteAtomically(filePath, jsonOut); @@ -231,16 +265,98 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable continue; } - var normalized = new MissingItem( - item.Key, - item.Value ?? string.Empty, - string.IsNullOrWhiteSpace(item.Source) ? SourceToString(MissingTextSource.Unknown) : item.Source); + var normalized = new MissingItem + { + Key = item.Key, + Value = item.Value ?? string.Empty, + Source = string.IsNullOrWhiteSpace(item.Source) ? SourceToString(MissingTextSource.Unknown) : item.Source, + SourceInfo = NormalizeSourceInfo(item.SourceInfo) + }; dict[item.Key] = normalized; } return dict; } + private static TranslationSourceInfo NormalizeSourceInfo(TranslationSourceInfo? sourceInfo) + { + if (sourceInfo == null) + { + return TranslationSourceInfo.From(MissingTextSource.Unknown); + } + + return new TranslationSourceInfo + { + Source = sourceInfo.Source, + ViewXamlPath = sourceInfo.ViewXamlPath, + ViewType = sourceInfo.ViewType, + ElementType = sourceInfo.ElementType, + ElementName = sourceInfo.ElementName, + PropertyName = sourceInfo.PropertyName, + BindingPath = sourceInfo.BindingPath, + Notes = sourceInfo.Notes + }; + } + + private static TranslationSourceInfo MergeSourceInfo(TranslationSourceInfo? existing, TranslationSourceInfo? incoming) + { + if (incoming == null) + { + return NormalizeSourceInfo(existing); + } + + if (existing == null) + { + return NormalizeSourceInfo(incoming); + } + + if (existing.Source == MissingTextSource.Unknown && incoming.Source != MissingTextSource.Unknown) + { + return incoming; + } + + if (existing.Source != MissingTextSource.Unknown && incoming.Source == MissingTextSource.Unknown) + { + return existing; + } + + if (existing.Source != incoming.Source) + { + return existing; + } + + var merged = new TranslationSourceInfo + { + Source = existing.Source, + ViewXamlPath = string.IsNullOrWhiteSpace(existing.ViewXamlPath) ? incoming.ViewXamlPath : existing.ViewXamlPath, + ViewType = string.IsNullOrWhiteSpace(existing.ViewType) ? incoming.ViewType : existing.ViewType, + ElementType = string.IsNullOrWhiteSpace(existing.ElementType) ? incoming.ElementType : existing.ElementType, + ElementName = string.IsNullOrWhiteSpace(existing.ElementName) ? incoming.ElementName : existing.ElementName, + PropertyName = string.IsNullOrWhiteSpace(existing.PropertyName) ? incoming.PropertyName : existing.PropertyName, + BindingPath = string.IsNullOrWhiteSpace(existing.BindingPath) ? incoming.BindingPath : existing.BindingPath, + Notes = string.IsNullOrWhiteSpace(existing.Notes) ? incoming.Notes : existing.Notes + }; + + if (IsSameSourceInfo(merged, existing)) + { + return existing; + } + + return merged; + } + + private static bool IsSameSourceInfo(TranslationSourceInfo left, TranslationSourceInfo right) + { + return left.Source == right.Source + && string.Equals(left.ViewXamlPath, right.ViewXamlPath, StringComparison.Ordinal) + && string.Equals(left.ViewType, right.ViewType, StringComparison.Ordinal) + && string.Equals(left.ElementType, right.ElementType, StringComparison.Ordinal) + && string.Equals(left.ElementName, right.ElementName, StringComparison.Ordinal) + && string.Equals(left.PropertyName, right.PropertyName, StringComparison.Ordinal) + && string.Equals(left.BindingPath, right.BindingPath, StringComparison.Ordinal) + && string.Equals(left.Notes, right.Notes, StringComparison.Ordinal); + } + private static string SourceToString(MissingTextSource source) { return source switch @@ -320,7 +436,13 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable FlushMissingIfDirty(); } - private sealed record MissingItem(string Key, string Value, string Source); + private sealed class MissingItem + { + public string Key { get; set; } = string.Empty; + public string? Value { get; set; } + public string? Source { get; set; } + public TranslationSourceInfo? SourceInfo { get; set; } + } private void OnOtherConfigPropertyChanged(object? sender, PropertyChangedEventArgs e) { diff --git a/BetterGenshinImpact/View/Behavior/AutoTranslateInterceptor.cs b/BetterGenshinImpact/View/Behavior/AutoTranslateInterceptor.cs index 9a0676b8..57820648 100644 --- a/BetterGenshinImpact/View/Behavior/AutoTranslateInterceptor.cs +++ b/BetterGenshinImpact/View/Behavior/AutoTranslateInterceptor.cs @@ -525,7 +525,7 @@ namespace BetterGenshinImpact.View.Behavior } } - var translated = translator.Translate(original, MissingTextSource.UiStaticLiteral); + var translated = translator.Translate(original, BuildSourceInfo(obj, property, MissingTextSource.UiStaticLiteral)); if (!ReferenceEquals(value, translated) && !string.Equals(value, translated, StringComparison.Ordinal)) { obj.SetValue(property, translated); @@ -599,7 +599,7 @@ namespace BetterGenshinImpact.View.Behavior } } - var translated = translator.Translate(original, MissingTextSource.UiStaticLiteral); + var translated = translator.Translate(original, BuildSourceInfo(obj, property, MissingTextSource.UiStaticLiteral)); if (!ReferenceEquals(currentValue, translated) && !string.Equals(currentValue, translated, StringComparison.Ordinal)) { setter(translated); @@ -691,6 +691,85 @@ namespace BetterGenshinImpact.View.Behavior BindingOperations.GetBindingExpressionBase(obj, property)?.UpdateTarget(); } } + + private static TranslationSourceInfo BuildSourceInfo(DependencyObject element, DependencyProperty property, MissingTextSource source) + { + var viewElement = FindViewElement(element); + var viewType = viewElement?.GetType(); + var xamlPath = GetViewXamlPath(viewType); + + return new TranslationSourceInfo + { + Source = source, + ViewXamlPath = xamlPath, + ViewType = viewType?.FullName, + ElementType = element.GetType().FullName, + ElementName = GetElementName(element), + PropertyName = property.Name + }; + } + + private static DependencyObject? FindViewElement(DependencyObject? element) + { + var current = element; + while (current != null) + { + if (current is Window || current is Page || current is UserControl) + { + return current; + } + + var parent = LogicalTreeHelper.GetParent(current); + if (parent == null && current is FrameworkElement fe) + { + parent = fe.Parent ?? fe.TemplatedParent as DependencyObject; + } + + if (parent == null && current is FrameworkContentElement fce) + { + parent = fce.Parent; + } + + if (parent == null) + { + parent = VisualTreeHelper.GetParent(current); + } + + current = parent; + } + + return null; + } + + private static string? GetViewXamlPath(Type? viewType) + { + if (viewType == null) + { + return null; + } + + var ns = viewType.Namespace ?? string.Empty; + const string viewMarker = ".View."; + var index = ns.IndexOf(viewMarker, StringComparison.Ordinal); + if (index < 0) + { + return null; + } + + var relativeNamespace = ns[(index + viewMarker.Length)..]; + var folder = string.IsNullOrWhiteSpace(relativeNamespace) ? "View" : $"View/{relativeNamespace.Replace('.', '/')}"; + return $"{folder}/{viewType.Name}.xaml"; + } + + private static string? GetElementName(DependencyObject element) + { + return element switch + { + FrameworkElement fe when !string.IsNullOrWhiteSpace(fe.Name) => fe.Name, + FrameworkContentElement fce when !string.IsNullOrWhiteSpace(fce.Name) => fce.Name, + _ => null + }; + } } } } diff --git a/BetterGenshinImpact/View/Converters/TrConverter.cs b/BetterGenshinImpact/View/Converters/TrConverter.cs index b6d543f5..b6c3166b 100644 --- a/BetterGenshinImpact/View/Converters/TrConverter.cs +++ b/BetterGenshinImpact/View/Converters/TrConverter.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Windows; using System.Windows.Data; using BetterGenshinImpact.Service.Interface; @@ -16,11 +17,13 @@ public sealed class TrConverter : IValueConverter } var translator = App.GetService(); - return translator?.Translate(s, MissingTextSource.UiDynamicBinding) ?? s; + var source = parameter is MissingTextSource sourceParam ? sourceParam : MissingTextSource.UiDynamicBinding; + return translator?.Translate(s, TranslationSourceInfo.From(source)) ?? s; } public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) { return value ?? string.Empty; } + }