Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,31 @@ dotnet run -- update-all --continue-on-error

This is useful when BTCPayServer adds new features and strings. Instead of retranslating everything, you can just update with the new additions.

### Refresh Keys Without Translating (placeholders)

If you just want to add the newly-added English keys to your translation files as placeholders (to translate later, by hand or with `update`), use `refresh-keys`. Unlike `update`, it does **not** call the AI service and does **not** require an OpenRouter API key.

```bash
# Refresh all translation files from a LOCAL source file (no download)
dotnet run -- refresh-keys --source-file ../btcpayserver/BTCPayServer/Services/Translations.Default.cs

# Refresh only specific languages
dotnet run -- refresh-keys --source-file ./Translations.Default.cs --languages fr es de

# Refresh from a running BTCPay Server (includes the DI-registered strings)
dotnet run -- refresh-keys --btcpay-url http://localhost:14142

# Without --source-file / --btcpay-url it falls back to the configured InputFile (GitHub)
dotnet run -- refresh-keys
```

**How `refresh-keys` differs from `update`:**
- **No AI / no API key** - new keys are inserted with the English text as a placeholder value.
- **Insert-only** - it never removes keys (so DI-registered strings not present in the static source are kept). It only adds keys that are missing.
- **Byte-preserving** - existing entries (including `_maintainer`/`_source` metadata, ordering, and formatting) are left untouched; only new lines are added. Re-running it is a no-op once everything is present.

Options: `--source-file <path>` (local file, overrides the configured InputFile), `--btcpay-url <url>` (takes precedence over `--source-file`), `--languages <codes>` (optional filter; omit to refresh all files).

## Fetching Translations from a Running BTCPay Server

By default the tool fetches strings by parsing `Translations.Default.cs` from GitHub. However, some strings are registered via Dependency Injection (by plugins, payment methods, etc.) and do not appear in that file.
Expand Down
40 changes: 40 additions & 0 deletions Translator.Tests/CLI/CliTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,46 @@ await File.WriteAllTextAsync(translationFile, """
}
}

[Fact]
public async Task RefreshKeys_InsertsMissingKeys_FromLocalSourceFile_AndReturnsZero()
{
var outputDirectory = CreateTempDirectory();
var inputFile = CreateKnownTranslationsInputFile();
var frenchFile = Path.Combine(outputDirectory, "french.json");

try
{
await File.WriteAllTextAsync(frenchFile, "{\r\n \"existing\": \"valeur\"\r\n}");

var result = await CliTestHost.RunAsync(
["refresh-keys", "--source-file", inputFile],
new Dictionary<string, string?>
{
["Translation__OutputDirectory"] = outputDirectory,
["OPENROUTER_API_KEY"] = "" // refresh-keys must work without OpenRouter configured
});

Assert.Equal(0, result.ExitCode);
Assert.Contains("Refresh completed", result.CombinedOutput);

var written = await File.ReadAllTextAsync(frenchFile);
Assert.Contains("\"hello\"", written); // new source key inserted
Assert.Contains("\"existing\": \"valeur\"", written); // existing entry untouched
}
finally
{
if (Directory.Exists(outputDirectory))
{
Directory.Delete(outputDirectory, recursive: true);
}

if (File.Exists(inputFile))
{
File.Delete(inputFile);
}
}
}

private static string CreateTempDirectory()
{
var directory = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.CliTests", Guid.NewGuid().ToString("N"));
Expand Down
237 changes: 237 additions & 0 deletions Translator.Tests/Services/FileWriterRefreshTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
using System.Text;
using BTCPayTranslator.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Newtonsoft.Json.Linq;
using Xunit;

namespace BTCPayTranslator.Tests.Services;

public class FileWriterRefreshTests
{
// Build a CRLF JSON document from individual lines (no trailing newline unless requested).
private static string Crlf(params string[] lines) => string.Join("\r\n", lines);

private static FileWriter Sut() => new(NullLogger<FileWriter>.Instance);

private static Dictionary<string, string> Source(params (string Key, string Value)[] entries) =>
entries.ToDictionary(e => e.Key, e => e.Value);

[Fact]
public async Task InsertMissingKeysAsync_InsertsNewKey_InCorrectSortedPosition()
{
var file = WriteTemp(Crlf(
"{",
" \"a\": \"A\",",
" \"c\": \"C\"",
"}"));
try
{
var added = await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "B"), ("c", "C")));

Assert.Equal(1, added);
var keys = JObject.Parse(await File.ReadAllTextAsync(file)).Properties().Select(p => p.Name).ToList();
Assert.Equal(new[] { "a", "b", "c" }, keys);
}
finally { Cleanup(file); }
}

[Fact]
public async Task InsertMissingKeysAsync_PlaceholderValue_EqualsEnglishSource()
{
var file = WriteTemp(Crlf("{", " \"a\": \"A\"", "}"));
try
{
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "English B")));

var json = JObject.Parse(await File.ReadAllTextAsync(file));
Assert.Equal("English B", json["b"]!.Value<string>());
}
finally { Cleanup(file); }
}

[Fact]
public async Task InsertMissingKeysAsync_PreservesExistingLines_AndEmptyValues_AndNonAscii()
{
var existingLines = new[]
{
" \"_maintainer\": \"someone|https://example.com\",",
" \"déjà\": \"déjà vu\",",
" \"empty\": \"\",",
" \"zed\": \"Z\""
};
var file = WriteTemp(Crlf(new[] { "{" }.Concat(existingLines).Append("}").ToArray()));
try
{
var added = await Sut().InsertMissingKeysAsync(file, Source(("mango", "Mango"), ("zed", "Z")));

Assert.Equal(1, added);
var text = await File.ReadAllTextAsync(file);

// Every original entry line survives verbatim.
foreach (var line in existingLines)
Assert.Contains(line, text);

// Empty value preserved, non-ASCII left raw (no \u escapes anywhere).
Assert.DoesNotContain("\\u", text);
var json = JObject.Parse(text);
Assert.Equal("", json["empty"]!.Value<string>());
Assert.Equal("déjà vu", json["déjà"]!.Value<string>());
}
finally { Cleanup(file); }
}

[Fact]
public async Task InsertMissingKeysAsync_PreservesTrailingSpaceOnExistingLine()
{
// A non-last line that ends with ", " (comma + trailing space) must stay byte-identical.
var spacey = " \"a\": \"A\", ";
var file = WriteTemp(Crlf("{", spacey, " \"c\": \"C\"", "}"));
try
{
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "B"), ("c", "C")));

var lines = (await File.ReadAllTextAsync(file)).Split("\r\n");
Assert.Contains(spacey, lines);
}
finally { Cleanup(file); }
}

[Fact]
public async Task InsertMissingKeysAsync_DoesNotReorderExisting_InNonCanonicalOrderFile()
{
// Keys deliberately NOT in writer order.
var file = WriteTemp(Crlf(
"{",
" \"_maintainer\": \"x|https://e.com\",",
" \"zebra\": \"Z\",",
" \"alpha\": \"A\"",
"}"));
try
{
await Sut().InsertMissingKeysAsync(file, Source(("zebra", "Z"), ("alpha", "A"), ("mango", "M")));

var keys = JObject.Parse(await File.ReadAllTextAsync(file)).Properties().Select(p => p.Name).ToList();
// Existing relative order is preserved; only positions of the 3 pre-existing keys matter here.
Assert.True(keys.IndexOf("_maintainer") < keys.IndexOf("zebra"));
Assert.True(keys.IndexOf("zebra") < keys.IndexOf("alpha"));
Assert.Contains("mango", keys);
}
finally { Cleanup(file); }
}

[Fact]
public async Task InsertMissingKeysAsync_IsIdempotent()
{
var file = WriteTemp(Crlf("{", " \"a\": \"A\",", " \"c\": \"C\"", "}"));
try
{
var first = await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "B"), ("c", "C")));
var afterFirst = await File.ReadAllBytesAsync(file);

var second = await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "B"), ("c", "C")));
var afterSecond = await File.ReadAllBytesAsync(file);

Assert.Equal(1, first);
Assert.Equal(0, second);
Assert.Equal(afterFirst, afterSecond);
}
finally { Cleanup(file); }
}

[Fact]
public async Task InsertMissingKeysAsync_PreservesTrailingNewline_WhenPresent()
{
var file = WriteTemp(Crlf("{", " \"a\": \"A\"", "}") + "\r\n");
try
{
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "B")));
Assert.EndsWith("}\r\n", await File.ReadAllTextAsync(file));
}
finally { Cleanup(file); }
}

[Fact]
public async Task InsertMissingKeysAsync_PreservesNoTrailingNewline_WhenAbsent()
{
var file = WriteTemp(Crlf("{", " \"a\": \"A\"", "}"));
try
{
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "B")));
var text = await File.ReadAllTextAsync(file);
Assert.EndsWith("}", text);
Assert.False(text.EndsWith("}\r\n"));
}
finally { Cleanup(file); }
}

[Fact]
public async Task InsertMissingKeysAsync_InsertingAfterLastKey_FixesPreviousLastComma()
{
var file = WriteTemp(Crlf("{", " \"a\": \"A\"", "}"));
try
{
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("z", "Z")));

var lines = (await File.ReadAllTextAsync(file)).Split("\r\n");
Assert.Equal(" \"a\": \"A\",", lines[1]); // gained a comma
Assert.Equal(" \"z\": \"Z\"", lines[2]); // new last, no comma
}
finally { Cleanup(file); }
}

[Fact]
public async Task InsertMissingKeysAsync_InsertsAtTop_WhenNewKeyPrecedesAllExisting()
{
var file = WriteTemp(Crlf("{", " \"m\": \"M\"", "}"));
try
{
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("m", "M")));

var keys = JObject.Parse(await File.ReadAllTextAsync(file)).Properties().Select(p => p.Name).ToList();
Assert.Equal(new[] { "a", "m" }, keys);
}
finally { Cleanup(file); }
}

[Fact]
public async Task InsertMissingKeysAsync_ReturnsZero_OnMissingFile()
{
var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json");
var added = await Sut().InsertMissingKeysAsync(missing, Source(("a", "A")));
Assert.Equal(0, added);
}

[Fact]
public async Task InsertMissingKeysAsync_RendersValueWithNewline_AsOnePhysicalLine()
{
var file = WriteTemp(Crlf("{", " \"a\": \"A\"", "}"));
try
{
// Source value contains an actual newline; it must be escaped as \n on a single line.
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("multi", "line1\nline2")));

var text = await File.ReadAllTextAsync(file);
var lines = text.Split("\r\n");
Assert.Equal(4, lines.Length); // { , "a" , "multi" , }
Assert.Contains(lines, l => l.Contains("\"multi\"") && l.Contains("line1\\nline2"));
Assert.Equal("line1\nline2", JObject.Parse(text)["multi"]!.Value<string>());
}
finally { Cleanup(file); }
}

private static string WriteTemp(string content)
{
var dir = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "french.json");
File.WriteAllText(path, content, new UTF8Encoding(false));
return path;
}

private static void Cleanup(string file)
{
var dir = Path.GetDirectoryName(file)!;
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
Loading
Loading