mirror of
https://github.com/babalae/better-genshin-impact.git
synced 2026-05-21 09:45:48 +08:00
* feat(i18n): 添加界面与日志的国际化支持
- 新增 ITranslationService 接口及 JsonTranslationService 实现,提供基于 JSON 的翻译服务
- 添加 TrConverter 转换器,支持通过绑定动态翻译界面文本
- 引入 AutoTranslateInterceptor 行为,自动扫描并翻译界面中的静态文本
- 集成 TranslatingSerilogLoggerProvider,实现日志输出的实时翻译
- 在 App.xaml 中注册全局样式,为 Window、UserControl 和 Page 启用自动翻译
* refactor(AutoTranslateInterceptor): 优化自动翻译拦截器的加载与应用机制
- 移除 HomePage 中冗余的 EnableAutoTranslate 属性设置,改为继承属性
- 通过类构造函数注册全局 Loaded 事件处理器,替代在每个元素上单独添加
- 引入请求队列机制,批量处理待应用翻译的元素,避免重复调度
- 扩展属性类型检查,支持 object 类型以处理更多动态内容场景
* fix: 移除全局自动翻译拦截器以避免冲突
移除在 App.xaml 中为 Window、UserControl 和 Page 全局设置的 AutoTranslateInterceptor,
改为仅在 PickerWindow 中显式启用。这解决了全局样式可能导致的意外行为或冲突。
* feat(ui): 为多个窗口启用自动翻译拦截器
为 MapLabelSearchWindow、ArtifactOcrDialog、PromptDialog 等 14 个窗口添加了 AutoTranslateInterceptor.EnableAutoTranslate 属性,以启用自动翻译拦截功能。
* feat(i18n): 添加国际化目录支持并优化异常处理
* feat(ui): 添加软件UI语言设置并改进翻译服务
- 在通用设置页面新增UI语言选择控件,支持动态切换界面语言
- 修改游戏语言标签为“原神游戏语言”以明确区分
- 改进JsonTranslationService,支持UI语言切换时的实时翻译更新
- 优化AutoTranslateInterceptor,缓存原始文本值并在语言切换时恢复
- 添加属性变更监听机制,确保UI元素在语言切换后正确刷新
* feat(自动翻译): 添加排除自动翻译的依赖属性
在 AutoTranslateInterceptor 中新增 ExcludeAutoTranslate 附加属性,允许对特定依赖对象禁用自动翻译功能。当遍历元素进行翻译时,会检查此属性并跳过已标记排除的元素。
* feat(translation): 为缺失文本翻译添加详细上下文信息
扩展翻译服务以收集缺失文本的详细上下文,包括视图路径、元素类型、属性名等。
重构 `ITranslationService` 接口,引入 `TranslationSourceInfo` 类封装上下文信息。
修改 `AutoTranslateInterceptor` 自动收集 UI 元素信息,`JsonTranslationService` 合并多来源上下文。
* Revert "feat(自动翻译): 添加排除自动翻译的依赖属性"
This reverts commit a1c2334951.
* fix: 跳过 GridViewRowPresenter 中的文本翻译
添加 IsInGridViewRowPresenter 检查,避免在 GridViewRowPresenter 控件内进行自动翻译,防止潜在的界面显示问题。
* fix: 修复自动翻译拦截器在组合框上下文中的误触发
在自动翻译拦截器中添加了 IsInComboBoxContext 方法,用于检测依赖对象是否处于 ComboBox 或其相关弹出菜单的上下文中。当检测到对象位于组合框上下文时,跳过自动翻译逻辑,避免对下拉选项等界面元素进行不必要的翻译操作,从而解决潜在的界面干扰问题。
* feat(translation): 添加缺失翻译上报至 Supabase 的功能
- 新增 IMissingTranslationReporter 接口及 SupabaseMissingTranslationReporter 实现
- 在 JsonTranslationService 中集成缺失翻译上报逻辑
- 添加缺失翻译收集的配置设置(MissingTranslationCollectionSettings)
- 优化缺失翻译文件的序列化格式,将 Source 字段改为紧凑的数字表示
- 移除 ScriptRepoUpdater 中未使用的 using 语句
- 在 App.xaml.cs 中注册 SupabaseMissingTranslationReporter 服务
* fix: 修复自动翻译功能中原始值恢复和重复报告问题
- 移除未使用的法语翻译支持以简化语言选项
- 修复 Supabase 报告序列化时移除冗余字段
- 添加已缺失翻译键的缓存以避免重复报告
- 重构自动翻译拦截器,将原始值存储移至依赖属性
- 修复原始值恢复逻辑,确保正确遍历所有子元素
* feat(ui): 添加更新UI语言文件功能
- 在 ITranslationService 接口中添加 Reload 方法
- 在 JsonTranslationService 中实现 Reload 方法,支持重新加载语言文件并发送变更通知
- 在通用设置页面添加“更新”按钮,点击后从远程仓库下载最新语言文件
- 实现 OnUpdateUiLanguageAsync 命令,支持从 GitHub 和镜像源下载语言文件
- 下载后自动替换本地文件并重新加载翻译服务
400 lines
15 KiB
C#
400 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Diagnostics;
|
|
using System.Threading;
|
|
using System.Threading.Channels;
|
|
using System.Threading.Tasks;
|
|
using BetterGenshinImpact.Helpers.Http;
|
|
using BetterGenshinImpact.Service.Interface;
|
|
|
|
namespace BetterGenshinImpact.Service;
|
|
|
|
public sealed class SupabaseMissingTranslationReporter : IMissingTranslationReporter, IDisposable
|
|
{
|
|
private readonly Channel<MissingTranslationEvent> _channel;
|
|
private readonly CancellationTokenSource _cts = new();
|
|
private readonly Task _worker;
|
|
|
|
public SupabaseMissingTranslationReporter()
|
|
{
|
|
_channel = Channel.CreateBounded<MissingTranslationEvent>(
|
|
new BoundedChannelOptions(10_000)
|
|
{
|
|
SingleReader = true,
|
|
SingleWriter = false,
|
|
FullMode = BoundedChannelFullMode.DropWrite
|
|
});
|
|
|
|
_worker = Task.Run(() => WorkerAsync(_cts.Token), _cts.Token);
|
|
}
|
|
|
|
public bool TryEnqueue(string language, string key, TranslationSourceInfo sourceInfo)
|
|
{
|
|
if (!MissingTranslationCollectionSettings.IsValid)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(language) || string.IsNullOrWhiteSpace(key))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return _channel.Writer.TryWrite(
|
|
new MissingTranslationEvent(
|
|
language,
|
|
key,
|
|
SourceToCompactString(sourceInfo.Source),
|
|
NormalizeSourceInfoForMissing(sourceInfo)));
|
|
}
|
|
|
|
private async Task WorkerAsync(CancellationToken token)
|
|
{
|
|
var pending = new Dictionary<string, MissingTranslationUpsertRow>(StringComparer.Ordinal);
|
|
|
|
using var timer = new PeriodicTimer(MissingTranslationCollectionSettings.FlushInterval);
|
|
|
|
try
|
|
{
|
|
while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false))
|
|
{
|
|
while (_channel.Reader.TryRead(out var ev))
|
|
{
|
|
var k = MakeKey(ev.Language, ev.Key);
|
|
if (!pending.TryGetValue(k, out var existing))
|
|
{
|
|
pending[k] = new MissingTranslationUpsertRow(ev.Language, ev.Key, ev.Source, ev.SourceInfo);
|
|
continue;
|
|
}
|
|
|
|
pending[k] = existing.Merge(ev.Source, ev.SourceInfo);
|
|
}
|
|
|
|
if (!MissingTranslationCollectionSettings.IsValid)
|
|
{
|
|
pending.Clear();
|
|
continue;
|
|
}
|
|
|
|
if (pending.Count > 0)
|
|
{
|
|
await FlushAsync(pending, token).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.WriteLine(ex);
|
|
}
|
|
}
|
|
|
|
private async Task FlushAsync(Dictionary<string, MissingTranslationUpsertRow> pending, CancellationToken token)
|
|
{
|
|
while (pending.Count > 0 && MissingTranslationCollectionSettings.IsValid && !token.IsCancellationRequested)
|
|
{
|
|
var batch = pending.Values.Take(MissingTranslationCollectionSettings.BatchSize).ToList();
|
|
if (batch.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var row in batch)
|
|
{
|
|
pending.Remove(MakeKey(row.Language, row.Key));
|
|
}
|
|
|
|
var ok = await TryUpsertBatchAsync(batch, token).ConfigureAwait(false);
|
|
if (!ok)
|
|
{
|
|
foreach (var row in batch)
|
|
{
|
|
pending[MakeKey(row.Language, row.Key)] = row;
|
|
}
|
|
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task<bool> TryUpsertBatchAsync(IReadOnlyList<MissingTranslationUpsertRow> batch, CancellationToken token)
|
|
{
|
|
try
|
|
{
|
|
if (!MissingTranslationCollectionSettings.IsValid)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var client = HttpClientFactory.GetClient(
|
|
"SupabaseMissingTranslation",
|
|
() =>
|
|
{
|
|
var http = new HttpClient
|
|
{
|
|
Timeout = TimeSpan.FromSeconds(10)
|
|
};
|
|
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
|
return http;
|
|
});
|
|
|
|
var url = $"{MissingTranslationCollectionSettings.SupabaseUrl.TrimEnd('/')}/rest/v1/{MissingTranslationCollectionSettings.Table}";
|
|
var requestUri = $"{url}?on_conflict=language,key";
|
|
|
|
var payload = JsonSerializer.Serialize(
|
|
batch.Select(r => new SupabaseMissingRowSnake(r.Language, r.Key, r.Source, r.SourceInfo)),
|
|
SupabaseJsonOptions);
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
|
|
{
|
|
Content = new StringContent(payload, Encoding.UTF8, "application/json")
|
|
};
|
|
|
|
request.Headers.TryAddWithoutValidation("apikey", MissingTranslationCollectionSettings.SupabaseApiKey);
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MissingTranslationCollectionSettings.SupabaseApiKey);
|
|
request.Headers.TryAddWithoutValidation("Prefer", "resolution=merge-duplicates,return=minimal");
|
|
|
|
using var response = await client.SendAsync(request, token).ConfigureAwait(false);
|
|
var responseText = string.Empty;
|
|
try
|
|
{
|
|
responseText = await response.Content.ReadAsStringAsync(token).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
Debug.WriteLine(
|
|
$"[MissingTranslation][Supabase] status={(int)response.StatusCode} {response.StatusCode}, batch={batch.Count}, table={MissingTranslationCollectionSettings.Table}, body={TruncateForLog(responseText, 2000)}");
|
|
return response.IsSuccessStatusCode;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_channel.Writer.TryComplete();
|
|
_cts.Cancel();
|
|
try
|
|
{
|
|
_worker.Wait(TimeSpan.FromSeconds(1));
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
_cts.Dispose();
|
|
}
|
|
|
|
private static string MakeKey(string language, string key)
|
|
{
|
|
return $"{language}\u001F{key}";
|
|
}
|
|
|
|
private static string TruncateForLog(string? text, int maxLength)
|
|
{
|
|
if (string.IsNullOrEmpty(text) || maxLength <= 0)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
if (text.Length <= maxLength)
|
|
{
|
|
return text;
|
|
}
|
|
|
|
return text.Substring(0, maxLength) + "...(truncated)";
|
|
}
|
|
|
|
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 NormalizeSourceInfoForMissing(TranslationSourceInfo? sourceInfo)
|
|
{
|
|
return StripSource(NormalizeSourceInfo(sourceInfo));
|
|
}
|
|
|
|
private static TranslationSourceInfo StripSource(TranslationSourceInfo sourceInfo)
|
|
{
|
|
return new TranslationSourceInfo
|
|
{
|
|
Source = MissingTextSource.Unknown,
|
|
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 readonly JsonSerializerOptions SupabaseJsonOptions = new()
|
|
{
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
WriteIndented = false
|
|
};
|
|
|
|
private readonly record struct MissingTranslationEvent(
|
|
string Language,
|
|
string Key,
|
|
string Source,
|
|
TranslationSourceInfo SourceInfo);
|
|
|
|
private sealed record MissingTranslationUpsertRow(string Language, string Key, string Source, TranslationSourceInfo SourceInfo)
|
|
{
|
|
public MissingTranslationUpsertRow Merge(string source, TranslationSourceInfo sourceInfo)
|
|
{
|
|
var mergedSource = string.Equals(Source, SourceToCompactString(MissingTextSource.Unknown), StringComparison.Ordinal) ? source : Source;
|
|
var mergedSourceInfo = MergeSourceInfo(SourceInfo, sourceInfo);
|
|
return new MissingTranslationUpsertRow(Language, Key, mergedSource, mergedSourceInfo);
|
|
}
|
|
}
|
|
|
|
private readonly record struct SupabaseMissingRowSnake(
|
|
[property: JsonPropertyName("language")] string Language,
|
|
[property: JsonPropertyName("key")] string Key,
|
|
[property: JsonPropertyName("source")] string Source,
|
|
[property: JsonPropertyName("source_info"), JsonConverter(typeof(TranslationSourceInfoWithoutSourceJsonConverter))] TranslationSourceInfo SourceInfo);
|
|
|
|
private sealed class TranslationSourceInfoWithoutSourceJsonConverter : JsonConverter<TranslationSourceInfo>
|
|
{
|
|
public override TranslationSourceInfo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
{
|
|
if (reader.TokenType == JsonTokenType.Null)
|
|
{
|
|
return TranslationSourceInfo.From(MissingTextSource.Unknown);
|
|
}
|
|
|
|
using var doc = JsonDocument.ParseValue(ref reader);
|
|
var root = doc.RootElement;
|
|
return new TranslationSourceInfo
|
|
{
|
|
Source = MissingTextSource.Unknown,
|
|
ViewXamlPath = root.TryGetProperty("ViewXamlPath", out var p1) ? p1.GetString() : null,
|
|
ViewType = root.TryGetProperty("ViewType", out var p2) ? p2.GetString() : null,
|
|
ElementType = root.TryGetProperty("ElementType", out var p3) ? p3.GetString() : null,
|
|
ElementName = root.TryGetProperty("ElementName", out var p4) ? p4.GetString() : null,
|
|
PropertyName = root.TryGetProperty("PropertyName", out var p5) ? p5.GetString() : null,
|
|
BindingPath = root.TryGetProperty("BindingPath", out var p6) ? p6.GetString() : null,
|
|
Notes = root.TryGetProperty("Notes", out var p7) ? p7.GetString() : null
|
|
};
|
|
}
|
|
|
|
public override void Write(Utf8JsonWriter writer, TranslationSourceInfo value, JsonSerializerOptions options)
|
|
{
|
|
writer.WriteStartObject();
|
|
WriteIfNotNull(writer, "ViewXamlPath", value.ViewXamlPath);
|
|
WriteIfNotNull(writer, "ViewType", value.ViewType);
|
|
WriteIfNotNull(writer, "ElementType", value.ElementType);
|
|
WriteIfNotNull(writer, "ElementName", value.ElementName);
|
|
WriteIfNotNull(writer, "PropertyName", value.PropertyName);
|
|
WriteIfNotNull(writer, "BindingPath", value.BindingPath);
|
|
WriteIfNotNull(writer, "Notes", value.Notes);
|
|
writer.WriteEndObject();
|
|
}
|
|
|
|
private static void WriteIfNotNull(Utf8JsonWriter writer, string propertyName, string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return;
|
|
}
|
|
|
|
writer.WriteString(propertyName, value);
|
|
}
|
|
}
|
|
|
|
private static string SourceToCompactString(MissingTextSource source)
|
|
{
|
|
return ((int)source).ToString(CultureInfo.InvariantCulture);
|
|
}
|
|
}
|