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 和镜像源下载语言文件
- 下载后自动替换本地文件并重新加载翻译服务
688 lines
23 KiB
C#
688 lines
23 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using BetterGenshinImpact.Core.Config;
|
|
using BetterGenshinImpact.Service.Interface;
|
|
using CommunityToolkit.Mvvm.Messaging;
|
|
using CommunityToolkit.Mvvm.Messaging.Messages;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace BetterGenshinImpact.Service;
|
|
|
|
public sealed class JsonTranslationService : ITranslationService, IDisposable
|
|
{
|
|
private readonly IConfigService _configService;
|
|
private readonly IMissingTranslationReporter _missingTranslationReporter;
|
|
private readonly object _sync = new();
|
|
private readonly ConcurrentDictionary<string, TranslationSourceInfo> _missingKeys = new(StringComparer.Ordinal);
|
|
private readonly Timer _flushTimer;
|
|
private readonly OtherConfig _otherConfig;
|
|
|
|
private string _loadedCultureName = string.Empty;
|
|
private IReadOnlyDictionary<string, string> _map = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
private volatile IReadOnlySet<string> _existingMissingKeys = new HashSet<string>(StringComparer.Ordinal);
|
|
private int _dirtyMissing;
|
|
|
|
public JsonTranslationService(IConfigService configService, IMissingTranslationReporter missingTranslationReporter)
|
|
{
|
|
_configService = configService;
|
|
_missingTranslationReporter = missingTranslationReporter;
|
|
_otherConfig = _configService.Get().OtherConfig;
|
|
_otherConfig.PropertyChanged += OnOtherConfigPropertyChanged;
|
|
_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 void Reload()
|
|
{
|
|
var cultureName = _otherConfig.UiCultureInfoName ?? string.Empty;
|
|
string previousLoaded;
|
|
string currentLoaded;
|
|
|
|
lock (_sync)
|
|
{
|
|
previousLoaded = _loadedCultureName;
|
|
FlushMissingIfDirty(previousLoaded);
|
|
|
|
if (string.IsNullOrWhiteSpace(cultureName) || IsChineseCultureName(cultureName))
|
|
{
|
|
_loadedCultureName = string.Empty;
|
|
_map = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
_existingMissingKeys = new HashSet<string>(StringComparer.Ordinal);
|
|
}
|
|
else
|
|
{
|
|
_loadedCultureName = cultureName;
|
|
_map = LoadMap(cultureName);
|
|
_existingMissingKeys = LoadExistingMissingKeys(cultureName);
|
|
}
|
|
|
|
_missingKeys.Clear();
|
|
Interlocked.Exchange(ref _dirtyMissing, 0);
|
|
currentLoaded = _loadedCultureName;
|
|
}
|
|
|
|
WeakReferenceMessenger.Default.Send(
|
|
new PropertyChangedMessage<object>(this, nameof(OtherConfig.UiCultureInfoName), previousLoaded, currentLoaded));
|
|
}
|
|
|
|
public string Translate(string text)
|
|
{
|
|
return Translate(text, TranslationSourceInfo.From(MissingTextSource.Unknown));
|
|
}
|
|
|
|
public string Translate(string text, TranslationSourceInfo sourceInfo)
|
|
{
|
|
if (string.IsNullOrEmpty(text))
|
|
{
|
|
return text;
|
|
}
|
|
|
|
if (!ContainsCjk(text))
|
|
{
|
|
return text;
|
|
}
|
|
|
|
var culture = GetCurrentCulture();
|
|
if (IsChineseCulture(culture))
|
|
{
|
|
return text;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(culture.Name))
|
|
{
|
|
return text;
|
|
}
|
|
|
|
EnsureMapLoaded(culture.Name);
|
|
|
|
if (_map.TryGetValue(text, out var translated) && !string.IsNullOrWhiteSpace(translated))
|
|
{
|
|
return translated;
|
|
}
|
|
|
|
var normalizedSource = NormalizeSourceInfo(sourceInfo);
|
|
_missingKeys.AddOrUpdate(
|
|
text,
|
|
normalizedSource,
|
|
(_, existingSource) => MergeSourceInfo(existingSource, normalizedSource));
|
|
Interlocked.Exchange(ref _dirtyMissing, 1);
|
|
if (!_existingMissingKeys.Contains(text))
|
|
{
|
|
_missingTranslationReporter.TryEnqueue(culture.Name, text, normalizedSource);
|
|
}
|
|
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;
|
|
}
|
|
|
|
var previousCultureName = _loadedCultureName;
|
|
FlushMissingIfDirty(previousCultureName);
|
|
_map = LoadMap(cultureName);
|
|
_loadedCultureName = cultureName;
|
|
_existingMissingKeys = LoadExistingMissingKeys(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 (Exception e)
|
|
{
|
|
Debug.WriteLine(e);
|
|
return new Dictionary<string, string>(StringComparer.Ordinal);
|
|
}
|
|
}
|
|
|
|
private void FlushMissingIfDirty()
|
|
{
|
|
FlushMissingIfDirty(_loadedCultureName);
|
|
}
|
|
|
|
private void FlushMissingIfDirty(string cultureName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(cultureName))
|
|
{
|
|
Interlocked.Exchange(ref _dirtyMissing, 0);
|
|
return;
|
|
}
|
|
|
|
if (IsChineseCultureName(cultureName))
|
|
{
|
|
Interlocked.Exchange(ref _dirtyMissing, 0);
|
|
return;
|
|
}
|
|
|
|
if (Interlocked.Exchange(ref _dirtyMissing, 0) == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
FlushMissing(cultureName);
|
|
}
|
|
catch
|
|
{
|
|
Interlocked.Exchange(ref _dirtyMissing, 1);
|
|
}
|
|
}
|
|
|
|
private void FlushMissing(string cultureName)
|
|
{
|
|
var missingSnapshot = _missingKeys.ToArray();
|
|
if (missingSnapshot.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var filePath = GetMissingFilePath(cultureName);
|
|
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 sourceInfo = pair.Value;
|
|
var source = SourceToCompactString(sourceInfo.Source);
|
|
var missingSourceInfo = StripSource(sourceInfo);
|
|
|
|
if (!existing.TryGetValue(key, out var existingItem))
|
|
{
|
|
existing[key] = new MissingItem
|
|
{
|
|
Key = key,
|
|
Value = string.Empty,
|
|
Source = source,
|
|
SourceInfo = missingSourceInfo
|
|
};
|
|
updated = true;
|
|
continue;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(existingItem.Source) || string.Equals(existingItem.Source, SourceToCompactString(MissingTextSource.Unknown), StringComparison.Ordinal))
|
|
{
|
|
existing[key] = new MissingItem
|
|
{
|
|
Key = key,
|
|
Value = existingItem.Value ?? string.Empty,
|
|
Source = source,
|
|
SourceInfo = missingSourceInfo
|
|
};
|
|
updated = true;
|
|
continue;
|
|
}
|
|
|
|
var mergedSourceInfo = MergeSourceInfo(existingItem.SourceInfo, missingSourceInfo);
|
|
if (!ReferenceEquals(mergedSourceInfo, existingItem.SourceInfo))
|
|
{
|
|
existing[key] = new MissingItem
|
|
{
|
|
Key = key,
|
|
Value = existingItem.Value ?? string.Empty,
|
|
Source = existingItem.Source ?? source,
|
|
SourceInfo = mergedSourceInfo
|
|
};
|
|
updated = true;
|
|
}
|
|
}
|
|
|
|
if (!updated)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var items = existing.Values
|
|
.OrderBy(i => i.Key, StringComparer.Ordinal)
|
|
.Select(i => new MissingItem
|
|
{
|
|
Key = i.Key,
|
|
Value = i.Value ?? string.Empty,
|
|
Source = string.IsNullOrWhiteSpace(i.Source) ? SourceToCompactString(MissingTextSource.Unknown) : i.Source,
|
|
SourceInfo = NormalizeSourceInfoForMissing(i.SourceInfo)
|
|
})
|
|
.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
|
|
{
|
|
Key = item.Key,
|
|
Value = item.Value ?? string.Empty,
|
|
Source = string.IsNullOrWhiteSpace(item.Source) ? SourceToCompactString(MissingTextSource.Unknown) : item.Source,
|
|
SourceInfo = NormalizeSourceInfoForMissing(item.SourceInfo)
|
|
};
|
|
dict[item.Key] = normalized;
|
|
}
|
|
|
|
return dict;
|
|
}
|
|
|
|
private static IReadOnlySet<string> LoadExistingMissingKeys(string cultureName)
|
|
{
|
|
var filePath = GetMissingFilePath(cultureName);
|
|
if (!File.Exists(filePath))
|
|
{
|
|
return new HashSet<string>(StringComparer.Ordinal);
|
|
}
|
|
|
|
try
|
|
{
|
|
return new HashSet<string>(LoadMissing(filePath).Keys, StringComparer.Ordinal);
|
|
}
|
|
catch
|
|
{
|
|
return new HashSet<string>(StringComparer.Ordinal);
|
|
}
|
|
}
|
|
|
|
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 string SourceToCompactString(MissingTextSource source)
|
|
{
|
|
return ((int)source).ToString(CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
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 IsChineseCultureName(string cultureName)
|
|
{
|
|
return cultureName.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 Global.Absolute(@"User\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();
|
|
_otherConfig.PropertyChanged -= OnOtherConfigPropertyChanged;
|
|
FlushMissingIfDirty();
|
|
}
|
|
|
|
private sealed class MissingItem
|
|
{
|
|
public string Key { get; set; } = string.Empty;
|
|
public string? Value { get; set; }
|
|
[JsonConverter(typeof(MissingSourceStringConverter))]
|
|
public string? Source { get; set; }
|
|
[JsonConverter(typeof(MissingSourceInfoWithoutSourceConverter))]
|
|
public TranslationSourceInfo? SourceInfo { get; set; }
|
|
}
|
|
|
|
private sealed class MissingSourceStringConverter : JsonConverter<string?>
|
|
{
|
|
public override void WriteJson(JsonWriter writer, string? value, JsonSerializer serializer)
|
|
{
|
|
if (string.IsNullOrEmpty(value))
|
|
{
|
|
writer.WriteNull();
|
|
return;
|
|
}
|
|
|
|
writer.WriteValue(value);
|
|
}
|
|
|
|
public override string? ReadJson(JsonReader reader, Type objectType, string? existingValue, bool hasExistingValue, JsonSerializer serializer)
|
|
{
|
|
if (reader.TokenType == JsonToken.Null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (reader.TokenType == JsonToken.Integer)
|
|
{
|
|
try
|
|
{
|
|
return Convert.ToInt32(reader.Value).ToString(CultureInfo.InvariantCulture);
|
|
}
|
|
catch
|
|
{
|
|
return SourceToCompactString(MissingTextSource.Unknown);
|
|
}
|
|
}
|
|
|
|
if (reader.TokenType == JsonToken.String)
|
|
{
|
|
var s = reader.Value as string;
|
|
if (string.IsNullOrWhiteSpace(s))
|
|
{
|
|
return SourceToCompactString(MissingTextSource.Unknown);
|
|
}
|
|
|
|
if (int.TryParse(s, out var parsed))
|
|
{
|
|
return parsed.ToString(CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
return s switch
|
|
{
|
|
"Log" => SourceToCompactString(MissingTextSource.Log),
|
|
"UiStaticLiteral" => SourceToCompactString(MissingTextSource.UiStaticLiteral),
|
|
"UiDynamicBinding" => SourceToCompactString(MissingTextSource.UiDynamicBinding),
|
|
_ => SourceToCompactString(MissingTextSource.Unknown)
|
|
};
|
|
}
|
|
|
|
return SourceToCompactString(MissingTextSource.Unknown);
|
|
}
|
|
}
|
|
|
|
private sealed class MissingSourceInfoWithoutSourceConverter : JsonConverter<TranslationSourceInfo?>
|
|
{
|
|
public override void WriteJson(JsonWriter writer, TranslationSourceInfo? value, JsonSerializer serializer)
|
|
{
|
|
if (value == null)
|
|
{
|
|
writer.WriteNull();
|
|
return;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
public override TranslationSourceInfo? ReadJson(JsonReader reader, Type objectType, TranslationSourceInfo? existingValue, bool hasExistingValue, JsonSerializer serializer)
|
|
{
|
|
if (reader.TokenType == JsonToken.Null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var obj = JObject.Load(reader);
|
|
return new TranslationSourceInfo
|
|
{
|
|
Source = MissingTextSource.Unknown,
|
|
ViewXamlPath = obj.Value<string>("ViewXamlPath") ?? obj.Value<string>("viewXamlPath"),
|
|
ViewType = obj.Value<string>("ViewType") ?? obj.Value<string>("viewType"),
|
|
ElementType = obj.Value<string>("ElementType") ?? obj.Value<string>("elementType"),
|
|
ElementName = obj.Value<string>("ElementName") ?? obj.Value<string>("elementName"),
|
|
PropertyName = obj.Value<string>("PropertyName") ?? obj.Value<string>("propertyName"),
|
|
BindingPath = obj.Value<string>("BindingPath") ?? obj.Value<string>("bindingPath"),
|
|
Notes = obj.Value<string>("Notes") ?? obj.Value<string>("notes")
|
|
};
|
|
}
|
|
|
|
private static void WriteIfNotNull(JsonWriter writer, string propertyName, string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return;
|
|
}
|
|
|
|
writer.WritePropertyName(propertyName);
|
|
writer.WriteValue(value);
|
|
}
|
|
}
|
|
|
|
private void OnOtherConfigPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName != nameof(OtherConfig.UiCultureInfoName))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var cultureName = _otherConfig.UiCultureInfoName ?? string.Empty;
|
|
string previousLoaded;
|
|
string currentLoaded;
|
|
|
|
lock (_sync)
|
|
{
|
|
previousLoaded = _loadedCultureName;
|
|
FlushMissingIfDirty(previousLoaded);
|
|
|
|
if (string.IsNullOrWhiteSpace(cultureName) || IsChineseCultureName(cultureName))
|
|
{
|
|
_loadedCultureName = string.Empty;
|
|
_map = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
_existingMissingKeys = new HashSet<string>(StringComparer.Ordinal);
|
|
}
|
|
else
|
|
{
|
|
_loadedCultureName = cultureName;
|
|
_map = LoadMap(cultureName);
|
|
_existingMissingKeys = LoadExistingMissingKeys(cultureName);
|
|
}
|
|
|
|
_missingKeys.Clear();
|
|
Interlocked.Exchange(ref _dirtyMissing, 0);
|
|
currentLoaded = _loadedCultureName;
|
|
}
|
|
|
|
if (!string.Equals(previousLoaded, currentLoaded, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
WeakReferenceMessenger.Default.Send(
|
|
new PropertyChangedMessage<object>(this, nameof(OtherConfig.UiCultureInfoName), previousLoaded, currentLoaded));
|
|
}
|
|
}
|
|
}
|