VanguardReMaster/server/src/Vanguard.Content/Translations/TranslationService.cs

201 lines
7.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}