mirror of
https://github.com/babalae/better-genshin-impact.git
synced 2026-03-15 07:43:20 +08:00
feat(translation): 为缺失文本翻译添加详细上下文信息
扩展翻译服务以收集缺失文本的详细上下文,包括视图路径、元素类型、属性名等。 重构 `ITranslationService` 接口,引入 `TranslationSourceInfo` 类封装上下文信息。 修改 `AutoTranslateInterceptor` 自动收集 UI 元素信息,`JsonTranslationService` 合并多来源上下文。
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
|
||||
{
|
||||
private readonly IConfigService _configService;
|
||||
private readonly object _sync = new();
|
||||
private readonly ConcurrentDictionary<string, MissingTextSource> _missingKeys = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, TranslationSourceInfo> _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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ITranslationService>();
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user