supervisor-simulator/scripts/Data/Import/CsvImporter.cs

627 lines
14 KiB
C#

using Godot;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
public class CsvImportResult
{
public int CreatedCount { get; set; }
public int SkippedCount { get; set; }
public int ErrorCount => Errors.Count;
public List<string> Errors { get; } = new();
public void AddError(string message)
{
Errors.Add(message);
}
}
public static class CsvImporter
{
private readonly struct CsvPropertyInfo
{
public readonly string Name;
public readonly Variant.Type Type;
public readonly PropertyHint Hint;
public readonly string HintString;
public CsvPropertyInfo(string name, Variant.Type type, PropertyHint hint, string hintString)
{
Name = name;
Type = type;
Hint = hint;
HintString = hintString;
}
}
public static CsvImportResult Import(CsvImportConfig config)
{
var result = new CsvImportResult();
if (config == null)
{
result.AddError("CSV importer: config is null.");
return result;
}
if (string.IsNullOrWhiteSpace(config.CsvPath))
{
result.AddError("CSV importer: CsvPath is empty.");
return result;
}
if (config.ResourceTemplate == null)
{
result.AddError("CSV importer: ResourceTemplate is not set.");
return result;
}
if (!config.CsvPath.StartsWith("res://", StringComparison.OrdinalIgnoreCase))
{
result.AddError($"CSV importer: CsvPath must use res:// (got {config.CsvPath}).");
return result;
}
if (!FileAccess.FileExists(config.CsvPath))
{
result.AddError($"CSV importer: CsvPath not found: {config.CsvPath}");
return result;
}
if (string.IsNullOrWhiteSpace(config.OutputDir))
{
result.AddError("CSV importer: OutputDir is empty.");
return result;
}
if (!config.OutputDir.StartsWith("res://", StringComparison.OrdinalIgnoreCase))
{
result.AddError($"CSV importer: OutputDir must use res:// (got {config.OutputDir}).");
return result;
}
if (!EnsureOutputDir(config.OutputDir, result))
{
return result;
}
var csvText = FileAccess.GetFileAsString(config.CsvPath);
var rows = ParseCsv(csvText);
if (rows.Count == 0)
{
result.AddError("CSV importer: no rows found.");
return result;
}
var headers = rows[0];
if (headers.Length == 0)
{
result.AddError("CSV importer: header row is empty.");
return result;
}
var headerMap = BuildHeaderMap(headers);
if (headerMap.Count == 0)
{
result.AddError("CSV importer: header row has no usable columns.");
return result;
}
var idKey = Normalize(config.IdColumn);
if (!headerMap.TryGetValue(idKey, out var idIndex))
{
result.AddError($"CSV importer: id column \"{config.IdColumn}\" not found.");
return result;
}
var indexIds = new Godot.Collections.Array<StringName>();
var indexPaths = new Godot.Collections.Array<string>();
var outputDir = config.OutputDir.TrimEnd('/');
var template = config.ResourceTemplate;
var propertyMap = BuildPropertyMap(template);
for (var rowIndex = 1; rowIndex < rows.Count; rowIndex++)
{
var row = rows[rowIndex];
if (IsRowEmpty(row) || IsRowComment(row))
{
result.SkippedCount++;
continue;
}
var idValue = GetCell(row, idIndex);
if (string.IsNullOrWhiteSpace(idValue))
{
result.AddError($"CSV importer: row {rowIndex + 1} is missing id.");
result.SkippedCount++;
continue;
}
var resource = DuplicateTemplate(template, result);
if (resource == null)
{
return result;
}
for (var colIndex = 0; colIndex < headers.Length; colIndex++)
{
var header = headers[colIndex];
if (string.IsNullOrWhiteSpace(header))
{
continue;
}
var cell = GetCell(row, colIndex);
if (string.IsNullOrWhiteSpace(cell))
{
continue;
}
var normalizedHeader = Normalize(header);
if (!propertyMap.TryGetValue(normalizedHeader, out var prop))
{
continue;
}
if (!TryParseCell(cell, prop, config.ArrayDelimiter, out var value))
{
result.AddError($"CSV importer: row {rowIndex + 1} column \"{header}\" has invalid value \"{cell}\".");
continue;
}
resource.Set(prop.Name, CreateVariant(value));
}
var fileName = MakeSafeFileName(idValue);
if (string.IsNullOrWhiteSpace(fileName))
{
result.AddError($"CSV importer: row {rowIndex + 1} has invalid id \"{idValue}\".");
result.SkippedCount++;
continue;
}
var outputPath = $"{outputDir}/{fileName}.tres";
var saveError = ResourceSaver.Save(resource, outputPath);
if (saveError != Error.Ok)
{
result.AddError($"CSV importer: failed to save {outputPath} ({saveError}).");
result.SkippedCount++;
continue;
}
indexIds.Add(new StringName(idValue));
indexPaths.Add(outputPath);
result.CreatedCount++;
}
if (!string.IsNullOrWhiteSpace(config.IndexOutputPath))
{
SaveIndex(config.IndexOutputPath, indexIds, indexPaths, result);
}
return result;
}
private static bool EnsureOutputDir(string outputDir, CsvImportResult result)
{
var globalDir = ProjectSettings.GlobalizePath(outputDir);
var dirError = DirAccess.MakeDirRecursiveAbsolute(globalDir);
if (dirError != Error.Ok && dirError != Error.AlreadyExists)
{
result.AddError($"CSV importer: failed to create output dir {outputDir} ({dirError}).");
return false;
}
return true;
}
private static Resource DuplicateTemplate(Resource template, CsvImportResult result)
{
if (template == null)
{
result.AddError("CSV importer: ResourceTemplate is null.");
return null;
}
var duplicated = template.Duplicate(true);
if (duplicated is not Resource resource)
{
result.AddError("CSV importer: ResourceTemplate duplicate is not a Resource.");
return null;
}
return resource;
}
private static Variant CreateVariant(object value)
{
return value switch
{
string s => Variant.CreateFrom(s),
StringName s => Variant.CreateFrom(s),
int i => Variant.CreateFrom(i),
float f => Variant.CreateFrom(f),
double d => Variant.CreateFrom(d),
bool b => Variant.CreateFrom(b),
Godot.Collections.Array a => Variant.CreateFrom(a),
Godot.Collections.Dictionary d => Variant.CreateFrom(d),
GodotObject o => Variant.CreateFrom(o),
_ => Variant.CreateFrom(value.ToString())
};
}
private static Dictionary<string, CsvPropertyInfo> BuildPropertyMap(Resource resource)
{
var map = new Dictionary<string, CsvPropertyInfo>();
var properties = resource.GetPropertyList();
foreach (var entry in properties)
{
if (entry is not Godot.Collections.Dictionary dict)
{
continue;
}
var usage = (PropertyUsageFlags)(int)dict["usage"];
if ((usage & PropertyUsageFlags.Storage) == 0)
{
continue;
}
var name = GetDictString(dict, "name");
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
var type = (Variant.Type)(int)dict["type"];
var hint = (PropertyHint)(int)dict["hint"];
var hintString = GetDictString(dict, "hint_string");
map[Normalize(name)] = new CsvPropertyInfo(name, type, hint, hintString);
}
return map;
}
private static Dictionary<string, int> BuildHeaderMap(string[] headers)
{
var map = new Dictionary<string, int>();
for (var i = 0; i < headers.Length; i++)
{
var normalized = Normalize(headers[i]);
if (string.IsNullOrWhiteSpace(normalized))
{
continue;
}
if (!map.ContainsKey(normalized))
{
map[normalized] = i;
}
}
return map;
}
private static string GetCell(string[] row, int index)
{
if (row == null || index < 0 || index >= row.Length)
{
return string.Empty;
}
return row[index].Trim();
}
private static bool IsRowEmpty(string[] row)
{
if (row == null || row.Length == 0)
{
return true;
}
foreach (var cell in row)
{
if (!string.IsNullOrWhiteSpace(cell))
{
return false;
}
}
return true;
}
private static bool IsRowComment(string[] row)
{
if (row == null || row.Length == 0)
{
return false;
}
var first = row[0].Trim();
return first.StartsWith("#", StringComparison.Ordinal);
}
private static bool TryParseCell(string raw, CsvPropertyInfo prop, string arrayDelimiter, out object value)
{
switch (prop.Type)
{
case Variant.Type.String:
value = raw;
return true;
case Variant.Type.StringName:
value = new StringName(raw);
return true;
case Variant.Type.Int:
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
{
value = intValue;
return true;
}
break;
case Variant.Type.Float:
if (float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var floatValue))
{
value = floatValue;
return true;
}
break;
case Variant.Type.Bool:
if (TryParseBool(raw, out var boolValue))
{
value = boolValue;
return true;
}
break;
case Variant.Type.Array:
value = ParseArray(raw, prop, arrayDelimiter);
return true;
}
value = null;
return false;
}
private static bool TryParseBool(string raw, out bool value)
{
switch (raw.Trim().ToLowerInvariant())
{
case "true":
case "1":
case "yes":
case "y":
value = true;
return true;
case "false":
case "0":
case "no":
case "n":
value = false;
return true;
default:
value = false;
return false;
}
}
private static object ParseArray(string raw, CsvPropertyInfo prop, string arrayDelimiter)
{
var items = raw.Split(arrayDelimiter, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var elementType = GetArrayElementType(prop);
switch (elementType)
{
case Variant.Type.StringName:
var stringNameArray = new Godot.Collections.Array<StringName>();
foreach (var item in items)
{
stringNameArray.Add(new StringName(item));
}
return stringNameArray;
case Variant.Type.Int:
var intArray = new Godot.Collections.Array<int>();
foreach (var item in items)
{
if (int.TryParse(item, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
{
intArray.Add(intValue);
}
}
return intArray;
case Variant.Type.Float:
var floatArray = new Godot.Collections.Array<float>();
foreach (var item in items)
{
if (float.TryParse(item, NumberStyles.Float, CultureInfo.InvariantCulture, out var floatValue))
{
floatArray.Add(floatValue);
}
}
return floatArray;
default:
var stringArray = new Godot.Collections.Array<string>();
foreach (var item in items)
{
stringArray.Add(item);
}
return stringArray;
}
}
private static Variant.Type GetArrayElementType(CsvPropertyInfo prop)
{
if (prop.Hint != PropertyHint.ArrayType || string.IsNullOrWhiteSpace(prop.HintString))
{
return Variant.Type.String;
}
switch (prop.HintString.Trim())
{
case "StringName":
return Variant.Type.StringName;
case "String":
return Variant.Type.String;
case "int":
case "Int":
return Variant.Type.Int;
case "float":
case "Float":
case "double":
case "Double":
return Variant.Type.Float;
case "bool":
case "Bool":
return Variant.Type.Bool;
default:
return Variant.Type.String;
}
}
private static void SaveIndex(string indexPath, Godot.Collections.Array<StringName> ids, Godot.Collections.Array<string> paths, CsvImportResult result)
{
if (string.IsNullOrWhiteSpace(indexPath))
{
return;
}
if (!indexPath.StartsWith("res://", StringComparison.OrdinalIgnoreCase))
{
result.AddError($"CSV importer: IndexOutputPath must use res:// (got {indexPath}).");
return;
}
var index = new CsvIndex
{
Ids = ids,
Paths = paths
};
var saveError = ResourceSaver.Save(index, indexPath);
if (saveError != Error.Ok)
{
result.AddError($"CSV importer: failed to save index {indexPath} ({saveError}).");
}
}
private static List<string[]> ParseCsv(string text)
{
var rows = new List<string[]>();
var row = new List<string>();
var cell = new StringBuilder();
var inQuotes = false;
for (var i = 0; i < text.Length; i++)
{
var c = text[i];
if (inQuotes)
{
if (c == '"')
{
if (i + 1 < text.Length && text[i + 1] == '"')
{
cell.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
cell.Append(c);
}
}
else
{
switch (c)
{
case '"':
inQuotes = true;
break;
case ',':
row.Add(cell.ToString());
cell.Clear();
break;
case '\n':
row.Add(cell.ToString());
cell.Clear();
rows.Add(row.ToArray());
row.Clear();
break;
case '\r':
break;
default:
cell.Append(c);
break;
}
}
}
if (cell.Length > 0 || row.Count > 0)
{
row.Add(cell.ToString());
rows.Add(row.ToArray());
}
return rows;
}
private static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim().TrimStart('\uFEFF');
var sb = new StringBuilder(trimmed.Length);
foreach (var c in trimmed)
{
if (c == '_' || c == '-' || c == ' ')
{
continue;
}
sb.Append(char.ToLowerInvariant(c));
}
return sb.ToString();
}
private static string MakeSafeFileName(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var sb = new StringBuilder(value.Length);
foreach (var c in value.Trim())
{
if (c == ' ' || c == '-' || c == '.')
{
sb.Append('_');
continue;
}
if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|')
{
continue;
}
sb.Append(char.ToLowerInvariant(c));
}
return sb.ToString();
}
private static string GetDictString(Godot.Collections.Dictionary dict, string key)
{
if (!dict.ContainsKey(key))
{
return string.Empty;
}
return dict[key].AsString();
}
}