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 (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)
|
if (values.Length == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,10 +10,30 @@ public enum MissingTextSource
|
|||||||
Unknown
|
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
|
public interface ITranslationService
|
||||||
{
|
{
|
||||||
string Translate(string text);
|
string Translate(string text);
|
||||||
string Translate(string text, MissingTextSource source);
|
string Translate(string text, TranslationSourceInfo sourceInfo);
|
||||||
CultureInfo GetCurrentCulture();
|
CultureInfo GetCurrentCulture();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
|
|||||||
{
|
{
|
||||||
private readonly IConfigService _configService;
|
private readonly IConfigService _configService;
|
||||||
private readonly object _sync = new();
|
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 Timer _flushTimer;
|
||||||
private readonly OtherConfig _otherConfig;
|
private readonly OtherConfig _otherConfig;
|
||||||
|
|
||||||
@@ -56,10 +56,10 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
|
|||||||
|
|
||||||
public string Translate(string text)
|
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))
|
if (string.IsNullOrEmpty(text))
|
||||||
{
|
{
|
||||||
@@ -84,10 +84,11 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
|
|||||||
return translated;
|
return translated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var normalizedSource = NormalizeSourceInfo(sourceInfo);
|
||||||
_missingKeys.AddOrUpdate(
|
_missingKeys.AddOrUpdate(
|
||||||
text,
|
text,
|
||||||
source,
|
normalizedSource,
|
||||||
(_, existingSource) => existingSource == MissingTextSource.Unknown ? source : existingSource);
|
(_, existingSource) => MergeSourceInfo(existingSource, normalizedSource));
|
||||||
Interlocked.Exchange(ref _dirtyMissing, 1);
|
Interlocked.Exchange(ref _dirtyMissing, 1);
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
@@ -189,18 +190,45 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
|
|||||||
foreach (var pair in missingSnapshot)
|
foreach (var pair in missingSnapshot)
|
||||||
{
|
{
|
||||||
var key = pair.Key;
|
var key = pair.Key;
|
||||||
var source = pair.Value;
|
var sourceInfo = pair.Value;
|
||||||
|
var source = SourceToString(sourceInfo.Source);
|
||||||
|
|
||||||
if (!existing.TryGetValue(key, out var existingItem))
|
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;
|
updated = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(existingItem.Source) || string.Equals(existingItem.Source, SourceToString(MissingTextSource.Unknown), StringComparison.Ordinal))
|
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;
|
updated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,7 +240,13 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
|
|||||||
|
|
||||||
var items = existing.Values
|
var items = existing.Values
|
||||||
.OrderBy(i => i.Key, StringComparer.Ordinal)
|
.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();
|
.ToList();
|
||||||
var jsonOut = JsonConvert.SerializeObject(items, Formatting.Indented);
|
var jsonOut = JsonConvert.SerializeObject(items, Formatting.Indented);
|
||||||
WriteAtomically(filePath, jsonOut);
|
WriteAtomically(filePath, jsonOut);
|
||||||
@@ -231,16 +265,98 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var normalized = new MissingItem(
|
var normalized = new MissingItem
|
||||||
item.Key,
|
{
|
||||||
item.Value ?? string.Empty,
|
Key = item.Key,
|
||||||
string.IsNullOrWhiteSpace(item.Source) ? SourceToString(MissingTextSource.Unknown) : item.Source);
|
Value = item.Value ?? string.Empty,
|
||||||
|
Source = string.IsNullOrWhiteSpace(item.Source) ? SourceToString(MissingTextSource.Unknown) : item.Source,
|
||||||
|
SourceInfo = NormalizeSourceInfo(item.SourceInfo)
|
||||||
|
};
|
||||||
dict[item.Key] = normalized;
|
dict[item.Key] = normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
return dict;
|
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)
|
private static string SourceToString(MissingTextSource source)
|
||||||
{
|
{
|
||||||
return source switch
|
return source switch
|
||||||
@@ -320,7 +436,13 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
|
|||||||
FlushMissingIfDirty();
|
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)
|
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))
|
if (!ReferenceEquals(value, translated) && !string.Equals(value, translated, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
obj.SetValue(property, translated);
|
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))
|
if (!ReferenceEquals(currentValue, translated) && !string.Equals(currentValue, translated, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
setter(translated);
|
setter(translated);
|
||||||
@@ -691,6 +691,85 @@ namespace BetterGenshinImpact.View.Behavior
|
|||||||
BindingOperations.GetBindingExpressionBase(obj, property)?.UpdateTarget();
|
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;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
using System.Windows.Data;
|
using System.Windows.Data;
|
||||||
using BetterGenshinImpact.Service.Interface;
|
using BetterGenshinImpact.Service.Interface;
|
||||||
|
|
||||||
@@ -16,11 +17,13 @@ public sealed class TrConverter : IValueConverter
|
|||||||
}
|
}
|
||||||
|
|
||||||
var translator = App.GetService<ITranslationService>();
|
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)
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
{
|
{
|
||||||
return value ?? string.Empty;
|
return value ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user