File size: 11,494 Bytes
3dd5c9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Npgsql;
using TaskTrackingSystem.Shared.Localization;

var options = BackfillOptions.Parse(args);
if (options.ShowHelp)
{
    BackfillOptions.PrintHelp();
    return 0;
}

var connectionString = Environment.GetEnvironmentVariable("SUPABASE_CONNECTION_STRING")
    ?? Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection");
if (string.IsNullOrWhiteSpace(connectionString))
{
    Console.Error.WriteLine("Missing SUPABASE_CONNECTION_STRING or ConnectionStrings__DefaultConnection.");
    return 2;
}

if (!options.Apply)
{
    Console.WriteLine("Dry run mode: no database updates will be written. Use --apply to persist translations.");
}

var provider = new BackfillTranslationProvider();
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(options.CancellationToken);

var targets = BackfillTarget.Create(options);
var summary = new BackfillSummary();

foreach (var target in targets)
{
    var rows = await ReadRowsAsync(connection, target, options);
    foreach (var batch in rows.Chunk(options.BatchSize))
    {
        foreach (var row in batch)
        {
            foreach (var field in target.Fields)
            {
                if (string.IsNullOrWhiteSpace(row.Values[field.Source]) || !string.IsNullOrWhiteSpace(row.Values[field.Target]))
                {
                    continue;
                }

                summary.Inspected++;
                var source = row.Values[field.Source]!;
                var result = await provider.TranslateAsync(
                    source,
                    field.IsName ? "my" : "en",
                    field.IsName ? "en" : "my",
                    field.IsName,
                    options.CancellationToken);

                if (!result.Success || string.IsNullOrWhiteSpace(result.TranslatedText))
                {
                    summary.Failed++;
                    Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: failed provider={result.Provider} error={result.ErrorMessage}");
                    continue;
                }

                if (!options.Apply)
                {
                    summary.WouldUpdate++;
                    Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: would update provider={result.Provider}");
                    continue;
                }

                try
                {
                    var updated = await UpdateMissingFieldAsync(connection, target, row.Id, field, result.TranslatedText!, options.CancellationToken);
                    if (updated)
                    {
                        summary.Updated++;
                        Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: updated provider={result.Provider}");
                    }
                    else
                    {
                        summary.Skipped++;
                        Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: skipped because target was filled concurrently");
                    }
                }
                catch (Exception ex) when (ex is not OperationCanceledException)
                {
                    summary.Failed++;
                    Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: failed database update error={ex.Message}");
                }
            }
        }
    }
}

Console.WriteLine($"Summary: inspected={summary.Inspected}; wouldUpdate={summary.WouldUpdate}; updated={summary.Updated}; skipped={summary.Skipped}; failed={summary.Failed}; mode={(options.Apply ? "apply" : "dry-run")}");
return 0;

static async Task<List<BackfillRow>> ReadRowsAsync(NpgsqlConnection connection, BackfillTarget target, BackfillOptions options)
{
    var missing = string.Join(" OR ", target.Fields.Select(field => $"(\"{field.Target}\" IS NULL OR btrim(\"{field.Target}\") = '')"));
    var sql = $"SELECT \"Id\", {string.Join(", ", target.Fields.SelectMany(field => new[] { $"\"{field.Source}\"", $"\"{field.Target}\"" }).Distinct())} FROM \"{target.Table}\" WHERE ({string.Join(" OR ", target.Fields.Select(field => $"\"{field.Source}\" IS NOT NULL AND btrim(\"{field.Source}\") <> ''"))}) AND ({missing})";
    if (options.Id.HasValue) sql += " AND \"Id\" = @id";
    sql += " ORDER BY \"Id\" LIMIT @limit";

    await using var command = new NpgsqlCommand(sql, connection);
    command.Parameters.AddWithValue("limit", options.Limit ?? int.MaxValue);
    if (options.Id.HasValue) command.Parameters.AddWithValue("id", options.Id.Value);

    var rows = new List<BackfillRow>();
    await using var reader = await command.ExecuteReaderAsync(options.CancellationToken);
    while (await reader.ReadAsync(options.CancellationToken))
    {
        var values = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
        for (var index = 1; index < reader.FieldCount; index++)
        {
            values[reader.GetName(index)] = reader.IsDBNull(index) ? null : reader.GetString(index);
        }
        rows.Add(new(reader.GetInt64(0), values));
    }
    return rows;
}

static async Task<bool> UpdateMissingFieldAsync(NpgsqlConnection connection, BackfillTarget target, long id, BackfillField field, string translatedText, CancellationToken cancellationToken)
{
    var sql = $"UPDATE \"{target.Table}\" SET \"{field.Target}\" = @value WHERE \"Id\" = @id AND (\"{field.Target}\" IS NULL OR btrim(\"{field.Target}\") = '')";
    await using var command = new NpgsqlCommand(sql, connection);
    command.Parameters.AddWithValue("value", translatedText);
    command.Parameters.AddWithValue("id", id);
    return await command.ExecuteNonQueryAsync(cancellationToken) == 1;
}

internal sealed record BackfillTarget(string Table, IReadOnlyList<BackfillField> Fields)
{
    public static IReadOnlyList<BackfillTarget> Create(BackfillOptions options)
    {
        var all = new List<BackfillTarget>
        {
            new("Projects", [new("Name", "NameMy"), new("Description", "DescriptionMy")]),
            new("Tasks", [new("Title", "TitleMy"), new("Description", "DescriptionMy")]),
            new("Issues", [new("Title", "TitleMy"), new("Description", "DescriptionMy"), new("DelayReason", "DelayReasonMy"), new("BlockedBy", "BlockedByMy")]),
            new("Users", [new("FirstName", "FirstNameMy", true), new("LastName", "LastNameMy", true)]),
            new("Roles", [new("Name", "NameMy"), new("Description", "DescriptionMy")]),
            new("notifications", [new("Title", "TitleMy"), new("Body", "BodyMy")])
        };
        if (options.IncludeComments) all.Add(new("Comments", [new("Message", "MessageMy")]));
        return options.Table is null ? all : all.Where(target => string.Equals(target.Table, options.Table, StringComparison.OrdinalIgnoreCase)).ToArray();
    }
}

internal sealed record BackfillField(string Source, string Target, bool IsName = false);
internal sealed record BackfillRow(long Id, Dictionary<string, string?> Values);
internal sealed class BackfillSummary { public int Inspected; public int WouldUpdate; public int Updated; public int Skipped; public int Failed; }

internal sealed class BackfillOptions
{
    public bool Apply { get; private set; }
    public bool IncludeComments { get; private set; }
    public bool ShowHelp { get; private set; }
    public string? Table { get; private set; }
    public int? Limit { get; private set; }
    public int BatchSize { get; private set; } = 20;
    public long? Id { get; private set; }
    public CancellationToken CancellationToken => cancellationSource.Token;
    private readonly CancellationTokenSource cancellationSource = new();

    public static BackfillOptions Parse(string[] args)
    {
        var options = new BackfillOptions();
        Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; options.cancellationSource.Cancel(); };
        for (var i = 0; i < args.Length; i++)
        {
            switch (args[i].ToLowerInvariant())
            {
                case "--apply": options.Apply = true; break;
                case "--dry-run": options.Apply = false; break;
                case "--include-comments": options.IncludeComments = true; break;
                case "--table" when i + 1 < args.Length: options.Table = args[++i]; break;
                case "--limit" when i + 1 < args.Length && int.TryParse(args[++i], out var limit): options.Limit = Math.Max(1, limit); break;
                case "--batch-size" when i + 1 < args.Length && int.TryParse(args[++i], out var batchSize): options.BatchSize = Math.Max(1, batchSize); break;
                case "--id" when i + 1 < args.Length && long.TryParse(args[++i], out var id): options.Id = id; break;
                case "--help" or "-h": options.ShowHelp = true; break;
            }
        }
        return options;
    }

    public static void PrintHelp() => Console.WriteLine("Usage: dotnet run --project Tools/TranslationBackfill -- --dry-run|--apply [--table Projects] [--batch-size 20] [--limit 100] [--id 123] [--include-comments]");
}

internal sealed class BackfillTranslationProvider
{
    private readonly HttpClient client = new();
    private readonly string? endpoint = Environment.GetEnvironmentVariable("TRANSLATION_API_URL");
    private readonly string? apiKey = Environment.GetEnvironmentVariable("TRANSLATION_API_KEY");
    private readonly string provider = Environment.GetEnvironmentVariable("TRANSLATION_PROVIDER") ?? "configured-http";

    public async Task<TranslationResult> TranslateAsync(string sourceText, string sourceLanguage, string targetLanguage, bool isName, CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(endpoint)) return TranslationResult.NotConfigured();
        using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
        if (!string.IsNullOrWhiteSpace(apiKey)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
        request.Content = JsonContent.Create(new { text = sourceText, sourceLanguage, targetLanguage, mode = isName ? "transliteration" : "translation", instruction = isName ? "Return the name only; transliterate pronunciation, do not translate meaning." : "Return translated text only; preserve names, codes, IDs, dates, numbers, placeholders, URLs, and appropriate technical terms." });
        using var response = await client.SendAsync(request, cancellationToken);
        var body = await response.Content.ReadAsStringAsync(cancellationToken);
        if (!response.IsSuccessStatusCode) return new(false, null, $"Provider returned {(int)response.StatusCode}.", provider, false);
        try
        {
            using var document = JsonDocument.Parse(body);
            var root = document.RootElement;
            var text = root.TryGetProperty("translatedText", out var translated) ? translated.GetString() : root.TryGetProperty("text", out var plain) ? plain.GetString() : root.TryGetProperty("translation", out var alternate) ? alternate.GetString() : null;
            return string.IsNullOrWhiteSpace(text) ? new(false, null, "Provider returned no translated text.", provider, false) : TranslationResult.Generated(text.Trim(), provider);
        }
        catch (JsonException) { return new(false, null, "Provider returned invalid JSON.", provider, false); }
    }
}