627 lines
14 KiB
C#
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();
|
|
}
|
|
}
|