253 lines
11 KiB
C#
253 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.Encodings.Web;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace Cthangover.Tools.EntityEditor
|
|
{
|
|
/// <summary>
|
|
/// The entities of one kind (<c>"Kind": "item"</c>, <c>"character"</c>…) in one
|
|
/// mod, as an editor changes them, free of any UI.
|
|
/// <para/>
|
|
/// Files are kept as JSON trees, not as typed DTOs: a form changes only the
|
|
/// properties it knows, and everything else — fields of other mods' C#
|
|
/// (<c>heal</c>, <c>verb</c>, <c>Charset</c>), loot tables, the property
|
|
/// order — survives a save untouched. A file is written only when its text
|
|
/// differs from what was loaded or last saved, so opening and saving does not
|
|
/// reformat files nobody changed. New entities go to <see cref="NewFile"/>,
|
|
/// created on the first save when the mod has no such file.
|
|
/// <para/>
|
|
/// Localized texts are part of the document too (<see cref="SetText"/>): the
|
|
/// form shows a name in every language of the mod, and an undo step restores
|
|
/// texts and data together. Undo works on whole states, as in the map editor.
|
|
/// </summary>
|
|
public class EntityDocument
|
|
{
|
|
private const int HistoryLimit = 200;
|
|
|
|
private static readonly JsonSerializerOptions WriteOptions = new()
|
|
{
|
|
WriteIndented = true,
|
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
};
|
|
|
|
private readonly Dictionary<string, JsonObject> files = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, string> savedText = new(StringComparer.Ordinal);
|
|
private readonly List<EntityRecord> records = new();
|
|
private readonly List<string> undo = new();
|
|
private readonly List<string> redo = new();
|
|
private Dictionary<string, string> texts = new(StringComparer.Ordinal);
|
|
private Dictionary<string, string> savedTexts = new(StringComparer.Ordinal);
|
|
|
|
/// <summary>Entity kind of the document.</summary>
|
|
public string Kind { get; }
|
|
|
|
/// <summary>File new entities are added to.</summary>
|
|
public string NewFile { get; }
|
|
|
|
/// <summary>The entities in file order (files sorted by path).</summary>
|
|
public IReadOnlyList<EntityRecord> Records => records;
|
|
|
|
/// <summary>True when there is a step to undo.</summary>
|
|
public bool CanUndo => undo.Count > 0;
|
|
|
|
/// <summary>True when there is a step to redo.</summary>
|
|
public bool CanRedo => redo.Count > 0;
|
|
|
|
private EntityDocument(string kind, string newFile)
|
|
{
|
|
Kind = kind;
|
|
NewFile = newFile;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the document from the mod's JSON files (path, text). Files of
|
|
/// another kind, without a kind or with broken JSON are skipped; their
|
|
/// paths are returned in <paramref name="skipped"/> only when they declare
|
|
/// this kind but cannot be read, so the window can name them.
|
|
/// </summary>
|
|
public static EntityDocument Load(string kind, string newFile, IEnumerable<(string Path, string Text)> sources, out List<string> skipped)
|
|
{
|
|
var document = new EntityDocument(kind, newFile);
|
|
skipped = new List<string>();
|
|
foreach (var (path, text) in sources.OrderBy(s => s.Path, StringComparer.Ordinal))
|
|
{
|
|
JsonObject root;
|
|
try
|
|
{
|
|
root = JsonNode.Parse(text ?? string.Empty) as JsonObject;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
if (text != null && text.Contains($"\"{kind}\""))
|
|
skipped.Add(path);
|
|
continue;
|
|
}
|
|
if (root == null || !string.Equals(root["Kind"]?.ToString(), kind, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
if (root["Items"] is not JsonArray)
|
|
root["Items"] = new JsonArray();
|
|
document.files[path] = root;
|
|
document.savedText[path] = Serialize(root);
|
|
}
|
|
document.Rebuild();
|
|
return document;
|
|
}
|
|
|
|
/// <summary>Paths of the document's files.</summary>
|
|
public IEnumerable<string> Files => files.Keys;
|
|
|
|
/// <summary>Adds an entity to <see cref="NewFile"/> (the file is created when missing) and returns it.</summary>
|
|
public EntityRecord Add(JsonObject entity)
|
|
{
|
|
if (!files.TryGetValue(NewFile, out var root))
|
|
{
|
|
root = new JsonObject { ["Kind"] = Kind, ["Items"] = new JsonArray() };
|
|
files[NewFile] = root;
|
|
}
|
|
((JsonArray)root["Items"]).Add(entity);
|
|
Rebuild();
|
|
return records.First(r => r.Data == entity);
|
|
}
|
|
|
|
/// <summary>A copy of <paramref name="record"/> with id <paramref name="newId"/>, placed right after it in its file.</summary>
|
|
public EntityRecord Duplicate(EntityRecord record, string newId)
|
|
{
|
|
var copy = (JsonObject)record.Data.DeepClone();
|
|
copy["Id"] = newId;
|
|
var items = (JsonArray)files[record.File]["Items"];
|
|
items.Insert(items.IndexOf(record.Data) + 1, copy);
|
|
Rebuild();
|
|
return records.First(r => r.Data == copy);
|
|
}
|
|
|
|
/// <summary>Removes an entity from its file (the file stays, maybe with no items).</summary>
|
|
public void Remove(EntityRecord record)
|
|
{
|
|
((JsonArray)files[record.File]["Items"]).Remove(record.Data);
|
|
Rebuild();
|
|
}
|
|
|
|
/// <summary><paramref name="wanted"/> when no entity has it, else <c>wanted_2</c>, <c>wanted_3</c>…</summary>
|
|
public string FreeId(string wanted)
|
|
{
|
|
var id = wanted;
|
|
for (var number = 2; records.Any(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); number++)
|
|
id = wanted + "_" + number;
|
|
return id;
|
|
}
|
|
|
|
/// <summary>The record with this id, or null.</summary>
|
|
public EntityRecord Find(string id) => records.FirstOrDefault(r => r.Id == id);
|
|
|
|
/// <summary>A localized text the document holds (see <see cref="SetText"/>), or null when it was never set or loaded.</summary>
|
|
public string GetText(string language, string key)
|
|
=> texts.TryGetValue(TextKey(language, key), out var value) ? value : null;
|
|
|
|
/// <summary>Remembers the text the mod's files have now (loading, not an edit).</summary>
|
|
public void LoadText(string language, string key, string value)
|
|
{
|
|
var textKey = TextKey(language, key);
|
|
texts[textKey] = value;
|
|
savedTexts[textKey] = value;
|
|
}
|
|
|
|
/// <summary>Changes a localized text; it is written on save.</summary>
|
|
public void SetText(string language, string key, string value) => texts[TextKey(language, key)] = value ?? string.Empty;
|
|
|
|
/// <summary>Remembers the current state as one undo step and forgets the redo branch.</summary>
|
|
public void BeginChange()
|
|
{
|
|
undo.Add(Snapshot());
|
|
if (undo.Count > HistoryLimit)
|
|
undo.RemoveAt(0);
|
|
redo.Clear();
|
|
}
|
|
|
|
/// <summary>Steps back; false when there is nothing to undo.</summary>
|
|
public bool Undo() => Swap(undo, redo);
|
|
|
|
/// <summary>Steps forward again; false when there is nothing to redo.</summary>
|
|
public bool Redo() => Swap(redo, undo);
|
|
|
|
/// <summary>Files whose text changed since loading or the last <see cref="MarkSaved"/>.</summary>
|
|
public List<(string Path, string Text)> ChangedFiles()
|
|
=> files.Select(f => (f.Key, Serialize(f.Value)))
|
|
.Where(f => !savedText.TryGetValue(f.Key, out var saved) || saved != f.Item2)
|
|
.ToList();
|
|
|
|
/// <summary>Texts changed since loading or the last <see cref="MarkSaved"/>: language, key, value.</summary>
|
|
public List<(string Language, string Key, string Value)> ChangedTexts()
|
|
=> texts.Where(t => !savedTexts.TryGetValue(t.Key, out var saved) || saved != t.Value)
|
|
.Select(t => (t.Key.Substring(0, t.Key.IndexOf('\n')), t.Key.Substring(t.Key.IndexOf('\n') + 1), t.Value))
|
|
.ToList();
|
|
|
|
/// <summary>The current files and texts are what is on disk now.</summary>
|
|
public void MarkSaved()
|
|
{
|
|
foreach (var (path, text) in ChangedFiles())
|
|
savedText[path] = text;
|
|
savedTexts = new Dictionary<string, string>(texts, StringComparer.Ordinal);
|
|
}
|
|
|
|
/// <summary>Problems that would break the game's data, as locale keys with an argument; empty when it can be saved.</summary>
|
|
public List<(string Key, string Argument)> Validate()
|
|
{
|
|
var problems = new List<(string, string)>();
|
|
foreach (var record in records.Where(r => string.IsNullOrWhiteSpace(r.Id)))
|
|
problems.Add(("tools/entity_editor/problem_id", record.File));
|
|
foreach (var group in records.Where(r => !string.IsNullOrWhiteSpace(r.Id)).GroupBy(r => r.Id, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
if (group.Count() > 1)
|
|
problems.Add(("tools/entity_editor/problem_duplicate", group.Key));
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
/// <summary>A file's text as the document writes it: two-space indented, letters unescaped, LF line breaks (the writer would use CRLF on Windows), a final one.</summary>
|
|
public static string Serialize(JsonObject root) => root.ToJsonString(WriteOptions).Replace("\r\n", "\n") + "\n";
|
|
|
|
private static string TextKey(string language, string key) => language + "\n" + key;
|
|
|
|
private void Rebuild()
|
|
{
|
|
records.Clear();
|
|
foreach (var file in files.OrderBy(f => f.Key, StringComparer.Ordinal))
|
|
{
|
|
foreach (var item in (JsonArray)file.Value["Items"])
|
|
{
|
|
if (item is JsonObject entity)
|
|
records.Add(new EntityRecord(file.Key, entity));
|
|
}
|
|
}
|
|
}
|
|
|
|
private string Snapshot()
|
|
{
|
|
var state = new JsonObject
|
|
{
|
|
["files"] = new JsonObject(files.Select(f => new KeyValuePair<string, JsonNode>(f.Key, f.Value.DeepClone()))),
|
|
["texts"] = new JsonObject(texts.Select(t => new KeyValuePair<string, JsonNode>(t.Key, t.Value)))
|
|
};
|
|
return state.ToJsonString();
|
|
}
|
|
|
|
private bool Swap(List<string> from, List<string> to)
|
|
{
|
|
if (from.Count == 0)
|
|
return false;
|
|
to.Add(Snapshot());
|
|
var state = JsonNode.Parse(from[^1]).AsObject();
|
|
from.RemoveAt(from.Count - 1);
|
|
files.Clear();
|
|
foreach (var file in state["files"].AsObject())
|
|
files[file.Key] = file.Value.DeepClone().AsObject();
|
|
texts = state["texts"].AsObject().ToDictionary(t => t.Key, t => t.Value?.ToString() ?? string.Empty, StringComparer.Ordinal);
|
|
Rebuild();
|
|
return true;
|
|
}
|
|
}
|
|
}
|