mirror of
https://github.com/babalae/better-genshin-impact.git
synced 2026-05-21 09:45:48 +08:00
feat(translation): 添加缺失翻译上报至 Supabase 的功能
- 新增 IMissingTranslationReporter 接口及 SupabaseMissingTranslationReporter 实现 - 在 JsonTranslationService 中集成缺失翻译上报逻辑 - 添加缺失翻译收集的配置设置(MissingTranslationCollectionSettings) - 优化缺失翻译文件的序列化格式,将 Source 字段改为紧凑的数字表示 - 移除 ScriptRepoUpdater 中未使用的 using 语句 - 在 App.xaml.cs 中注册 SupabaseMissingTranslationReporter 服务
This commit is contained in:
@@ -86,6 +86,7 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
Log.Logger = loggerConfiguration.CreateLogger();
|
||||
services.AddSingleton<IMissingTranslationReporter, SupabaseMissingTranslationReporter>();
|
||||
services.AddSingleton<ITranslationService, JsonTranslationService>();
|
||||
services.AddLogging(logging =>
|
||||
{
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.RichTextBoxEx.Wpf" Version="1.1.0.1" />
|
||||
<!-- <PackageReference Include="supabase-csharp" Version="0.16.2" />-->
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
|
||||
<PackageReference Include="System.IO.Hashing" Version="9.0.4" />
|
||||
<PackageReference Include="TorchSharp" Version="0.105.0" />
|
||||
|
||||
@@ -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
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private string _uiCultureInfoName = "zh-Hans";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace BetterGenshinImpact.Service.Interface;
|
||||
|
||||
public interface IMissingTranslationReporter
|
||||
{
|
||||
bool TryEnqueue(string language, string key, TranslationSourceInfo sourceInfo);
|
||||
}
|
||||
@@ -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<string, TranslationSourceInfo> _missingKeys = new(StringComparer.Ordinal);
|
||||
private readonly Timer _flushTimer;
|
||||
@@ -28,9 +30,10 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
|
||||
private IReadOnlyDictionary<string, string> _map = new Dictionary<string, string>(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<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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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("value")] string Value,
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user