201 lines
7.3 KiB
C#
201 lines
7.3 KiB
C#
using System.Text;
|
||
using Vanguard.Rules.Cards;
|
||
using YamlDotNet.Serialization;
|
||
using YamlDotNet.Serialization.NamingConventions;
|
||
|
||
namespace Vanguard.Content.Translations;
|
||
|
||
public sealed class TranslationFile
|
||
{
|
||
public int Version { get; set; } = 1;
|
||
public List<TranslationEntry> Cards { get; set; } = new();
|
||
}
|
||
|
||
public sealed class TranslationEntry
|
||
{
|
||
public string NameId { get; set; } = string.Empty;
|
||
public string NameEn { get; set; } = string.Empty;
|
||
|
||
/// <summary>Английский текст с токенами вместо иконок. Источник для перевода.</summary>
|
||
public string? Template { get; set; }
|
||
|
||
public string? TextRu { get; set; }
|
||
}
|
||
|
||
public static class TranslationYaml
|
||
{
|
||
private const string Header =
|
||
"""
|
||
# Русские тексты карт.
|
||
#
|
||
# Имена карт остаются английскими — переводим только текст эффекта.
|
||
# Заполняйте только textRu. Остальные поля не меняйте.
|
||
# Токены в фигурных скобках — это иконки: {auto}, {cb:3}, {vcrc}, {clan:kagero}.
|
||
# Их нужно сохранить в textRu ровно в таком же количестве, иначе проверка не пройдёт.
|
||
# Список всех токенов — content/keywords.yaml.
|
||
#
|
||
# Проверка: dotnet run --project server/src/Vanguard.Cli -- translations check
|
||
|
||
""";
|
||
|
||
public static void Write(TranslationFile file, string path)
|
||
{
|
||
var dir = Path.GetDirectoryName(path);
|
||
if (!string.IsNullOrEmpty(dir))
|
||
Directory.CreateDirectory(dir);
|
||
|
||
var sb = new StringBuilder();
|
||
sb.Append(Header.ReplaceLineEndings("\n"));
|
||
sb.Append("version: ").Append(file.Version).Append('\n');
|
||
sb.Append("cards:\n");
|
||
|
||
foreach (var card in file.Cards)
|
||
{
|
||
sb.Append(" - nameId: ").Append(card.NameId).Append('\n');
|
||
sb.Append(" nameEn: ").Append(Quote(card.NameEn)).Append('\n');
|
||
AppendBlock(sb, "template", card.Template);
|
||
sb.Append(" textRu: ").Append(Quote(card.TextRu ?? string.Empty)).Append('\n');
|
||
}
|
||
|
||
File.WriteAllText(path, sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||
}
|
||
|
||
public static TranslationFile Read(string path)
|
||
{
|
||
var deserializer = new DeserializerBuilder()
|
||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||
.IgnoreUnmatchedProperties()
|
||
.Build();
|
||
|
||
return deserializer.Deserialize<TranslationFile>(File.ReadAllText(path)) ?? new TranslationFile();
|
||
}
|
||
|
||
private static void AppendBlock(StringBuilder sb, string key, string? value)
|
||
{
|
||
if (string.IsNullOrEmpty(value))
|
||
{
|
||
sb.Append(" ").Append(key).Append(": \"\"\n");
|
||
return;
|
||
}
|
||
|
||
sb.Append(" ").Append(key).Append(": |-\n");
|
||
foreach (var line in value.ReplaceLineEndings("\n").Split('\n'))
|
||
sb.Append(" ").Append(line.TrimEnd()).Append('\n');
|
||
}
|
||
|
||
private static string Quote(string value) =>
|
||
"\"" + value
|
||
.Replace("\\", "\\\\")
|
||
.Replace("\"", "\\\"")
|
||
.Replace("\r", string.Empty)
|
||
.Replace("\n", "\\n") + "\"";
|
||
}
|
||
|
||
public sealed record TranslationIssue(string NameId, string Code, string Message);
|
||
|
||
public sealed record TranslationReport(
|
||
int Total,
|
||
int Translated,
|
||
IReadOnlyList<TranslationIssue> Issues)
|
||
{
|
||
public bool IsComplete => Issues.Count == 0;
|
||
}
|
||
|
||
public static class TranslationService
|
||
{
|
||
/// <summary>
|
||
/// Собирает шаблоны из каталога, сохраняя уже введённые переводы.
|
||
/// </summary>
|
||
public static TranslationFile Build(CardCatalog catalog, TranslationFile? existing)
|
||
{
|
||
var previous = existing?.Cards.ToDictionary(c => c.NameId, StringComparer.Ordinal)
|
||
?? new Dictionary<string, TranslationEntry>(StringComparer.Ordinal);
|
||
|
||
var cards = catalog.Cards
|
||
.OrderBy(c => c.NameId, StringComparer.Ordinal)
|
||
.Select(card =>
|
||
{
|
||
previous.TryGetValue(card.NameId, out var old);
|
||
return new TranslationEntry
|
||
{
|
||
NameId = card.NameId,
|
||
NameEn = card.NameEn,
|
||
Template = CardText.CardTextTokenizer.ToTemplate(card.TextEn),
|
||
TextRu = old?.TextRu ?? string.Empty,
|
||
};
|
||
})
|
||
.ToList();
|
||
|
||
return new TranslationFile { Version = 1, Cards = cards };
|
||
}
|
||
|
||
public static TranslationReport Check(TranslationFile file, CardText.KeywordCatalog? keywords = null)
|
||
{
|
||
var issues = new List<TranslationIssue>();
|
||
var translated = 0;
|
||
var unknown = new HashSet<string>(StringComparer.Ordinal);
|
||
|
||
foreach (var card in file.Cards)
|
||
{
|
||
var hasText = string.IsNullOrWhiteSpace(card.Template) || !string.IsNullOrWhiteSpace(card.TextRu);
|
||
|
||
if (hasText)
|
||
translated++;
|
||
else
|
||
issues.Add(new TranslationIssue(card.NameId, "text.missing", "Не заполнено textRu."));
|
||
|
||
var source = CardText.CardTextTokenizer.ExtractTokens(card.Template);
|
||
var target = CardText.CardTextTokenizer.ExtractTokens(card.TextRu);
|
||
|
||
if (!string.IsNullOrWhiteSpace(card.TextRu))
|
||
{
|
||
foreach (var (token, count) in source)
|
||
{
|
||
var actual = target.GetValueOrDefault(token);
|
||
if (actual != count)
|
||
{
|
||
issues.Add(new TranslationIssue(
|
||
card.NameId,
|
||
"token.mismatch",
|
||
$"Токен {{{token}}}: в шаблоне {count}, в переводе {actual}."));
|
||
}
|
||
}
|
||
|
||
foreach (var (token, count) in target)
|
||
{
|
||
if (source.ContainsKey(token))
|
||
continue;
|
||
|
||
issues.Add(new TranslationIssue(
|
||
card.NameId,
|
||
"token.extra",
|
||
$"В переводе лишний токен {{{token}}} ({count})."));
|
||
}
|
||
}
|
||
|
||
if (keywords is not null)
|
||
{
|
||
var tokens = CardText.CardTextTokenizer.ExtractTokens(card.Template).Keys
|
||
.Concat(CardText.CardTextTokenizer.ExtractTokens(card.TextRu).Keys);
|
||
|
||
foreach (var token in tokens)
|
||
{
|
||
var parts = token.Split(':', 2);
|
||
var name = parts[0];
|
||
var value = parts.Length > 1 ? parts[1] : null;
|
||
|
||
if (!keywords.ContainsToken(name, value) && unknown.Add(token))
|
||
{
|
||
issues.Add(new TranslationIssue(
|
||
card.NameId,
|
||
"token.unknown",
|
||
$"Токен {{{token}}} отсутствует в content/keywords.yaml."));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return new TranslationReport(file.Cards.Count, translated, issues);
|
||
}
|
||
}
|