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 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(); var indexPaths = new Godot.Collections.Array(); 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 BuildPropertyMap(Resource resource) { var map = new Dictionary(); 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 BuildHeaderMap(string[] headers) { var map = new Dictionary(); 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(); foreach (var item in items) { stringNameArray.Add(new StringName(item)); } return stringNameArray; case Variant.Type.Int: var intArray = new Godot.Collections.Array(); 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(); 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(); 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 ids, Godot.Collections.Array 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 ParseCsv(string text) { var rows = new List(); var row = new List(); 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(); } }