mirror of
https://github.com/babalae/better-genshin-impact.git
synced 2026-03-15 07:43:20 +08:00
feat(i18n): 添加界面与日志的国际化支持
- 新增 ITranslationService 接口及 JsonTranslationService 实现,提供基于 JSON 的翻译服务 - 添加 TrConverter 转换器,支持通过绑定动态翻译界面文本 - 引入 AutoTranslateInterceptor 行为,自动扫描并翻译界面中的静态文本 - 集成 TranslatingSerilogLoggerProvider,实现日志输出的实时翻译 - 在 App.xaml 中注册全局样式,为 Window、UserControl 和 Page 启用自动翻译
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
<Application x:Class="BetterGenshinImpact.App"
|
<Application x:Class="BetterGenshinImpact.App"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior"
|
xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior"
|
||||||
@@ -24,12 +24,22 @@
|
|||||||
<bgivc:NotNullConverter x:Key="NotNullConverter" />
|
<bgivc:NotNullConverter x:Key="NotNullConverter" />
|
||||||
<bgivc:EnumToKVPConverter x:Key="EnumToKVPConverter" />
|
<bgivc:EnumToKVPConverter x:Key="EnumToKVPConverter" />
|
||||||
<bgivc:CultureInfoNameToKVPConverter x:Key="CultureInfoNameToKVPConverter" />
|
<bgivc:CultureInfoNameToKVPConverter x:Key="CultureInfoNameToKVPConverter" />
|
||||||
|
<bgivc:TrConverter x:Key="TrConverter" />
|
||||||
<Style BasedOn="{StaticResource DefaultTextBoxStyle}" TargetType="{x:Type TextBox}">
|
<Style BasedOn="{StaticResource DefaultTextBoxStyle}" TargetType="{x:Type TextBox}">
|
||||||
<Setter Property="behavior:ClipboardInterceptor.EnableSafeClipboard" Value="True" />
|
<Setter Property="behavior:ClipboardInterceptor.EnableSafeClipboard" Value="True" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style BasedOn="{StaticResource DefaultTextBoxStyle}" TargetType="{x:Type ui:TextBox}">
|
<Style BasedOn="{StaticResource DefaultTextBoxStyle}" TargetType="{x:Type ui:TextBox}">
|
||||||
<Setter Property="behavior:ClipboardInterceptor.EnableSafeClipboard" Value="True" />
|
<Setter Property="behavior:ClipboardInterceptor.EnableSafeClipboard" Value="True" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style TargetType="{x:Type Window}">
|
||||||
|
<Setter Property="behavior:AutoTranslateInterceptor.EnableAutoTranslate" Value="True" />
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="{x:Type UserControl}">
|
||||||
|
<Setter Property="behavior:AutoTranslateInterceptor.EnableAutoTranslate" Value="True" />
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="{x:Type Page}">
|
||||||
|
<Setter Property="behavior:AutoTranslateInterceptor.EnableAutoTranslate" Value="True" />
|
||||||
|
</Style>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
@@ -86,7 +86,12 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
|
|
||||||
Log.Logger = loggerConfiguration.CreateLogger();
|
Log.Logger = loggerConfiguration.CreateLogger();
|
||||||
services.AddLogging(c => c.AddSerilog());
|
services.AddSingleton<ITranslationService, JsonTranslationService>();
|
||||||
|
services.AddLogging(logging =>
|
||||||
|
{
|
||||||
|
logging.ClearProviders();
|
||||||
|
logging.Services.AddSingleton<ILoggerProvider, TranslatingSerilogLoggerProvider>();
|
||||||
|
});
|
||||||
|
|
||||||
services.AddLocalization();
|
services.AddLocalization();
|
||||||
|
|
||||||
|
|||||||
130
BetterGenshinImpact/Helpers/TranslatingSerilogLoggerProvider.cs
Normal file
130
BetterGenshinImpact/Helpers/TranslatingSerilogLoggerProvider.cs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using BetterGenshinImpact.Service.Interface;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace BetterGenshinImpact.Helpers;
|
||||||
|
|
||||||
|
public sealed class TranslatingSerilogLoggerProvider : ILoggerProvider
|
||||||
|
{
|
||||||
|
private readonly ITranslationService _translationService;
|
||||||
|
|
||||||
|
public TranslatingSerilogLoggerProvider(ITranslationService translationService)
|
||||||
|
{
|
||||||
|
_translationService = translationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Microsoft.Extensions.Logging.ILogger CreateLogger(string categoryName)
|
||||||
|
{
|
||||||
|
return new TranslatingSerilogLogger(categoryName, _translationService);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TranslatingSerilogLogger : Microsoft.Extensions.Logging.ILogger
|
||||||
|
{
|
||||||
|
private readonly ITranslationService _translationService;
|
||||||
|
private readonly Serilog.ILogger _logger;
|
||||||
|
|
||||||
|
public TranslatingSerilogLogger(string categoryName, ITranslationService translationService)
|
||||||
|
{
|
||||||
|
_translationService = translationService;
|
||||||
|
_logger = Serilog.Log.Logger.ForContext("SourceContext", categoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull
|
||||||
|
{
|
||||||
|
return NullScope.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEnabled(LogLevel logLevel)
|
||||||
|
{
|
||||||
|
return logLevel != LogLevel.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
if (!IsEnabled(logLevel))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var serilogLevel = ConvertLevel(logLevel);
|
||||||
|
if (serilogLevel == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (template, values) = ExtractTemplateAndValues(state, formatter, exception);
|
||||||
|
var translatedTemplate = _translationService.Translate(template, MissingTextSource.Log);
|
||||||
|
|
||||||
|
if (values.Length == 0)
|
||||||
|
{
|
||||||
|
_logger.Write(serilogLevel.Value, exception, translatedTemplate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Write(serilogLevel.Value, exception, translatedTemplate, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string Template, object?[] Values) ExtractTemplateAndValues<TState>(
|
||||||
|
TState state,
|
||||||
|
Func<TState, Exception?, string> formatter,
|
||||||
|
Exception? exception)
|
||||||
|
{
|
||||||
|
if (state is IReadOnlyList<KeyValuePair<string, object?>> kvps)
|
||||||
|
{
|
||||||
|
var original = kvps.FirstOrDefault(kv => string.Equals(kv.Key, "{OriginalFormat}", StringComparison.Ordinal));
|
||||||
|
var template = original.Value as string;
|
||||||
|
if (string.IsNullOrEmpty(template))
|
||||||
|
{
|
||||||
|
template = formatter(state, exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
var values = kvps
|
||||||
|
.Where(kv =>
|
||||||
|
!string.Equals(kv.Key, "{OriginalFormat}", StringComparison.Ordinal) &&
|
||||||
|
!string.Equals(kv.Key, "EventId", StringComparison.Ordinal))
|
||||||
|
.Select(kv => kv.Value)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return (template ?? string.Empty, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (formatter(state, exception), Array.Empty<object?>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LogEventLevel? ConvertLevel(LogLevel level)
|
||||||
|
{
|
||||||
|
return level switch
|
||||||
|
{
|
||||||
|
LogLevel.Trace => LogEventLevel.Verbose,
|
||||||
|
LogLevel.Debug => LogEventLevel.Debug,
|
||||||
|
LogLevel.Information => LogEventLevel.Information,
|
||||||
|
LogLevel.Warning => LogEventLevel.Warning,
|
||||||
|
LogLevel.Error => LogEventLevel.Error,
|
||||||
|
LogLevel.Critical => LogEventLevel.Fatal,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullScope : IDisposable
|
||||||
|
{
|
||||||
|
public static NullScope Instance { get; } = new();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
BetterGenshinImpact/Service/Interface/ITranslationService.cs
Normal file
19
BetterGenshinImpact/Service/Interface/ITranslationService.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace BetterGenshinImpact.Service.Interface;
|
||||||
|
|
||||||
|
public enum MissingTextSource
|
||||||
|
{
|
||||||
|
Log,
|
||||||
|
UiStaticLiteral,
|
||||||
|
UiDynamicBinding,
|
||||||
|
Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ITranslationService
|
||||||
|
{
|
||||||
|
string Translate(string text);
|
||||||
|
string Translate(string text, MissingTextSource source);
|
||||||
|
CultureInfo GetCurrentCulture();
|
||||||
|
}
|
||||||
|
|
||||||
303
BetterGenshinImpact/Service/JsonTranslationService.cs
Normal file
303
BetterGenshinImpact/Service/JsonTranslationService.cs
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using BetterGenshinImpact.Service.Interface;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace BetterGenshinImpact.Service;
|
||||||
|
|
||||||
|
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 Timer _flushTimer;
|
||||||
|
|
||||||
|
private string _loadedCultureName = string.Empty;
|
||||||
|
private IReadOnlyDictionary<string, string> _map = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
private int _dirtyMissing;
|
||||||
|
|
||||||
|
public JsonTranslationService(IConfigService configService)
|
||||||
|
{
|
||||||
|
_configService = configService;
|
||||||
|
_flushTimer = new Timer(_ => FlushMissingIfDirty(), null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CultureInfo GetCurrentCulture()
|
||||||
|
{
|
||||||
|
var name = _configService.Get().OtherConfig.UiCultureInfoName;
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return CultureInfo.InvariantCulture;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new CultureInfo(name);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return CultureInfo.InvariantCulture;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Translate(string text)
|
||||||
|
{
|
||||||
|
return Translate(text, MissingTextSource.Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Translate(string text, MissingTextSource source)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ContainsCjk(text))
|
||||||
|
{
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
var culture = GetCurrentCulture();
|
||||||
|
if (IsChineseCulture(culture))
|
||||||
|
{
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureMapLoaded(culture.Name);
|
||||||
|
|
||||||
|
if (_map.TryGetValue(text, out var translated) && !string.IsNullOrWhiteSpace(translated))
|
||||||
|
{
|
||||||
|
return translated;
|
||||||
|
}
|
||||||
|
|
||||||
|
_missingKeys.AddOrUpdate(
|
||||||
|
text,
|
||||||
|
source,
|
||||||
|
(_, existingSource) => existingSource == MissingTextSource.Unknown ? source : existingSource);
|
||||||
|
Interlocked.Exchange(ref _dirtyMissing, 1);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureMapLoaded(string cultureName)
|
||||||
|
{
|
||||||
|
if (string.Equals(_loadedCultureName, cultureName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (string.Equals(_loadedCultureName, cultureName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FlushMissingIfDirty();
|
||||||
|
_map = LoadMap(cultureName);
|
||||||
|
_loadedCultureName = cultureName;
|
||||||
|
_missingKeys.Clear();
|
||||||
|
Interlocked.Exchange(ref _dirtyMissing, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyDictionary<string, string> LoadMap(string cultureName)
|
||||||
|
{
|
||||||
|
var path = GetMapFilePath(cultureName);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path, Encoding.UTF8);
|
||||||
|
var dict = JsonConvert.DeserializeObject<Dictionary<string, string>>(json) ?? new Dictionary<string, string>();
|
||||||
|
return new Dictionary<string, string>(dict, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FlushMissingIfDirty()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _dirtyMissing, 0) == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FlushMissing();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _dirtyMissing, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FlushMissing()
|
||||||
|
{
|
||||||
|
var culture = GetCurrentCulture();
|
||||||
|
if (IsChineseCulture(culture))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var missingSnapshot = _missingKeys.ToArray();
|
||||||
|
if (missingSnapshot.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var filePath = GetMissingFilePath(culture.Name);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
||||||
|
|
||||||
|
Dictionary<string, MissingItem> existing;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
existing = File.Exists(filePath) ? LoadMissing(filePath) : new Dictionary<string, MissingItem>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
existing = new Dictionary<string, MissingItem>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = false;
|
||||||
|
foreach (var pair in missingSnapshot)
|
||||||
|
{
|
||||||
|
var key = pair.Key;
|
||||||
|
var source = pair.Value;
|
||||||
|
|
||||||
|
if (!existing.TryGetValue(key, out var existingItem))
|
||||||
|
{
|
||||||
|
existing[key] = new MissingItem(key, string.Empty, SourceToString(source));
|
||||||
|
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));
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updated)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)))
|
||||||
|
.ToList();
|
||||||
|
var jsonOut = JsonConvert.SerializeObject(items, Formatting.Indented);
|
||||||
|
WriteAtomically(filePath, jsonOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, MissingItem> LoadMissing(string filePath)
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(filePath, Encoding.UTF8);
|
||||||
|
var list = JsonConvert.DeserializeObject<List<MissingItem>>(json) ?? [];
|
||||||
|
|
||||||
|
var dict = new Dictionary<string, MissingItem>(StringComparer.Ordinal);
|
||||||
|
foreach (var item in list)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(item.Key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = new MissingItem(
|
||||||
|
item.Key,
|
||||||
|
item.Value ?? string.Empty,
|
||||||
|
string.IsNullOrWhiteSpace(item.Source) ? SourceToString(MissingTextSource.Unknown) : item.Source);
|
||||||
|
dict[item.Key] = normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SourceToString(MissingTextSource source)
|
||||||
|
{
|
||||||
|
return source switch
|
||||||
|
{
|
||||||
|
MissingTextSource.Log => "Log",
|
||||||
|
MissingTextSource.UiStaticLiteral => "UiStaticLiteral",
|
||||||
|
MissingTextSource.UiDynamicBinding => "UiDynamicBinding",
|
||||||
|
_ => "Unknown"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteAtomically(string filePath, string content)
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(filePath)!;
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
var tmp = Path.Combine(directory, $"{Path.GetFileName(filePath)}.{Guid.NewGuid():N}.tmp");
|
||||||
|
File.WriteAllText(tmp, content, Encoding.UTF8);
|
||||||
|
|
||||||
|
if (File.Exists(filePath))
|
||||||
|
{
|
||||||
|
File.Replace(tmp, filePath, null);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
File.Move(tmp, filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsChineseCulture(CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (culture == CultureInfo.InvariantCulture)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = culture.Name;
|
||||||
|
return name.StartsWith("zh", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsCjk(string text)
|
||||||
|
{
|
||||||
|
foreach (var ch in text)
|
||||||
|
{
|
||||||
|
if ((ch >= 0x4E00 && ch <= 0x9FFF) || (ch >= 0x3400 && ch <= 0x4DBF))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetI18nDirectory()
|
||||||
|
{
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, "i18n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetMapFilePath(string cultureName)
|
||||||
|
{
|
||||||
|
return Path.Combine(GetI18nDirectory(), $"{cultureName}.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetMissingFilePath(string cultureName)
|
||||||
|
{
|
||||||
|
return Path.Combine(GetI18nDirectory(), $"missing.{cultureName}.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_flushTimer.Dispose();
|
||||||
|
FlushMissingIfDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record MissingItem(string Key, string Value, string Source);
|
||||||
|
}
|
||||||
416
BetterGenshinImpact/View/Behavior/AutoTranslateInterceptor.cs
Normal file
416
BetterGenshinImpact/View/Behavior/AutoTranslateInterceptor.cs
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Documents;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using BetterGenshinImpact.Service.Interface;
|
||||||
|
|
||||||
|
namespace BetterGenshinImpact.View.Behavior
|
||||||
|
{
|
||||||
|
public static class AutoTranslateInterceptor
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty EnableAutoTranslateProperty =
|
||||||
|
DependencyProperty.RegisterAttached(
|
||||||
|
"EnableAutoTranslate",
|
||||||
|
typeof(bool),
|
||||||
|
typeof(AutoTranslateInterceptor),
|
||||||
|
new PropertyMetadata(false, OnEnableAutoTranslateChanged));
|
||||||
|
|
||||||
|
public static void SetEnableAutoTranslate(DependencyObject element, bool value)
|
||||||
|
=> element.SetValue(EnableAutoTranslateProperty, value);
|
||||||
|
|
||||||
|
public static bool GetEnableAutoTranslate(DependencyObject element)
|
||||||
|
=> (bool)element.GetValue(EnableAutoTranslateProperty);
|
||||||
|
|
||||||
|
private static readonly DependencyProperty ScopeProperty =
|
||||||
|
DependencyProperty.RegisterAttached(
|
||||||
|
"Scope",
|
||||||
|
typeof(Scope),
|
||||||
|
typeof(AutoTranslateInterceptor),
|
||||||
|
new PropertyMetadata(null));
|
||||||
|
|
||||||
|
private static void OnEnableAutoTranslateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (d is not FrameworkElement fe)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.NewValue is true)
|
||||||
|
{
|
||||||
|
if (fe.GetValue(ScopeProperty) is Scope oldScope)
|
||||||
|
{
|
||||||
|
fe.Loaded -= oldScope.OnLoaded;
|
||||||
|
fe.Unloaded -= oldScope.OnUnloaded;
|
||||||
|
oldScope.Dispose();
|
||||||
|
fe.ClearValue(ScopeProperty);
|
||||||
|
}
|
||||||
|
|
||||||
|
var scope = new Scope(fe);
|
||||||
|
fe.SetValue(ScopeProperty, scope);
|
||||||
|
fe.Loaded += scope.OnLoaded;
|
||||||
|
fe.Unloaded += scope.OnUnloaded;
|
||||||
|
if (fe.IsLoaded)
|
||||||
|
{
|
||||||
|
scope.ApplyNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (fe.GetValue(ScopeProperty) is Scope scope)
|
||||||
|
{
|
||||||
|
fe.Loaded -= scope.OnLoaded;
|
||||||
|
fe.Unloaded -= scope.OnUnloaded;
|
||||||
|
scope.Dispose();
|
||||||
|
fe.ClearValue(ScopeProperty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Scope : IDisposable
|
||||||
|
{
|
||||||
|
private readonly FrameworkElement _root;
|
||||||
|
private readonly List<Action> _unsubscribe = new();
|
||||||
|
private bool _applied;
|
||||||
|
private readonly RoutedEventHandler _anyLoadedHandler;
|
||||||
|
private readonly HashSet<ContextMenu> _trackedContextMenus = new();
|
||||||
|
private readonly HashSet<ToolTip> _trackedToolTips = new();
|
||||||
|
|
||||||
|
public Scope(FrameworkElement root)
|
||||||
|
{
|
||||||
|
_root = root;
|
||||||
|
_anyLoadedHandler = OnAnyLoaded;
|
||||||
|
_root.AddHandler(FrameworkElement.LoadedEvent, _anyLoadedHandler, true);
|
||||||
|
_unsubscribe.Add(() => _root.RemoveHandler(FrameworkElement.LoadedEvent, _anyLoadedHandler));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnLoaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_applied)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applied = true;
|
||||||
|
Apply(_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyNow()
|
||||||
|
{
|
||||||
|
if (_applied)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applied = true;
|
||||||
|
Apply(_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnUnloaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var unsub in _unsubscribe)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
unsub();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_unsubscribe.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAnyLoaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_applied)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.OriginalSource is not DependencyObject obj)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_root.Dispatcher.BeginInvoke(
|
||||||
|
() => Apply(obj),
|
||||||
|
DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Apply(DependencyObject root)
|
||||||
|
{
|
||||||
|
var translator = App.GetService<ITranslationService>();
|
||||||
|
if (translator == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var queue = new Queue<DependencyObject>();
|
||||||
|
var visited = new HashSet<DependencyObject>();
|
||||||
|
queue.Enqueue(root);
|
||||||
|
|
||||||
|
while (queue.Count > 0)
|
||||||
|
{
|
||||||
|
var current = queue.Dequeue();
|
||||||
|
if (!visited.Add(current))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TranslateKnown(current, translator);
|
||||||
|
|
||||||
|
if (current is FrameworkElement feCurrent)
|
||||||
|
{
|
||||||
|
TrackContextMenu(feCurrent.ContextMenu, queue);
|
||||||
|
TrackToolTip(feCurrent.ToolTip, queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current is Visual || current is System.Windows.Media.Media3D.Visual3D)
|
||||||
|
{
|
||||||
|
var count = VisualTreeHelper.GetChildrenCount(current);
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
queue.Enqueue(VisualTreeHelper.GetChild(current, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current is FrameworkElement || current is FrameworkContentElement)
|
||||||
|
{
|
||||||
|
foreach (var child in LogicalTreeHelper.GetChildren(current).OfType<DependencyObject>())
|
||||||
|
{
|
||||||
|
queue.Enqueue(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current is FrameworkElement fe)
|
||||||
|
{
|
||||||
|
foreach (var inline in EnumerateInlineObjects(fe))
|
||||||
|
{
|
||||||
|
queue.Enqueue(inline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TrackContextMenu(ContextMenu? contextMenu, Queue<DependencyObject> queue)
|
||||||
|
{
|
||||||
|
if (contextMenu == null || !_trackedContextMenus.Add(contextMenu))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.Enqueue(contextMenu);
|
||||||
|
|
||||||
|
RoutedEventHandler? openedHandler = null;
|
||||||
|
openedHandler = (_, _) =>
|
||||||
|
{
|
||||||
|
_root.Dispatcher.BeginInvoke(
|
||||||
|
() => Apply(contextMenu),
|
||||||
|
DispatcherPriority.Loaded);
|
||||||
|
};
|
||||||
|
contextMenu.Opened += openedHandler;
|
||||||
|
_unsubscribe.Add(() => contextMenu.Opened -= openedHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TrackToolTip(object? toolTip, Queue<DependencyObject> queue)
|
||||||
|
{
|
||||||
|
if (toolTip is not ToolTip tt || !_trackedToolTips.Add(tt))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.Enqueue(tt);
|
||||||
|
|
||||||
|
RoutedEventHandler? openedHandler = null;
|
||||||
|
openedHandler = (_, _) =>
|
||||||
|
{
|
||||||
|
_root.Dispatcher.BeginInvoke(
|
||||||
|
() => Apply(tt),
|
||||||
|
DispatcherPriority.Loaded);
|
||||||
|
};
|
||||||
|
tt.Opened += openedHandler;
|
||||||
|
_unsubscribe.Add(() => tt.Opened -= openedHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<DependencyObject> EnumerateInlineObjects(FrameworkElement fe)
|
||||||
|
{
|
||||||
|
if (fe is not TextBlock tb)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var inline in EnumerateInlineObjects(tb.Inlines))
|
||||||
|
{
|
||||||
|
yield return inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<DependencyObject> EnumerateInlineObjects(InlineCollection inlines)
|
||||||
|
{
|
||||||
|
foreach (var inline in inlines)
|
||||||
|
{
|
||||||
|
yield return inline;
|
||||||
|
|
||||||
|
if (inline is Span span)
|
||||||
|
{
|
||||||
|
foreach (var nested in EnumerateInlineObjects(span.Inlines))
|
||||||
|
{
|
||||||
|
yield return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inline is InlineUIContainer { Child: DependencyObject child })
|
||||||
|
{
|
||||||
|
yield return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TranslateKnown(DependencyObject obj, ITranslationService translator)
|
||||||
|
{
|
||||||
|
switch (obj)
|
||||||
|
{
|
||||||
|
case TextBlock tb:
|
||||||
|
TranslateIfNotBound(tb, TextBlock.TextProperty, tb.Text, s => tb.Text = s, translator);
|
||||||
|
TranslateToolTip(tb, translator);
|
||||||
|
break;
|
||||||
|
case Run run:
|
||||||
|
TranslateIfNotBound(run, Run.TextProperty, run.Text, s => run.Text = s, translator);
|
||||||
|
break;
|
||||||
|
case HeaderedContentControl hcc:
|
||||||
|
if (hcc.Header is string header)
|
||||||
|
{
|
||||||
|
TranslateIfNotBound(hcc, HeaderedContentControl.HeaderProperty, header, s => hcc.Header = s, translator);
|
||||||
|
}
|
||||||
|
TranslateToolTip(hcc, translator);
|
||||||
|
break;
|
||||||
|
case ContentControl cc:
|
||||||
|
if (cc.Content is string content)
|
||||||
|
{
|
||||||
|
TranslateIfNotBound(cc, ContentControl.ContentProperty, content, s => cc.Content = s, translator);
|
||||||
|
}
|
||||||
|
TranslateToolTip(cc, translator);
|
||||||
|
break;
|
||||||
|
case FrameworkElement fe:
|
||||||
|
TranslateToolTip(fe, translator);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
TranslateStringLocalValues(obj, translator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TranslateToolTip(FrameworkElement fe, ITranslationService translator)
|
||||||
|
{
|
||||||
|
if (fe.ToolTip is string tip)
|
||||||
|
{
|
||||||
|
TranslateIfNotBound(fe, FrameworkElement.ToolTipProperty, tip, s => fe.ToolTip = s, translator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TranslateStringLocalValues(DependencyObject obj, ITranslationService translator)
|
||||||
|
{
|
||||||
|
var enumerator = obj.GetLocalValueEnumerator();
|
||||||
|
while (enumerator.MoveNext())
|
||||||
|
{
|
||||||
|
var entry = enumerator.Current;
|
||||||
|
var property = entry.Property;
|
||||||
|
if (property.PropertyType != typeof(string))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BindingOperations.IsDataBound(obj, property))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Value is not string value || string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ShouldTranslatePropertyName(property.Name) || !ContainsHan(value))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var translated = translator.Translate(value, MissingTextSource.UiStaticLiteral);
|
||||||
|
if (!ReferenceEquals(value, translated) && !string.Equals(value, translated, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
obj.SetValue(property, translated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldTranslatePropertyName(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.EndsWith("Path", StringComparison.Ordinal)
|
||||||
|
|| name.EndsWith("MemberPath", StringComparison.Ordinal)
|
||||||
|
|| string.Equals(name, "Uid", StringComparison.Ordinal)
|
||||||
|
|| string.Equals(name, "Name", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return name.Contains("Text", StringComparison.Ordinal)
|
||||||
|
|| name.Contains("Content", StringComparison.Ordinal)
|
||||||
|
|| name.Contains("Header", StringComparison.Ordinal)
|
||||||
|
|| name.Contains("ToolTip", StringComparison.Ordinal)
|
||||||
|
|| name.Contains("Title", StringComparison.Ordinal)
|
||||||
|
|| name.Contains("Subtitle", StringComparison.Ordinal)
|
||||||
|
|| name.Contains("Description", StringComparison.Ordinal)
|
||||||
|
|| name.Contains("Placeholder", StringComparison.Ordinal)
|
||||||
|
|| name.Contains("Label", StringComparison.Ordinal)
|
||||||
|
|| name.Contains("Caption", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsHan(string text)
|
||||||
|
{
|
||||||
|
foreach (var ch in text)
|
||||||
|
{
|
||||||
|
if (ch is >= '\u4E00' and <= '\u9FFF')
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TranslateIfNotBound(
|
||||||
|
DependencyObject obj,
|
||||||
|
DependencyProperty property,
|
||||||
|
string currentValue,
|
||||||
|
Action<string> setter,
|
||||||
|
ITranslationService translator)
|
||||||
|
{
|
||||||
|
if (BindingOperations.IsDataBound(obj, property))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var translated = translator.Translate(currentValue, MissingTextSource.UiStaticLiteral);
|
||||||
|
if (!ReferenceEquals(currentValue, translated) && !string.Equals(currentValue, translated, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
setter(translated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
BetterGenshinImpact/View/Converters/TrConverter.cs
Normal file
26
BetterGenshinImpact/View/Converters/TrConverter.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using BetterGenshinImpact.Service.Interface;
|
||||||
|
|
||||||
|
namespace BetterGenshinImpact.View.Converters;
|
||||||
|
|
||||||
|
[ValueConversion(typeof(string), typeof(string))]
|
||||||
|
public sealed class TrConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is not string s || string.IsNullOrEmpty(s))
|
||||||
|
{
|
||||||
|
return value ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var translator = App.GetService<ITranslationService>();
|
||||||
|
return translator?.Translate(s, MissingTextSource.UiDynamicBinding) ?? s;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
return value ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<ui:FluentWindow x:Class="BetterGenshinImpact.View.MainWindow"
|
<ui:FluentWindow x:Class="BetterGenshinImpact.View.MainWindow"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
|
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
|
||||||
|
xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:markup="clr-namespace:BetterGenshinImpact.Markup"
|
xmlns:markup="clr-namespace:BetterGenshinImpact.Markup"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}"
|
ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}"
|
||||||
ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
ExtendsContentIntoTitleBar="True"
|
ExtendsContentIntoTitleBar="True"
|
||||||
|
behavior:AutoTranslateInterceptor.EnableAutoTranslate="True"
|
||||||
FontFamily="{StaticResource TextThemeFontFamily}"
|
FontFamily="{StaticResource TextThemeFontFamily}"
|
||||||
Visibility="{Binding IsVisible, Mode=TwoWay, Converter={StaticResource BooleanToVisibilityConverter}}"
|
Visibility="{Binding IsVisible, Mode=TwoWay, Converter={StaticResource BooleanToVisibilityConverter}}"
|
||||||
WindowBackdropType="Auto"
|
WindowBackdropType="Auto"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:pages="clr-namespace:BetterGenshinImpact.ViewModel.Pages"
|
xmlns:pages="clr-namespace:BetterGenshinImpact.ViewModel.Pages"
|
||||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||||
|
xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior"
|
||||||
Title="HomePage"
|
Title="HomePage"
|
||||||
d:DataContext="{d:DesignInstance Type=pages:HomePageViewModel}"
|
d:DataContext="{d:DesignInstance Type=pages:HomePageViewModel}"
|
||||||
d:DesignHeight="850"
|
d:DesignHeight="850"
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
FontFamily="{StaticResource TextThemeFontFamily}"
|
FontFamily="{StaticResource TextThemeFontFamily}"
|
||||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
behavior:AutoTranslateInterceptor.EnableAutoTranslate="True"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
|
|
||||||
<b:Interaction.Triggers>
|
<b:Interaction.Triggers>
|
||||||
|
|||||||
Reference in New Issue
Block a user