Files
OpenRA/OpenRA.Mods.Common/UtilityCommands/ExtractYamlStrings.cs
2024-11-03 16:52:47 +02:00

348 lines
10 KiB
C#

#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* This file is part of OpenRA, which is free software. It is made
* available to you under the terms of the GNU General Public License
* as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version. For more
* information, see COPYING.
*/
#endregion
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
using OpenRA.FileSystem;
using OpenRA.Mods.Common.Traits;
using OpenRA.Mods.Common.UpdateRules;
using OpenRA.Traits;
namespace OpenRA.Mods.Common.UtilityCommands
{
using YamlFileSet = List<(IReadWritePackage Package, string File, List<MiniYamlNodeBuilder> Nodes)>;
sealed class ExtractYamlStringsCommand : IUtilityCommand
{
string IUtilityCommand.Name { get { return "--extract-yaml-strings"; } }
bool IUtilityCommand.ValidateArguments(string[] args)
{
return true;
}
[Desc("Extract fluent strings that are not yet localized.")]
void IUtilityCommand.Run(Utility utility, string[] args)
{
// HACK: The engine code assumes that Game.modData is set.
var modData = Game.ModData = utility.ModData;
var traitInfos = modData.ObjectCreator.GetTypes()
.Where(t => t.Name.EndsWith("Info", StringComparison.InvariantCulture) && t.IsSubclassOf(typeof(TraitInfo)))
.ToDictionary(
t => t.Name[..^4],
t => Utility.GetFields(t).Where(Utility.HasAttribute<FluentReferenceAttribute>).Select(f => f.Name).ToArray())
.Where(t => t.Value.Length > 0)
.ToDictionary(t => t.Key, t => t.Value);
var modRules = UpdateUtils.LoadModYaml(modData, UpdateUtils.FilterExternalFiles(modData, modData.Manifest.Rules, new HashSet<string>()));
// Include files referenced in maps.
foreach (var package in modData.MapCache.EnumerateMapPackagesWithoutCaching())
{
using (var mapStream = package.GetStream("map.yaml"))
{
if (mapStream == null)
continue;
var yaml = new MiniYamlBuilder(null, MiniYaml.FromStream(mapStream, $"{package.Name}:map.yaml", false));
var mapRulesNode = yaml.NodeWithKeyOrDefault("Rules");
if (mapRulesNode != null)
modRules.AddRange(UpdateUtils.LoadExternalMapYaml(modData, mapRulesNode.Value, new HashSet<string>()));
}
}
var fluentPackage = modData.ModFiles.OpenPackage(modData.Manifest.Id + "|languages");
ExtractFromFile(Path.Combine(fluentPackage.Name, "rules.ftl"), modRules, traitInfos);
modRules.Save();
// Extract from maps.
foreach (var package in modData.MapCache.EnumerateMapPackagesWithoutCaching())
{
using (var mapStream = package.GetStream("map.yaml"))
{
if (mapStream == null)
continue;
var yaml = new MiniYamlBuilder(null, MiniYaml.FromStream(mapStream, $"{package.Name}:map.yaml", false));
var mapRules = new YamlFileSet() { (package, "map.yaml", yaml.Nodes) };
var mapRulesNode = yaml.NodeWithKeyOrDefault("Rules");
if (mapRulesNode != null)
mapRules.AddRange(UpdateUtils.LoadInternalMapYaml(modData, package, mapRulesNode.Value, new HashSet<string>()));
const string Mapftl = "map.ftl";
ExtractFromFile(Path.Combine(package.Name, Mapftl), mapRules, traitInfos, () =>
{
var node = yaml.NodeWithKeyOrDefault("FluentMessages");
if (node != null)
{
var value = node.NodeValue<string[]>();
if (!value.Contains(Mapftl))
node.Value.Value = string.Join(", ", value.Concat(new string[] { Mapftl }).ToArray());
}
else
yaml.Nodes.Add(new MiniYamlNodeBuilder("FluentMessages", Mapftl));
});
mapRules.Save();
}
}
}
static void ExtractFromFile(string fluentPath, YamlFileSet yamlSet, Dictionary<string, string[]> traitInfos, Action addAction = null)
{
var unsortedCandidates = new List<ExtractionCandidate>();
var groupedCandidates = new Dictionary<HashSet<string>, List<ExtractionCandidate>>();
// Get all string candidates.
foreach (var (_, file, actors) in yamlSet)
{
var candidates = new List<ExtractionCandidate>();
foreach (var actor in actors)
if (actor.Key != null)
ExtractFromActor(actor, traitInfos, ref candidates);
if (candidates.Count > 0)
{
var ruleFilename = file.Split('/').Last();
groupedCandidates[new HashSet<string>() { ruleFilename }] = new List<ExtractionCandidate>();
for (var i = 0; i < candidates.Count; i++)
{
var candidate = candidates[i];
candidate.Filename = ruleFilename;
unsortedCandidates.Add(candidate);
}
}
}
if (unsortedCandidates.Count == 0)
return;
// Join matching candidates.
foreach (var candidate in unsortedCandidates)
{
HashSet<string> foundHash = null;
ExtractionCandidate found = default;
foreach (var (hash, candidates) in groupedCandidates)
{
foreach (var c in candidates)
{
if (c.Actor == candidate.Actor && c.Key == candidate.Key && c.Value == candidate.Value)
{
foundHash = hash;
found = c;
break;
}
}
if (foundHash != null)
break;
}
if (foundHash == null)
{
var hash = groupedCandidates.Keys.First(t => t.First() == candidate.Filename);
groupedCandidates[hash].Add(candidate);
continue;
}
var newHash = foundHash.Append(candidate.Filename).ToHashSet();
candidate.Nodes.AddRange(found.Nodes);
groupedCandidates[foundHash].Remove(found);
var nHash = groupedCandidates.FirstOrDefault(t => t.Key.SetEquals(newHash));
if (nHash.Key != null)
groupedCandidates[nHash.Key].Add(candidate);
else
groupedCandidates[newHash] = new List<ExtractionCandidate>() { candidate };
}
addAction?.Invoke();
// StreamWriter can't create new directories.
var startWithNewline = File.Exists(fluentPath);
if (!startWithNewline)
Directory.CreateDirectory(Path.GetDirectoryName(fluentPath));
// Write output .ftl files.
using (var fluentWriter = new StreamWriter(fluentPath, true))
{
foreach (var (filename, candidates) in groupedCandidates.OrderBy(t => string.Join(',', t.Key)))
{
if (candidates.Count == 0)
continue;
if (startWithNewline)
fluentWriter.WriteLine();
else
startWithNewline = true;
fluentWriter.WriteLine("## " + string.Join(", ", filename));
// Pushing blocks to string first allows for fancier formatting.
var build = "";
foreach (var grouping in candidates.GroupBy(t => t.Actor))
{
if (grouping.Count() == 1)
{
var candidate = grouping.First();
var key = $"{candidate.Actor}-{candidate.Key}";
build += $"{key} = {candidate.Value}\n";
foreach (var node in candidate.Nodes)
node.Value.Value = key;
}
else
{
if (build.Length > 1 && build.Substring(build.Length - 2, 2) != "\n\n")
build += "\n";
var key = grouping.Key;
build += $"{key} =\n";
foreach (var candidate in grouping)
{
var type = candidate.Key;
build += $" .{type} = {candidate.Value}\n";
foreach (var node in candidate.Nodes)
node.Value.Value = $"{key}.{type}";
}
build += "\n";
}
}
fluentWriter.WriteLine(build.Trim('\n'));
}
}
}
struct ExtractionCandidate
{
public string Filename;
public readonly string Actor;
public readonly string Key;
public readonly string Value;
public readonly List<MiniYamlNodeBuilder> Nodes;
public ExtractionCandidate(string actor, string key, string value, MiniYamlNodeBuilder node)
{
Filename = null;
Actor = actor;
Key = key;
Value = value;
Nodes = new List<MiniYamlNodeBuilder>() { node };
}
}
static string ToLowerActor(string actor)
{
var s = actor.Replace('.', '-').Replace('_', '-').ToLowerInvariant();
if (actor[0] == '^')
return $"meta-{s[1..]}";
else
return $"actor-{s}";
}
static string ToLower(string value)
{
if (string.IsNullOrEmpty(value))
return "";
var s = new StringBuilder();
for (var i = 0; i < value.Length; i++)
{
var c = value[i];
if (char.IsUpper(c))
{
if (i > 0)
s.Append('-');
s.Append(char.ToLowerInvariant(c));
}
else
s.Append(c);
}
return s.ToString();
}
static void ExtractFromActor(MiniYamlNodeBuilder actor, Dictionary<string, string[]> traitInfos, ref List<ExtractionCandidate> candidates)
{
if (actor.Value?.Nodes == null)
return;
foreach (var trait in actor.Value.Nodes)
{
if (trait.Key == null)
continue;
var traitSplit = trait.Key.Split('@');
var traitInfo = traitSplit[0];
if (!traitInfos.TryGetValue(traitInfo, out var type) || trait.Value?.Nodes == null)
continue;
foreach (var property in trait.Value.Nodes)
{
if (property.Key == null)
continue;
var propertyType = property.Key.Split('@')[0];
if (!type.Contains(propertyType))
continue;
var propertyValue = property.Value.Value;
if (string.IsNullOrEmpty(propertyValue) || UpdateUtils.IsAlreadyExtracted(propertyValue) || !propertyValue.Any(char.IsLetterOrDigit))
continue;
var value = propertyValue
.Replace("\\n", "\n ")
.Trim().Trim('\n');
var actorName = ToLowerActor(actor.Key);
var key = traitInfo;
if (traitInfo == nameof(Buildable))
{
candidates.Add(new ExtractionCandidate(actorName, ToLower(propertyType), value, property));
continue;
}
else if (traitInfo == nameof(Encyclopedia))
{
candidates.Add(new ExtractionCandidate(actorName, ToLower(traitInfo), value, property));
continue;
}
else if (traitInfo == nameof(Tooltip) || traitInfo == nameof(EditorOnlyTooltipInfo)[..^4])
{
if (traitSplit.Length > 1)
key = $"{traitSplit[1].ToLowerInvariant()}-{propertyType}";
else
key = propertyType;
candidates.Add(new ExtractionCandidate(actorName, ToLower(key), value, property));
continue;
}
if (traitSplit.Length > 1)
key += $"-{traitSplit[1]}";
key += $"-{ToLower(propertyType)}";
candidates.Add(new ExtractionCandidate(actorName, key.ToLowerInvariant(), value, property));
}
}
}
}
}