diff --git a/addons/csv_resource_importer/CsvResourceImporter.cs b/addons/csv_resource_importer/CsvResourceImporter.cs new file mode 100644 index 0000000..4d4f073 --- /dev/null +++ b/addons/csv_resource_importer/CsvResourceImporter.cs @@ -0,0 +1,70 @@ +using Godot; + +[Tool] +public partial class CsvResourceImporter : EditorPlugin +{ + private const string MenuName = "Import CSV Resources..."; + private EditorFileDialog _configDialog; + + public override void _EnterTree() + { + AddToolMenuItem(MenuName, Callable.From(OnImportMenu)); + + _configDialog = new EditorFileDialog + { + Access = EditorFileDialog.AccessEnum.Resources, + FileMode = EditorFileDialog.FileModeEnum.OpenFile, + Title = "Select CsvImportConfig" + }; + _configDialog.AddFilter("*.tres,*.res ; Csv Import Config"); + _configDialog.FileSelected += OnConfigSelected; + EditorInterface.Singleton.GetBaseControl().AddChild(_configDialog); + } + + public override void _ExitTree() + { + RemoveToolMenuItem(MenuName); + if (_configDialog != null && IsInstanceValid(_configDialog)) + { + _configDialog.FileSelected -= OnConfigSelected; + _configDialog.QueueFree(); + } + } + + private void OnImportMenu() + { + if (_configDialog == null) + { + return; + } + + _configDialog.PopupCentered(new Vector2I(900, 600)); + } + + private void OnConfigSelected(string path) + { + var config = ResourceLoader.Load(path); + if (config == null) + { + GD.PushError($"CSV importer: failed to load config: {path}"); + return; + } + + var result = CsvImporter.Import(config); + if (result.ErrorCount > 0) + { + GD.PushWarning($"CSV importer: completed with {result.ErrorCount} errors, {result.CreatedCount} created, {result.SkippedCount} skipped."); + foreach (var error in result.Errors) + { + GD.PushWarning(error); + } + } + else + { + GD.Print($"CSV importer: created {result.CreatedCount} resources, skipped {result.SkippedCount}."); + } + + var fileSystem = EditorInterface.Singleton.GetResourceFilesystem(); + fileSystem?.Scan(); + } +} diff --git a/addons/csv_resource_importer/CsvResourceImporter.cs.uid b/addons/csv_resource_importer/CsvResourceImporter.cs.uid new file mode 100644 index 0000000..64593d1 --- /dev/null +++ b/addons/csv_resource_importer/CsvResourceImporter.cs.uid @@ -0,0 +1 @@ +uid://c60ywt2qwa2or diff --git a/addons/csv_resource_importer/plugin.cfg b/addons/csv_resource_importer/plugin.cfg new file mode 100644 index 0000000..effd0f1 --- /dev/null +++ b/addons/csv_resource_importer/plugin.cfg @@ -0,0 +1,6 @@ +[plugin] +name="CSV Resource Importer" +description="Import CSV data into Resource .tres files." +author="Codex" +version="1.0" +script="CsvResourceImporter.cs" diff --git a/scripts/Data/Import/CsvImportConfig.cs b/scripts/Data/Import/CsvImportConfig.cs new file mode 100644 index 0000000..462c8b4 --- /dev/null +++ b/scripts/Data/Import/CsvImportConfig.cs @@ -0,0 +1,23 @@ +using Godot; + +[GlobalClass] +public partial class CsvImportConfig : Resource +{ + [Export(PropertyHint.File, "*.csv")] + public string CsvPath { get; set; } = ""; + + [Export(PropertyHint.Dir)] + public string OutputDir { get; set; } = "res://resources/data"; + + [Export] + public Resource ResourceTemplate { get; set; } + + [Export] + public string IdColumn { get; set; } = "id"; + + [Export(PropertyHint.File, "*.tres,*.res")] + public string IndexOutputPath { get; set; } = ""; + + [Export] + public string ArrayDelimiter { get; set; } = ";"; +} diff --git a/scripts/Data/Import/CsvImportConfig.cs.uid b/scripts/Data/Import/CsvImportConfig.cs.uid new file mode 100644 index 0000000..4ab84a2 --- /dev/null +++ b/scripts/Data/Import/CsvImportConfig.cs.uid @@ -0,0 +1 @@ +uid://cmrnp25roceut diff --git a/scripts/Data/Import/CsvImporter.cs b/scripts/Data/Import/CsvImporter.cs new file mode 100644 index 0000000..db7e426 --- /dev/null +++ b/scripts/Data/Import/CsvImporter.cs @@ -0,0 +1,626 @@ +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(); + } +} diff --git a/scripts/Data/Import/CsvImporter.cs.uid b/scripts/Data/Import/CsvImporter.cs.uid new file mode 100644 index 0000000..85a9ea7 --- /dev/null +++ b/scripts/Data/Import/CsvImporter.cs.uid @@ -0,0 +1 @@ +uid://dx0nkkhc357c3 diff --git a/scripts/Data/Import/CsvIndex.cs b/scripts/Data/Import/CsvIndex.cs new file mode 100644 index 0000000..7573b9e --- /dev/null +++ b/scripts/Data/Import/CsvIndex.cs @@ -0,0 +1,11 @@ +using Godot; + +[GlobalClass] +public partial class CsvIndex : Resource +{ + [Export] + public Godot.Collections.Array Ids { get; set; } = new(); + + [Export] + public Godot.Collections.Array Paths { get; set; } = new(); +} diff --git a/scripts/Data/Import/CsvIndex.cs.uid b/scripts/Data/Import/CsvIndex.cs.uid new file mode 100644 index 0000000..d74fa78 --- /dev/null +++ b/scripts/Data/Import/CsvIndex.cs.uid @@ -0,0 +1 @@ +uid://hrwen2sp2api