From 9ae9c12cfaa92461272791076cda89ca6708f368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BE=89=E9=B8=AD=E8=9B=8B?= Date: Fri, 13 Feb 2026 10:11:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(translation):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E7=BF=BB=E8=AF=91=E4=B8=8A=E6=8A=A5=E8=87=B3?= =?UTF-8?q?=20Supabase=20=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 IMissingTranslationReporter 接口及 SupabaseMissingTranslationReporter 实现 - 在 JsonTranslationService 中集成缺失翻译上报逻辑 - 添加缺失翻译收集的配置设置(MissingTranslationCollectionSettings) - 优化缺失翻译文件的序列化格式,将 Source 字段改为紧凑的数字表示 - 移除 ScriptRepoUpdater 中未使用的 using 语句 - 在 App.xaml.cs 中注册 SupabaseMissingTranslationReporter 服务 --- BetterGenshinImpact/App.xaml.cs | 1 + .../BetterGenshinImpact.csproj | 1 + .../Core/Config/OtherConfig.cs | 4 +- .../Core/Script/ScriptRepoUpdater.cs | 1 - .../Interface/IMissingTranslationReporter.cs | 6 + .../Service/JsonTranslationService.cs | 180 +++++++- .../MissingTranslationCollectionSettings.cs | 22 + .../SupabaseMissingTranslationReporter.cs | 400 ++++++++++++++++++ 8 files changed, 594 insertions(+), 21 deletions(-) create mode 100644 BetterGenshinImpact/Service/Interface/IMissingTranslationReporter.cs create mode 100644 BetterGenshinImpact/Service/MissingTranslationCollectionSettings.cs create mode 100644 BetterGenshinImpact/Service/SupabaseMissingTranslationReporter.cs diff --git a/BetterGenshinImpact/App.xaml.cs b/BetterGenshinImpact/App.xaml.cs index ceff6473..02f735bd 100644 --- a/BetterGenshinImpact/App.xaml.cs +++ b/BetterGenshinImpact/App.xaml.cs @@ -86,6 +86,7 @@ public partial class App : Application } Log.Logger = loggerConfiguration.CreateLogger(); + services.AddSingleton(); services.AddSingleton(); services.AddLogging(logging => { diff --git a/BetterGenshinImpact/BetterGenshinImpact.csproj b/BetterGenshinImpact/BetterGenshinImpact.csproj index 9391c6b9..4df40d29 100644 --- a/BetterGenshinImpact/BetterGenshinImpact.csproj +++ b/BetterGenshinImpact/BetterGenshinImpact.csproj @@ -84,6 +84,7 @@ + diff --git a/BetterGenshinImpact/Core/Config/OtherConfig.cs b/BetterGenshinImpact/Core/Config/OtherConfig.cs index 491e6888..ac65d2b7 100644 --- a/BetterGenshinImpact/Core/Config/OtherConfig.cs +++ b/BetterGenshinImpact/Core/Config/OtherConfig.cs @@ -1,4 +1,4 @@ -using System; +using System; using BetterGenshinImpact.Core.Recognition; using BetterGenshinImpact.Model; using CommunityToolkit.Mvvm.ComponentModel; @@ -121,4 +121,4 @@ public partial class OtherConfig : ObservableObject /// [ObservableProperty] private string _uiCultureInfoName = "zh-Hans"; -} \ No newline at end of file +} diff --git a/BetterGenshinImpact/Core/Script/ScriptRepoUpdater.cs b/BetterGenshinImpact/Core/Script/ScriptRepoUpdater.cs index 4d411ff0..75266cd1 100644 --- a/BetterGenshinImpact/Core/Script/ScriptRepoUpdater.cs +++ b/BetterGenshinImpact/Core/Script/ScriptRepoUpdater.cs @@ -21,7 +21,6 @@ using System.Linq; using System.Net.Http; using System.Threading.Tasks; using System.Windows; -using Windows.UI.Xaml.Automation; using BetterGenshinImpact.View.Windows; using LibGit2Sharp; using LibGit2Sharp.Handlers; diff --git a/BetterGenshinImpact/Service/Interface/IMissingTranslationReporter.cs b/BetterGenshinImpact/Service/Interface/IMissingTranslationReporter.cs new file mode 100644 index 00000000..5673ead1 --- /dev/null +++ b/BetterGenshinImpact/Service/Interface/IMissingTranslationReporter.cs @@ -0,0 +1,6 @@ +namespace BetterGenshinImpact.Service.Interface; + +public interface IMissingTranslationReporter +{ + bool TryEnqueue(string language, string key, TranslationSourceInfo sourceInfo); +} diff --git a/BetterGenshinImpact/Service/JsonTranslationService.cs b/BetterGenshinImpact/Service/JsonTranslationService.cs index a02fbc8d..68560415 100644 --- a/BetterGenshinImpact/Service/JsonTranslationService.cs +++ b/BetterGenshinImpact/Service/JsonTranslationService.cs @@ -13,12 +13,14 @@ 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 _missingKeys = new(StringComparer.Ordinal); private readonly Timer _flushTimer; @@ -28,9 +30,10 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable private IReadOnlyDictionary _map = new Dictionary(StringComparer.Ordinal); private int _dirtyMissing; - public JsonTranslationService(IConfigService configService) + 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)); @@ -77,6 +80,11 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable return text; } + if (string.IsNullOrWhiteSpace(culture.Name)) + { + return text; + } + EnsureMapLoaded(culture.Name); if (_map.TryGetValue(text, out var translated) && !string.IsNullOrWhiteSpace(translated)) @@ -90,6 +98,7 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable normalizedSource, (_, existingSource) => MergeSourceInfo(existingSource, normalizedSource)); Interlocked.Exchange(ref _dirtyMissing, 1); + _missingTranslationReporter.TryEnqueue(culture.Name, text, normalizedSource); return text; } @@ -144,6 +153,12 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable private void FlushMissingIfDirty(string cultureName) { + if (string.IsNullOrWhiteSpace(cultureName)) + { + Interlocked.Exchange(ref _dirtyMissing, 0); + return; + } + if (IsChineseCultureName(cultureName)) { Interlocked.Exchange(ref _dirtyMissing, 0); @@ -191,7 +206,8 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable { var key = pair.Key; var sourceInfo = pair.Value; - var source = SourceToString(sourceInfo.Source); + var source = SourceToCompactString(sourceInfo.Source); + var missingSourceInfo = StripSource(sourceInfo); if (!existing.TryGetValue(key, out var existingItem)) { @@ -200,26 +216,26 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable Key = key, Value = string.Empty, Source = source, - SourceInfo = sourceInfo + SourceInfo = missingSourceInfo }; updated = true; continue; } - if (string.IsNullOrWhiteSpace(existingItem.Source) || string.Equals(existingItem.Source, SourceToString(MissingTextSource.Unknown), StringComparison.Ordinal)) + 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 = sourceInfo + SourceInfo = missingSourceInfo }; updated = true; continue; } - var mergedSourceInfo = MergeSourceInfo(existingItem.SourceInfo, sourceInfo); + var mergedSourceInfo = MergeSourceInfo(existingItem.SourceInfo, missingSourceInfo); if (!ReferenceEquals(mergedSourceInfo, existingItem.SourceInfo)) { existing[key] = new MissingItem @@ -244,8 +260,8 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable { Key = i.Key, Value = i.Value ?? string.Empty, - Source = i.Source ?? SourceToString(MissingTextSource.Unknown), - SourceInfo = NormalizeSourceInfo(i.SourceInfo) + Source = string.IsNullOrWhiteSpace(i.Source) ? SourceToCompactString(MissingTextSource.Unknown) : i.Source, + SourceInfo = NormalizeSourceInfoForMissing(i.SourceInfo) }) .ToList(); var jsonOut = JsonConvert.SerializeObject(items, Formatting.Indented); @@ -269,8 +285,8 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable { Key = item.Key, Value = item.Value ?? string.Empty, - Source = string.IsNullOrWhiteSpace(item.Source) ? SourceToString(MissingTextSource.Unknown) : item.Source, - SourceInfo = NormalizeSourceInfo(item.SourceInfo) + Source = string.IsNullOrWhiteSpace(item.Source) ? SourceToCompactString(MissingTextSource.Unknown) : item.Source, + SourceInfo = NormalizeSourceInfoForMissing(item.SourceInfo) }; dict[item.Key] = normalized; } @@ -298,6 +314,26 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable }; } + 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) @@ -357,15 +393,9 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable && string.Equals(left.Notes, right.Notes, StringComparison.Ordinal); } - private static string SourceToString(MissingTextSource source) + private static string SourceToCompactString(MissingTextSource source) { - return source switch - { - MissingTextSource.Log => "Log", - MissingTextSource.UiStaticLiteral => "UiStaticLiteral", - MissingTextSource.UiDynamicBinding => "UiDynamicBinding", - _ => "Unknown" - }; + return ((int)source).ToString(CultureInfo.InvariantCulture); } private static void WriteAtomically(string filePath, string content) @@ -440,10 +470,124 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable { 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 + { + 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 + { + 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("ViewXamlPath") ?? obj.Value("viewXamlPath"), + ViewType = obj.Value("ViewType") ?? obj.Value("viewType"), + ElementType = obj.Value("ElementType") ?? obj.Value("elementType"), + ElementName = obj.Value("ElementName") ?? obj.Value("elementName"), + PropertyName = obj.Value("PropertyName") ?? obj.Value("propertyName"), + BindingPath = obj.Value("BindingPath") ?? obj.Value("bindingPath"), + Notes = obj.Value("Notes") ?? obj.Value("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)) diff --git a/BetterGenshinImpact/Service/MissingTranslationCollectionSettings.cs b/BetterGenshinImpact/Service/MissingTranslationCollectionSettings.cs new file mode 100644 index 00000000..eb2de236 --- /dev/null +++ b/BetterGenshinImpact/Service/MissingTranslationCollectionSettings.cs @@ -0,0 +1,22 @@ +using System; + +namespace BetterGenshinImpact.Service; + +public static class MissingTranslationCollectionSettings +{ + public static readonly bool Enabled = true; + public static readonly string SupabaseUrl = "https://obwddvnwzaolbdawduxg.supabase.co"; + public static readonly string SupabaseApiKey = "sb_publishable_PyvQSxxCi02aawC-G6vtgg_wzOctlgm"; + public static readonly string Table = "translation_missing"; + public static readonly int BatchSize = 200; + public static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(5); + + public static bool IsValid => + Enabled + && !string.IsNullOrWhiteSpace(SupabaseUrl) + && !string.IsNullOrWhiteSpace(SupabaseApiKey) + && !string.IsNullOrWhiteSpace(Table) + && BatchSize > 0 + && FlushInterval > TimeSpan.Zero; +} + diff --git a/BetterGenshinImpact/Service/SupabaseMissingTranslationReporter.cs b/BetterGenshinImpact/Service/SupabaseMissingTranslationReporter.cs new file mode 100644 index 00000000..1b4f154c --- /dev/null +++ b/BetterGenshinImpact/Service/SupabaseMissingTranslationReporter.cs @@ -0,0 +1,400 @@ +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 _channel; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _worker; + + public SupabaseMissingTranslationReporter() + { + _channel = Channel.CreateBounded( + 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(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 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 TryUpsertBatchAsync(IReadOnlyList 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("value")] string Value, + [property: JsonPropertyName("source")] string Source, + [property: JsonPropertyName("source_info"), JsonConverter(typeof(TranslationSourceInfoWithoutSourceJsonConverter))] TranslationSourceInfo SourceInfo); + + private sealed class TranslationSourceInfoWithoutSourceJsonConverter : JsonConverter + { + 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); + } +}