데이터파이프 라인에서 리스트 타입 지원
list:int, list:bool 등
This commit is contained in:
@@ -1,347 +1,206 @@
|
|||||||
// Assets/Editor/DataImporter/CSVToSOImporter.cs
|
|
||||||
|
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEditor;
|
using UnityEditor;
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Collections; // 추가: 리스트 처리를 위해 필요
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Northbound.Editor
|
namespace Northbound.Editor
|
||||||
{
|
{
|
||||||
public static class CSVToSOImporter
|
public static class CSVToSOImporter
|
||||||
{
|
{
|
||||||
private static readonly string GAMEDATA_PATH = Path.Combine(
|
private static readonly string GAMEDATA_PATH = Path.Combine(Application.dataPath, "..", "GameData");
|
||||||
Application.dataPath,
|
|
||||||
"..", "GameData"
|
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly string SO_BASE_PATH = "Assets/Data/ScriptableObjects";
|
private static readonly string SO_BASE_PATH = "Assets/Data/ScriptableObjects";
|
||||||
|
|
||||||
|
[MenuItem("Tools/Data/Import All CSV")] // 메뉴 추가 (편의성)
|
||||||
public static void ImportAll()
|
public static void ImportAll()
|
||||||
{
|
{
|
||||||
Debug.Log("=== 전체 데이터 Import 시작 ===");
|
Debug.Log("=== 전체 데이터 Import 시작 ===");
|
||||||
|
if (!Directory.Exists(GAMEDATA_PATH)) { Debug.LogError("GameData 폴더가 없습니다."); return; }
|
||||||
|
|
||||||
var csvFiles = Directory.GetFiles(GAMEDATA_PATH, "*.csv");
|
var csvFiles = Directory.GetFiles(GAMEDATA_PATH, "*.csv");
|
||||||
|
|
||||||
int totalSuccess = 0;
|
int totalSuccess = 0;
|
||||||
int totalFail = 0;
|
int totalFail = 0;
|
||||||
|
|
||||||
foreach (var csvPath in csvFiles)
|
foreach (var csvPath in csvFiles)
|
||||||
{
|
{
|
||||||
if (csvPath.Contains("Backups"))
|
if (csvPath.Contains("Backups")) continue;
|
||||||
continue;
|
|
||||||
|
|
||||||
string schemaName = Path.GetFileNameWithoutExtension(csvPath);
|
string schemaName = Path.GetFileNameWithoutExtension(csvPath);
|
||||||
|
if (schemaName.StartsWith(".")) continue;
|
||||||
|
|
||||||
if (schemaName.StartsWith("."))
|
if (ImportSchema(schemaName)) totalSuccess++;
|
||||||
continue;
|
else totalFail++;
|
||||||
|
|
||||||
Debug.Log($"\n📋 {schemaName} Import 시작...");
|
|
||||||
|
|
||||||
if (ImportSchema(schemaName))
|
|
||||||
{
|
|
||||||
totalSuccess++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
totalFail++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug.Log("\n" + new string('=', 60));
|
Debug.Log($"\n🎉 Import 완료: 성공 {totalSuccess}, 실패 {totalFail}");
|
||||||
Debug.Log($"🎉 전체 Import 완료: 성공 {totalSuccess}개, 실패 {totalFail}개");
|
|
||||||
Debug.Log(new string('=', 60));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public static bool ImportSchema(string schemaName)
|
public static bool ImportSchema(string schemaName)
|
||||||
{
|
{
|
||||||
string csvPath = Path.Combine(GAMEDATA_PATH, $"{schemaName}.csv");
|
string csvPath = Path.Combine(GAMEDATA_PATH, $"{schemaName}.csv");
|
||||||
string outputPath = Path.Combine(SO_BASE_PATH, schemaName);
|
string outputPath = Path.Combine(SO_BASE_PATH, schemaName);
|
||||||
|
|
||||||
if (!File.Exists(csvPath))
|
if (!File.Exists(csvPath)) return false;
|
||||||
{
|
if (!Directory.Exists(outputPath)) Directory.CreateDirectory(outputPath);
|
||||||
Debug.LogError($"❌ CSV 파일을 찾을 수 없습니다: {csvPath}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Directory.Exists(outputPath))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(outputPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
string className = schemaName + "Data";
|
string className = schemaName + "Data";
|
||||||
Type dataType = FindScriptableObjectType(className);
|
Type dataType = FindScriptableObjectType(className);
|
||||||
|
if (dataType == null) return false;
|
||||||
if (dataType == null)
|
|
||||||
{
|
|
||||||
Debug.LogError($"❌ ScriptableObject 클래스를 찾을 수 없습니다: {className}");
|
|
||||||
Debug.LogError($"💡 generate_csharp_classes.py를 먼저 실행하세요.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug.Log($" ✅ 클래스 발견: {dataType.FullName}");
|
|
||||||
|
|
||||||
int importCount = 0;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var lines = File.ReadAllLines(csvPath, System.Text.Encoding.UTF8);
|
var lines = File.ReadAllLines(csvPath, System.Text.Encoding.UTF8);
|
||||||
|
if (lines.Length < 2) return false;
|
||||||
if (lines.Length < 2)
|
|
||||||
{
|
|
||||||
Debug.LogError("❌ CSV 파일에 데이터가 없습니다.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var headers = ParseCSVLine(lines[0]);
|
var headers = ParseCSVLine(lines[0]);
|
||||||
|
|
||||||
Debug.Log($" 📊 {lines.Length - 1}개 행, {headers.Length}개 컬럼");
|
|
||||||
|
|
||||||
for (int lineIndex = 1; lineIndex < lines.Length; lineIndex++)
|
for (int lineIndex = 1; lineIndex < lines.Length; lineIndex++)
|
||||||
{
|
{
|
||||||
string line = lines[lineIndex].Trim();
|
string line = lines[lineIndex].Trim();
|
||||||
|
if (string.IsNullOrEmpty(line)) continue;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(line))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var values = ParseCSVLine(line);
|
var values = ParseCSVLine(line);
|
||||||
|
|
||||||
if (values.Length != headers.Length)
|
|
||||||
{
|
|
||||||
Debug.LogWarning($" ⚠️ 행 {lineIndex + 1}: 컬럼 개수 불일치");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var so = ScriptableObject.CreateInstance(dataType);
|
var so = ScriptableObject.CreateInstance(dataType);
|
||||||
|
|
||||||
for (int col = 0; col < headers.Length; col++)
|
for (int col = 0; col < headers.Length; col++)
|
||||||
{
|
{
|
||||||
string fieldName = headers[col];
|
if (col >= values.Length) break;
|
||||||
if (string.IsNullOrEmpty(fieldName))
|
string fieldName = ToCamelCase(headers[col]);
|
||||||
continue;
|
FieldInfo field = dataType.GetField(fieldName, BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
if (field != null) SetFieldValue(so, field, values[col]);
|
||||||
string camelFieldName = ToCamelCase(fieldName);
|
|
||||||
Debug.Log(" 🔍 필드 매핑: " + camelFieldName);
|
|
||||||
|
|
||||||
FieldInfo field = dataType.GetField(camelFieldName, BindingFlags.Public | BindingFlags.Instance);
|
|
||||||
|
|
||||||
if (field == null)
|
|
||||||
{
|
|
||||||
if (lineIndex == 1)
|
|
||||||
{
|
|
||||||
Debug.LogWarning($" ⚠️ 필드를 찾을 수 없습니다: {camelFieldName}");
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
string cellValue = values[col];
|
|
||||||
SetFieldValue(so, field, cellValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
string assetName = GetAssetName(so, lineIndex);
|
string assetName = GetAssetName(so, lineIndex);
|
||||||
string fileName = assetName + ".asset";
|
AssetDatabase.CreateAsset(so, Path.Combine(outputPath, $"{assetName}.asset"));
|
||||||
string assetPath = Path.Combine(outputPath, fileName);
|
|
||||||
|
|
||||||
AssetDatabase.CreateAsset(so, assetPath);
|
|
||||||
importCount++;
|
|
||||||
|
|
||||||
Debug.Log($" ✅ {assetName}");
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Debug.LogError($" ❌ 행 {lineIndex + 1} Import 실패: {e.Message}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AssetDatabase.SaveAssets();
|
AssetDatabase.SaveAssets();
|
||||||
AssetDatabase.Refresh();
|
AssetDatabase.Refresh();
|
||||||
|
|
||||||
Debug.Log($" 🎉 {schemaName} Import 완료: {importCount}개");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
Debug.LogError($"❌ {schemaName} Import 실패: {e.Message}");
|
Debug.LogError($"{schemaName} 오류: {e.Message}");
|
||||||
Debug.LogException(e);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static string[] ParseCSVLine(string line)
|
|
||||||
{
|
|
||||||
var result = new List<string>();
|
|
||||||
bool inQuotes = false;
|
|
||||||
string currentValue = "";
|
|
||||||
|
|
||||||
for (int i = 0; i < line.Length; i++)
|
|
||||||
{
|
|
||||||
char c = line[i];
|
|
||||||
|
|
||||||
if (c == '"')
|
|
||||||
{
|
|
||||||
inQuotes = !inQuotes;
|
|
||||||
}
|
|
||||||
else if (c == ',' && !inQuotes)
|
|
||||||
{
|
|
||||||
result.Add(currentValue.Trim());
|
|
||||||
currentValue = "";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
currentValue += c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Add(currentValue.Trim());
|
|
||||||
|
|
||||||
return result.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static Type FindScriptableObjectType(string className)
|
|
||||||
{
|
|
||||||
string fullName = $"Northbound.Data.{className}";
|
|
||||||
|
|
||||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
|
||||||
{
|
|
||||||
Type type = assembly.GetType(fullName);
|
|
||||||
if (type != null && type.IsSubclassOf(typeof(ScriptableObject)))
|
|
||||||
{
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static string ToCamelCase(string snakeCase)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(snakeCase))
|
|
||||||
return snakeCase;
|
|
||||||
|
|
||||||
var parts = snakeCase.Split('_');
|
|
||||||
if (parts.Length == 1)
|
|
||||||
return snakeCase;
|
|
||||||
|
|
||||||
string result = parts[0];
|
|
||||||
for (int i = 1; i < parts.Length; i++)
|
|
||||||
{
|
|
||||||
if (parts[i].Length > 0)
|
|
||||||
{
|
|
||||||
result += char.ToUpper(parts[i][0]) + parts[i].Substring(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static void SetFieldValue(object obj, FieldInfo field, string value)
|
private static void SetFieldValue(object obj, FieldInfo field, string value)
|
||||||
{
|
{
|
||||||
|
Type fieldType = field.FieldType;
|
||||||
|
|
||||||
|
// 1. 리스트 타입 처리 (List<T>)
|
||||||
|
if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(List<>))
|
||||||
|
{
|
||||||
|
Type elementType = fieldType.GetGenericArguments()[0];
|
||||||
|
IList list = (IList)field.GetValue(obj);
|
||||||
|
if (list == null)
|
||||||
|
{
|
||||||
|
list = (IList)Activator.CreateInstance(fieldType);
|
||||||
|
field.SetValue(obj, list);
|
||||||
|
}
|
||||||
|
list.Clear();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
// 쉼표(,) 혹은 세미콜론(;)으로 분할 가능하게 설정
|
||||||
|
string[] items = value.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
object convertedItem = ConvertValue(item.Trim(), elementType);
|
||||||
|
if (convertedItem != null) list.Add(convertedItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
{
|
{
|
||||||
field.SetValue(obj, null);
|
field.SetValue(obj, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Type fieldType = field.FieldType;
|
// 2. Nullable 처리
|
||||||
|
|
||||||
if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(Nullable<>))
|
if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(Nullable<>))
|
||||||
{
|
{
|
||||||
Type underlyingType = Nullable.GetUnderlyingType(fieldType);
|
Type underlyingType = Nullable.GetUnderlyingType(fieldType);
|
||||||
object convertedValue = ConvertValue(value, underlyingType);
|
field.SetValue(obj, ConvertValue(value, underlyingType));
|
||||||
field.SetValue(obj, convertedValue);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
object convertedValue = ConvertValue(value, fieldType);
|
// 3. 일반 타입 처리
|
||||||
field.SetValue(obj, convertedValue);
|
field.SetValue(obj, ConvertValue(value, fieldType));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static object ConvertValue(string value, Type targetType)
|
private static object ConvertValue(string value, Type targetType)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (targetType == typeof(int)) return int.Parse(value);
|
||||||
|
if (targetType == typeof(float)) return float.Parse(value);
|
||||||
|
if (targetType == typeof(bool))
|
||||||
|
{
|
||||||
|
string l = value.ToLower();
|
||||||
|
return l == "true" || l == "1" || l == "yes";
|
||||||
|
}
|
||||||
|
if (targetType == typeof(string)) return value.Replace("\\n", "\n");
|
||||||
|
return Convert.ChangeType(value, targetType);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 유틸리티 메서드 (기존과 동일) ---
|
||||||
|
private static string[] ParseCSVLine(string line)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
bool inQuotes = false;
|
||||||
|
string current = "";
|
||||||
|
for (int i = 0; i < line.Length; i++)
|
||||||
|
{
|
||||||
|
char c = line[i];
|
||||||
|
if (c == '"') inQuotes = !inQuotes;
|
||||||
|
else if (c == ',' && !inQuotes) { result.Add(current.Trim()); current = ""; }
|
||||||
|
else current += c;
|
||||||
|
}
|
||||||
|
result.Add(current.Trim());
|
||||||
|
return result.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type FindScriptableObjectType(string className)
|
||||||
|
{
|
||||||
|
string fullName = $"Northbound.Data.{className}";
|
||||||
|
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
|
{
|
||||||
|
Type type = assembly.GetType(fullName);
|
||||||
|
if (type != null && type.IsSubclassOf(typeof(ScriptableObject))) return type;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (targetType == typeof(int))
|
|
||||||
{
|
|
||||||
if (int.TryParse(value, out int result))
|
|
||||||
return result;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
else if (targetType == typeof(float))
|
|
||||||
{
|
|
||||||
if (float.TryParse(value, out float result))
|
|
||||||
return result;
|
|
||||||
return 0f;
|
|
||||||
}
|
|
||||||
else if (targetType == typeof(double))
|
|
||||||
{
|
|
||||||
if (double.TryParse(value, out double result))
|
|
||||||
return result;
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
else if (targetType == typeof(bool))
|
|
||||||
{
|
|
||||||
string lower = value.ToLower();
|
|
||||||
return lower == "true" || lower == "1" || lower == "yes";
|
|
||||||
}
|
|
||||||
else if (targetType == typeof(string))
|
|
||||||
{
|
|
||||||
// ⭐ 이스케이프된 줄바꿈 복원
|
|
||||||
return value.Replace("\\n", "\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
private static string ToCamelCase(string snakeCase)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(snakeCase)) return snakeCase;
|
||||||
|
var parts = snakeCase.Split('_');
|
||||||
|
if (parts.Length == 1) return snakeCase;
|
||||||
|
string result = parts[0];
|
||||||
|
for (int i = 1; i < parts.Length; i++)
|
||||||
|
if (parts[i].Length > 0) result += char.ToUpper(parts[i][0]) + parts[i].Substring(1);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetAssetName(object so, int lineNumber)
|
private static string GetAssetName(object so, int lineNumber)
|
||||||
{
|
{
|
||||||
Type type = so.GetType();
|
Type type = so.GetType();
|
||||||
|
|
||||||
// ⭐ 1순위: id 필드 찾기
|
|
||||||
FieldInfo idField = type.GetField("id", BindingFlags.Public | BindingFlags.Instance);
|
FieldInfo idField = type.GetField("id", BindingFlags.Public | BindingFlags.Instance);
|
||||||
if (idField != null)
|
if (idField != null)
|
||||||
{
|
{
|
||||||
var idValue = idField.GetValue(so);
|
var val = idField.GetValue(so);
|
||||||
if (idValue != null)
|
if (val != null) return $"{type.Name.Replace("Data", "")}{val:D3}";
|
||||||
{
|
|
||||||
// 스키마 이름 추출 (TowersData → Towers, TowerData → Tower)
|
|
||||||
string typeName = type.Name;
|
|
||||||
if (typeName.EndsWith("Data"))
|
|
||||||
{
|
|
||||||
typeName = typeName.Substring(0, typeName.Length - 4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tower1, Enemy5 형식
|
|
||||||
return $"{typeName}{idValue:D3}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ⭐ 2순위: name 필드 찾기
|
|
||||||
FieldInfo nameField = type.GetField("name", BindingFlags.Public | BindingFlags.Instance);
|
|
||||||
if (nameField != null)
|
|
||||||
{
|
|
||||||
var nameValue = nameField.GetValue(so);
|
|
||||||
if (nameValue != null && !string.IsNullOrWhiteSpace(nameValue.ToString()))
|
|
||||||
{
|
|
||||||
string name = nameValue.ToString();
|
|
||||||
name = name.Replace(" ", "");
|
|
||||||
name = System.Text.RegularExpressions.Regex.Replace(name, @"[^a-zA-Z0-9_가-힣]", "");
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3순위: 행 번호 사용
|
|
||||||
return $"Data_Row{lineNumber}";
|
return $"Data_Row{lineNumber}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# DataTools/generate_csharp_classes.py
|
# DataTools/generate_csharp_classes.py
|
||||||
"""노션 스키마 → Unity C# ScriptableObject 클래스 생성"""
|
"""노션 스키마 → Unity C# ScriptableObject 클래스 생성 (리스트 지원 버전)"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,7 +10,18 @@ UNITY_OUTPUT_DIR = Path(__file__).parent.parent / "Assets" / "Data" / "Scripts"
|
|||||||
|
|
||||||
|
|
||||||
def get_csharp_type(field_type):
|
def get_csharp_type(field_type):
|
||||||
"""Python 타입 → C# 타입 변환"""
|
"""Python 타입 → C# 타입 변환 (list 지원)"""
|
||||||
|
# 리스트 여부 확인 (예: list:int, list:string)
|
||||||
|
is_list = field_type.lower().startswith('list')
|
||||||
|
|
||||||
|
# 기본 타입 추출
|
||||||
|
base_type = field_type.lower()
|
||||||
|
if is_list and ':' in base_type:
|
||||||
|
base_type = base_type.split(':')[1]
|
||||||
|
elif is_list:
|
||||||
|
# 'list'만 적혀있을 경우 기본적으로 string 리스트로 간주
|
||||||
|
base_type = 'string'
|
||||||
|
|
||||||
type_map = {
|
type_map = {
|
||||||
'int': 'int',
|
'int': 'int',
|
||||||
'float': 'float',
|
'float': 'float',
|
||||||
@@ -19,87 +30,54 @@ def get_csharp_type(field_type):
|
|||||||
'bool': 'bool',
|
'bool': 'bool',
|
||||||
'boolean': 'bool'
|
'boolean': 'bool'
|
||||||
}
|
}
|
||||||
return type_map.get(field_type.lower(), 'string')
|
|
||||||
|
csharp_base = type_map.get(base_type, 'string')
|
||||||
|
|
||||||
|
if is_list:
|
||||||
|
return f"List<{csharp_base}>"
|
||||||
|
return csharp_base
|
||||||
|
|
||||||
|
|
||||||
def to_camel_case(snake_str):
|
def to_camel_case(snake_str):
|
||||||
"""
|
"""snake_case → camelCase"""
|
||||||
snake_case → camelCase
|
|
||||||
|
|
||||||
예시:
|
|
||||||
- tower_type → towerType
|
|
||||||
- damage → damage
|
|
||||||
- attack_speed → attackSpeed
|
|
||||||
"""
|
|
||||||
components = snake_str.split('_')
|
components = snake_str.split('_')
|
||||||
return components[0] + ''.join(x.title() for x in components[1:])
|
return components[0] + ''.join(x.title() for x in components[1:])
|
||||||
|
|
||||||
|
|
||||||
def to_pascal_case(snake_str):
|
def to_pascal_case(snake_str):
|
||||||
"""
|
"""snake_case → PascalCase"""
|
||||||
snake_case → PascalCase
|
|
||||||
|
|
||||||
예시:
|
|
||||||
- tower_type → TowerType
|
|
||||||
- damage → Damage
|
|
||||||
"""
|
|
||||||
return ''.join(x.title() for x in snake_str.split('_'))
|
return ''.join(x.title() for x in snake_str.split('_'))
|
||||||
|
|
||||||
|
|
||||||
def group_fields_by_condition(schema):
|
def group_fields_by_condition(schema):
|
||||||
"""
|
"""필드를 조건별로 그룹화"""
|
||||||
필드를 조건별로 그룹화
|
|
||||||
|
|
||||||
반환:
|
|
||||||
{
|
|
||||||
'common': [공통 필드들],
|
|
||||||
'attack': [attack 타입 필드들],
|
|
||||||
'defense': [defense 타입 필드들],
|
|
||||||
...
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
groups = defaultdict(list)
|
groups = defaultdict(list)
|
||||||
|
|
||||||
for field in schema:
|
for field in schema:
|
||||||
if field.get('condition'):
|
if field.get('condition'):
|
||||||
condition_value = field['condition']['value']
|
condition_value = field['condition']['value']
|
||||||
groups[condition_value].append(field)
|
groups[condition_value].append(field)
|
||||||
else:
|
else:
|
||||||
groups['common'].append(field)
|
groups['common'].append(field)
|
||||||
|
|
||||||
return groups
|
return groups
|
||||||
|
|
||||||
|
|
||||||
def generate_class(schema_name, schema):
|
def generate_class(schema_name, schema):
|
||||||
"""
|
"""스키마 → C# 클래스 코드 생성"""
|
||||||
스키마 → C# 클래스 코드 생성
|
|
||||||
|
|
||||||
schema: [
|
|
||||||
{'name': 'id', 'type': 'int', 'condition': None, 'description': '...'},
|
|
||||||
{'name': 'damage', 'type': 'float', 'condition': {...}, 'description': '...'},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
"""
|
|
||||||
class_name = schema_name + "Data"
|
class_name = schema_name + "Data"
|
||||||
|
|
||||||
# 필드 그룹화
|
|
||||||
field_groups = group_fields_by_condition(schema)
|
field_groups = group_fields_by_condition(schema)
|
||||||
|
|
||||||
# 조건 필드명 찾기 (예: tower_type, enemy_type)
|
|
||||||
condition_field = None
|
condition_field = None
|
||||||
for field in schema:
|
for field in schema:
|
||||||
if field.get('condition'):
|
if field.get('condition'):
|
||||||
condition_field = field['condition']['field']
|
condition_field = field['condition']['field']
|
||||||
break
|
break
|
||||||
|
|
||||||
# C# 코드 생성 시작
|
|
||||||
lines = []
|
lines = []
|
||||||
|
|
||||||
# 파일 헤더
|
|
||||||
lines.append("// 이 파일은 자동 생성되었습니다. 직접 수정하지 마세요!")
|
lines.append("// 이 파일은 자동 생성되었습니다. 직접 수정하지 마세요!")
|
||||||
lines.append("// 생성 스크립트: DataTools/generate_csharp_classes.py")
|
lines.append("// 생성 스크립트: DataTools/generate_csharp_classes.py")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("using UnityEngine;")
|
lines.append("using UnityEngine;")
|
||||||
|
lines.append("using System.Collections.Generic; // 리스트 지원을 위해 추가")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("namespace Northbound.Data")
|
lines.append("namespace Northbound.Data")
|
||||||
lines.append("{")
|
lines.append("{")
|
||||||
@@ -121,10 +99,8 @@ def generate_class(schema_name, schema):
|
|||||||
|
|
||||||
header_name = condition_value.capitalize()
|
header_name = condition_value.capitalize()
|
||||||
lines.append(f" [Header(\"{header_name} 전용\")]")
|
lines.append(f" [Header(\"{header_name} 전용\")]")
|
||||||
|
|
||||||
for field in fields:
|
for field in fields:
|
||||||
add_field(lines, field, nullable=True)
|
add_field(lines, field, nullable=True)
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# 유틸리티 메서드
|
# 유틸리티 메서드
|
||||||
@@ -132,30 +108,27 @@ def generate_class(schema_name, schema):
|
|||||||
lines.append(" // ===== 유틸리티 메서드 =====")
|
lines.append(" // ===== 유틸리티 메서드 =====")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# 타입 체크 프로퍼티
|
|
||||||
for condition_value in field_groups.keys():
|
for condition_value in field_groups.keys():
|
||||||
if condition_value == 'common':
|
if condition_value == 'common':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
property_name = f"Is{to_pascal_case(condition_value)}"
|
property_name = f"Is{to_pascal_case(condition_value)}"
|
||||||
field_name = to_camel_case(condition_field)
|
field_name = to_camel_case(condition_field)
|
||||||
|
|
||||||
lines.append(f' public bool {property_name} => {field_name} == "{condition_value}";')
|
lines.append(f' public bool {property_name} => {field_name} == "{condition_value}";')
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Nullable 필드 Getter 메서드
|
|
||||||
for condition_value, fields in field_groups.items():
|
for condition_value, fields in field_groups.items():
|
||||||
if condition_value == 'common':
|
if condition_value == 'common':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for field in fields:
|
for field in fields:
|
||||||
field_name = to_camel_case(field['name'])
|
field_name = to_camel_case(field['name'])
|
||||||
method_name = f"Get{to_pascal_case(field['name'])}"
|
method_name = f"Get{to_pascal_case(field['name'])}"
|
||||||
field_type = get_csharp_type(field['type'])
|
field_type = get_csharp_type(field['type'])
|
||||||
|
|
||||||
# 기본값 결정
|
# 리스트 여부에 따른 기본값 처리
|
||||||
if field_type == 'int':
|
if "List<" in field_type:
|
||||||
|
default_value = f"new {field_type}()"
|
||||||
|
elif field_type == 'int':
|
||||||
default_value = '0'
|
default_value = '0'
|
||||||
elif field_type == 'float':
|
elif field_type == 'float':
|
||||||
default_value = '0f'
|
default_value = '0f'
|
||||||
@@ -166,7 +139,6 @@ def generate_class(schema_name, schema):
|
|||||||
|
|
||||||
lines.append(f" public {field_type} {method_name}() => {field_name} ?? {default_value};")
|
lines.append(f" public {field_type} {method_name}() => {field_name} ?? {default_value};")
|
||||||
|
|
||||||
# 클래스 종료
|
|
||||||
lines.append(" }")
|
lines.append(" }")
|
||||||
lines.append("}")
|
lines.append("}")
|
||||||
|
|
||||||
@@ -177,15 +149,18 @@ def add_field(lines, field, nullable=False):
|
|||||||
"""필드 정의 추가"""
|
"""필드 정의 추가"""
|
||||||
field_name = to_camel_case(field['name'])
|
field_name = to_camel_case(field['name'])
|
||||||
field_type = get_csharp_type(field['type'])
|
field_type = get_csharp_type(field['type'])
|
||||||
|
is_list = "List<" in field_type
|
||||||
|
|
||||||
# 주석 (설명이 있으면)
|
|
||||||
if field.get('description'):
|
if field.get('description'):
|
||||||
# 줄바꿈을 공백으로 변환 (C# 주석은 한 줄)
|
|
||||||
description = field['description'].replace('\\n', ' ').replace('\n', ' ')
|
description = field['description'].replace('\\n', ' ').replace('\n', ' ')
|
||||||
lines.append(f" /// <summary>{description}</summary>")
|
lines.append(f" /// <summary>{description}</summary>")
|
||||||
|
|
||||||
# 필드 선언
|
# 필드 선언
|
||||||
if nullable:
|
if is_list:
|
||||||
|
# 리스트는 인스펙터에서 바로 보이도록 초기화
|
||||||
|
lines.append(f" public {field_type} {field_name} = new {field_type}();")
|
||||||
|
elif nullable and field_type not in ['string']:
|
||||||
|
# string은 원래 nullable이므로 제외, 값 타입만 ? 적용
|
||||||
lines.append(f" public {field_type}? {field_name};")
|
lines.append(f" public {field_type}? {field_name};")
|
||||||
else:
|
else:
|
||||||
lines.append(f" public {field_type} {field_name};")
|
lines.append(f" public {field_type} {field_name};")
|
||||||
@@ -193,65 +168,34 @@ def add_field(lines, field, nullable=False):
|
|||||||
|
|
||||||
def generate_all_classes():
|
def generate_all_classes():
|
||||||
"""모든 스키마에 대해 C# 클래스 생성"""
|
"""모든 스키마에 대해 C# 클래스 생성"""
|
||||||
|
|
||||||
# 출력 폴더 생성
|
|
||||||
UNITY_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
UNITY_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# 스키마 파일 찾기
|
|
||||||
schema_files = list(GAMEDATA_DIR.glob(".*_schema.json"))
|
schema_files = list(GAMEDATA_DIR.glob(".*_schema.json"))
|
||||||
|
|
||||||
if not schema_files:
|
if not schema_files:
|
||||||
print("⚠️ 스키마 파일을 찾을 수 없습니다.")
|
print("⚠️ 스키마 파일을 찾을 수 없습니다.")
|
||||||
print("💡 먼저 sync_from_notion.py를 실행하세요.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("🔧 C# 클래스 자동 생성")
|
print("🔧 C# 클래스 자동 생성 (List 지원)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print()
|
|
||||||
|
|
||||||
generated_count = 0
|
generated_count = 0
|
||||||
|
|
||||||
for schema_file in schema_files:
|
for schema_file in schema_files:
|
||||||
# ".Towers_schema.json" → "Towers"
|
|
||||||
schema_name = schema_file.stem.replace("_schema", "").lstrip(".")
|
schema_name = schema_file.stem.replace("_schema", "").lstrip(".")
|
||||||
|
|
||||||
print(f"📋 {schema_name} 처리 중...")
|
|
||||||
|
|
||||||
# 스키마 로드
|
|
||||||
try:
|
try:
|
||||||
with open(schema_file, 'r', encoding='utf-8') as f:
|
with open(schema_file, 'r', encoding='utf-8') as f:
|
||||||
schema = json.load(f)
|
schema = json.load(f)
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ 스키마 로드 실패: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# C# 코드 생성
|
|
||||||
try:
|
|
||||||
csharp_code = generate_class(schema_name, schema)
|
csharp_code = generate_class(schema_name, schema)
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ 코드 생성 실패: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 파일 저장
|
|
||||||
output_file = UNITY_OUTPUT_DIR / f"{schema_name}Data.cs"
|
output_file = UNITY_OUTPUT_DIR / f"{schema_name}Data.cs"
|
||||||
try:
|
|
||||||
with open(output_file, 'w', encoding='utf-8') as f:
|
with open(output_file, 'w', encoding='utf-8') as f:
|
||||||
f.write(csharp_code)
|
f.write(csharp_code)
|
||||||
|
print(f" ✅ 생성: {schema_name}Data.cs")
|
||||||
print(f" ✅ 생성: {schema_name}Data.cs ({len(schema)}개 필드)")
|
|
||||||
generated_count += 1
|
generated_count += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ❌ 파일 저장 실패: {e}")
|
print(f" ❌ {schema_name} 실패: {e}")
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(f"🎉 완료: {generated_count}개 클래스 생성")
|
print(f"🎉 완료: {generated_count}개 클래스 생성")
|
||||||
print(f"📂 위치: {UNITY_OUTPUT_DIR}")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
# DataTools/sync_from_notion.py
|
|
||||||
"""노션 스키마 → CSV 동기화 (자동 발견)"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import shutil
|
||||||
from notion_client import Client
|
from notion_client import Client
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -11,8 +10,6 @@ from datetime import datetime
|
|||||||
# ===== 설정 =====
|
# ===== 설정 =====
|
||||||
NOTION_API_KEY = "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"
|
NOTION_API_KEY = "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"
|
||||||
notion = Client(auth=NOTION_API_KEY) if NOTION_API_KEY else None
|
notion = Client(auth=NOTION_API_KEY) if NOTION_API_KEY else None
|
||||||
|
|
||||||
# ⭐ 부모 페이지 ID만 설정 (1회)
|
|
||||||
SCHEMA_PARENT_PAGE_ID = "2f494d45b1a3818fa9fceb4f9e17d905"
|
SCHEMA_PARENT_PAGE_ID = "2f494d45b1a3818fa9fceb4f9e17d905"
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).parent
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
@@ -20,456 +17,162 @@ GAMEDATA_DIR = SCRIPT_DIR.parent / "GameData"
|
|||||||
BACKUP_DIR = GAMEDATA_DIR / "Backups"
|
BACKUP_DIR = GAMEDATA_DIR / "Backups"
|
||||||
BACKUP_DIR.mkdir(exist_ok=True)
|
BACKUP_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
# ===== 유틸리티 함수 =====
|
# ===== 유틸리티 함수 =====
|
||||||
|
|
||||||
def clean_page_title(title):
|
def clean_page_title(title):
|
||||||
"""
|
|
||||||
노션 페이지 제목 → 스키마 이름 변환
|
|
||||||
|
|
||||||
규칙:
|
|
||||||
1. 이모지, 특수문자 제거
|
|
||||||
2. "스키마", "Schema" 제거
|
|
||||||
3. 공백 제거
|
|
||||||
|
|
||||||
예시:
|
|
||||||
- "🏰 타워 스키마" → "타워"
|
|
||||||
- "Tower Schema" → "Tower"
|
|
||||||
- "Enemies" → "Enemies"
|
|
||||||
"""
|
|
||||||
# 1. 이모지 및 특수문자 제거
|
|
||||||
cleaned = re.sub(r'[^\w\s가-힣]', '', title).strip()
|
cleaned = re.sub(r'[^\w\s가-힣]', '', title).strip()
|
||||||
|
|
||||||
# 2. "스키마", "Schema" 제거
|
|
||||||
cleaned = re.sub(r'\s*(스키마|Schema)\s*', '', cleaned, flags=re.IGNORECASE).strip()
|
cleaned = re.sub(r'\s*(스키마|Schema)\s*', '', cleaned, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
# 3. 공백 제거
|
|
||||||
cleaned = cleaned.replace(' ', '')
|
cleaned = cleaned.replace(' ', '')
|
||||||
|
return cleaned if cleaned else title.replace(' ', '')
|
||||||
|
|
||||||
# 4. 비어있으면 원본 반환
|
def check_page_has_table(page_id):
|
||||||
if not cleaned:
|
try:
|
||||||
return title.replace(' ', '')
|
blocks_response = notion.blocks.children.list(block_id=page_id)
|
||||||
|
return any(block.get('type') == 'table' for block in blocks_response.get('results', []))
|
||||||
return cleaned
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
def discover_schema_pages(parent_id=None, depth=0, max_depth=3):
|
def discover_schema_pages(parent_id=None, depth=0, max_depth=3):
|
||||||
"""
|
if not notion: raise ValueError("API Key missing")
|
||||||
부모 페이지의 하위 페이지들을 재귀적으로 탐색하여 스키마 발견
|
parent_id = parent_id or SCHEMA_PARENT_PAGE_ID
|
||||||
|
if depth > max_depth: return {}
|
||||||
Args:
|
|
||||||
parent_id: 탐색할 부모 페이지 ID (None이면 SCHEMA_PARENT_PAGE_ID 사용)
|
|
||||||
depth: 현재 깊이 (0부터 시작)
|
|
||||||
max_depth: 최대 탐색 깊이 (기본 3단계)
|
|
||||||
|
|
||||||
반환:
|
|
||||||
{
|
|
||||||
"타워": "page_id_1",
|
|
||||||
"적유닛": "page_id_2",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
if not notion:
|
|
||||||
raise ValueError("Notion API 클라이언트가 초기화되지 않았습니다.")
|
|
||||||
|
|
||||||
if parent_id is None:
|
|
||||||
if not SCHEMA_PARENT_PAGE_ID or SCHEMA_PARENT_PAGE_ID == "노션_데이터_스키마_정의_페이지_ID":
|
|
||||||
raise ValueError(
|
|
||||||
"SCHEMA_PARENT_PAGE_ID가 설정되지 않았습니다.\n"
|
|
||||||
"sync_from_notion.py 파일에서 부모 페이지 ID를 설정하세요."
|
|
||||||
)
|
|
||||||
parent_id = SCHEMA_PARENT_PAGE_ID
|
|
||||||
|
|
||||||
# 최대 깊이 체크
|
|
||||||
if depth > max_depth:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
indent = " " * depth
|
indent = " " * depth
|
||||||
|
if depth == 0: print("🔍 스키마 페이지 자동 발견 중...")
|
||||||
if depth == 0:
|
|
||||||
print("🔍 스키마 페이지 자동 발견 중...")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 부모 페이지의 자식 블록 가져오기
|
|
||||||
children = notion.blocks.children.list(block_id=parent_id)
|
children = notion.blocks.children.list(block_id=parent_id)
|
||||||
|
|
||||||
schemas = {}
|
schemas = {}
|
||||||
|
|
||||||
for block in children['results']:
|
for block in children['results']:
|
||||||
if block['type'] == 'child_page':
|
if block['type'] == 'child_page':
|
||||||
page_id = block['id']
|
page_id = block['id']
|
||||||
page_title = block['child_page']['title']
|
page_title = block['child_page']['title']
|
||||||
|
|
||||||
# 제목 정리
|
|
||||||
schema_name = clean_page_title(page_title)
|
schema_name = clean_page_title(page_title)
|
||||||
|
|
||||||
print(f"{indent}📋 발견: '{page_title}'", end="")
|
print(f"{indent}📋 발견: '{page_title}'", end="")
|
||||||
|
if check_page_has_table(page_id):
|
||||||
# 이 페이지에 테이블이 있는지 확인
|
|
||||||
has_table = check_page_has_table(page_id)
|
|
||||||
|
|
||||||
if has_table:
|
|
||||||
# 테이블이 있으면 스키마로 등록
|
|
||||||
schemas[schema_name] = page_id
|
schemas[schema_name] = page_id
|
||||||
print(f" → {schema_name} ✅")
|
print(f" → {schema_name} ✅")
|
||||||
else:
|
else:
|
||||||
# 테이블이 없으면 하위 페이지 탐색
|
|
||||||
print(f" (폴더)")
|
print(f" (폴더)")
|
||||||
child_schemas = discover_schema_pages(page_id, depth + 1, max_depth)
|
schemas.update(discover_schema_pages(page_id, depth + 1, max_depth))
|
||||||
schemas.update(child_schemas)
|
|
||||||
|
|
||||||
if depth == 0 and not schemas:
|
|
||||||
print(" ⚠️ 하위 페이지를 찾을 수 없습니다.")
|
|
||||||
print(f" 💡 노션에서 부모 페이지 하위에 스키마 페이지를 추가하세요.")
|
|
||||||
|
|
||||||
return schemas
|
return schemas
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"{indent}❌ 탐색 실패: {e}")
|
print(f"{indent}❌ 탐색 실패: {e}")
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def check_page_has_table(page_id):
|
|
||||||
"""
|
|
||||||
페이지에 테이블 블록이 있는지 확인
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: 확인할 페이지 ID
|
|
||||||
|
|
||||||
반환:
|
|
||||||
True: 테이블 있음
|
|
||||||
False: 테이블 없음
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
blocks_response = notion.blocks.children.list(block_id=page_id)
|
|
||||||
blocks = blocks_response.get('results', [])
|
|
||||||
|
|
||||||
for block in blocks:
|
|
||||||
if block.get('type') == 'table':
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# 에러 발생 시 테이블 없음으로 간주
|
|
||||||
return False
|
|
||||||
|
|
||||||
def parse_condition(condition_str):
|
def parse_condition(condition_str):
|
||||||
"""
|
if not condition_str or condition_str.strip() == "": return None
|
||||||
사용 조건 파싱
|
match = re.match(r'(\w+)\s*(=|!=|>|<|>=|<=)\s*(.+)', condition_str.strip())
|
||||||
|
|
||||||
빈 문자열 → None (항상 사용)
|
|
||||||
"tower_type=attack" → {'field': 'tower_type', 'op': '=', 'value': 'attack'}
|
|
||||||
"""
|
|
||||||
if not condition_str or condition_str.strip() == "":
|
|
||||||
return None
|
|
||||||
|
|
||||||
condition_str = condition_str.strip()
|
|
||||||
|
|
||||||
# 단순 조건: "tower_type=attack"
|
|
||||||
match = re.match(r'(\w+)\s*(=|!=|>|<|>=|<=)\s*(.+)', condition_str)
|
|
||||||
if match:
|
if match:
|
||||||
return {
|
return {'field': match.group(1), 'op': match.group(2), 'value': match.group(3).strip()}
|
||||||
'field': match.group(1),
|
|
||||||
'op': match.group(2),
|
|
||||||
'value': match.group(3).strip()
|
|
||||||
}
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_notion_table(page_id):
|
def parse_notion_table(page_id):
|
||||||
"""노션 테이블 파싱"""
|
blocks = notion.blocks.children.list(block_id=page_id).get('results', [])
|
||||||
|
table_block = next((b for b in blocks if b['type'] == 'table'), None)
|
||||||
|
if not table_block: raise ValueError("테이블을 찾을 수 없습니다.")
|
||||||
|
|
||||||
if not notion:
|
rows = notion.blocks.children.list(block_id=table_block['id']).get('results', [])
|
||||||
raise ValueError("Notion API 클라이언트가 초기화되지 않았습니다.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 1. 블록 가져오기
|
|
||||||
blocks_response = notion.blocks.children.list(block_id=page_id)
|
|
||||||
blocks = blocks_response.get('results', [])
|
|
||||||
|
|
||||||
# 2. 테이블 찾기
|
|
||||||
table_block = None
|
|
||||||
for block in blocks:
|
|
||||||
if block.get('type') == 'table':
|
|
||||||
table_block = block
|
|
||||||
break
|
|
||||||
|
|
||||||
if not table_block:
|
|
||||||
raise ValueError(f"테이블을 찾을 수 없습니다.")
|
|
||||||
|
|
||||||
print(f" 📋 테이블 발견")
|
|
||||||
|
|
||||||
# 3. 행 가져오기
|
|
||||||
table_id = table_block['id']
|
|
||||||
rows_response = notion.blocks.children.list(block_id=table_id)
|
|
||||||
rows = rows_response.get('results', [])
|
|
||||||
|
|
||||||
if len(rows) < 2:
|
|
||||||
raise ValueError("테이블에 데이터가 없습니다.")
|
|
||||||
|
|
||||||
# 4. 파싱
|
|
||||||
schema = []
|
schema = []
|
||||||
|
|
||||||
def extract_text(cell, preserve_newlines=False):
|
def extract_text(cell, preserve_newlines=False):
|
||||||
"""
|
if not cell: return ""
|
||||||
셀에서 텍스트 추출
|
text = "".join([c.get('text', {}).get('content', '') for c in cell if c.get('type') == 'text'])
|
||||||
|
return text.replace('\n', '\\n').strip() if preserve_newlines else text.strip()
|
||||||
Args:
|
|
||||||
cell: 노션 셀 데이터
|
|
||||||
preserve_newlines: True면 줄바꿈 보존, False면 공백으로 변환
|
|
||||||
"""
|
|
||||||
if not cell or len(cell) == 0:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
text_parts = []
|
|
||||||
for content in cell:
|
|
||||||
if content.get('type') == 'text':
|
|
||||||
text_content = content.get('text', {}).get('content', '')
|
|
||||||
text_parts.append(text_content)
|
|
||||||
|
|
||||||
if preserve_newlines:
|
|
||||||
# 줄바꿈 보존 (\\n으로 이스케이프)
|
|
||||||
result = ''.join(text_parts)
|
|
||||||
# CSV에서 안전하게 저장하기 위해 실제 줄바꿈을 \\n으로 변환
|
|
||||||
result = result.replace('\n', '\\n')
|
|
||||||
return result.strip()
|
|
||||||
else:
|
|
||||||
# 줄바꿈을 공백으로 변환
|
|
||||||
return ''.join(text_parts).strip()
|
|
||||||
|
|
||||||
for row_idx, row in enumerate(rows[1:], start=2):
|
|
||||||
if row.get('type') != 'table_row':
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
for row in rows[1:]: # 헤더 제외
|
||||||
|
if row.get('type') != 'table_row': continue
|
||||||
cells = row['table_row']['cells']
|
cells = row['table_row']['cells']
|
||||||
|
field_name = extract_text(cells[0])
|
||||||
|
if not field_name: continue
|
||||||
|
|
||||||
# 4개 컬럼: 필드명, 타입, 사용 조건, 설명
|
# 필드 파싱
|
||||||
field_name = extract_text(cells[0]) if len(cells) > 0 else ""
|
field_type = extract_text(cells[1]).lower()
|
||||||
field_type = extract_text(cells[1]) if len(cells) > 1 else "string"
|
condition_str = extract_text(cells[2])
|
||||||
condition_str = extract_text(cells[2]) if len(cells) > 2 else ""
|
|
||||||
# ⭐ 설명 컬럼만 줄바꿈 보존
|
|
||||||
description = extract_text(cells[3], preserve_newlines=True) if len(cells) > 3 else ""
|
description = extract_text(cells[3], preserve_newlines=True) if len(cells) > 3 else ""
|
||||||
|
|
||||||
if not field_name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 조건 파싱
|
|
||||||
condition = parse_condition(condition_str)
|
|
||||||
|
|
||||||
if condition:
|
|
||||||
print(f" 📌 {field_name}: {condition['field']}={condition['value']}일 때 사용")
|
|
||||||
|
|
||||||
schema.append({
|
schema.append({
|
||||||
'name': field_name,
|
'name': field_name,
|
||||||
'type': field_type.lower(),
|
'type': field_type, # "list:int" 형태 그대로 보존
|
||||||
'condition': condition,
|
'condition': parse_condition(condition_str),
|
||||||
'description': description
|
'description': description
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(schema) == 0:
|
|
||||||
raise ValueError("파싱된 스키마가 비어있습니다.")
|
|
||||||
|
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ 파싱 오류: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_value(field_type, has_condition):
|
def get_default_value(field_type, has_condition):
|
||||||
"""
|
"""기본값 결정 (List 대응)"""
|
||||||
기본값 결정
|
if has_condition: return None
|
||||||
|
|
||||||
조건부 필드 → None (빈 칸)
|
f_type = field_type.lower()
|
||||||
공통 필드 → 타입별 기본값
|
if 'list' in f_type: return [] # 리스트는 빈 리스트 객체
|
||||||
"""
|
if f_type == "int": return 0
|
||||||
# 조건부 필드는 빈 칸
|
if f_type in ["float", "number"]: return 0.0
|
||||||
if has_condition:
|
if f_type in ["bool", "boolean"]: return False
|
||||||
return None
|
|
||||||
|
|
||||||
# 공통 필드는 타입별 기본값
|
|
||||||
if field_type == "int":
|
|
||||||
return 0
|
|
||||||
elif field_type in ["float", "number"]:
|
|
||||||
return 0.0
|
|
||||||
elif field_type in ["bool", "boolean"]:
|
|
||||||
return False
|
|
||||||
elif field_type == "string":
|
|
||||||
return ""
|
return ""
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def merge_schema_and_data(schema, existing_data):
|
def merge_schema_and_data(schema, existing_data):
|
||||||
"""스키마와 데이터 병합"""
|
|
||||||
|
|
||||||
schema_columns = [f['name'] for f in schema]
|
|
||||||
|
|
||||||
if existing_data is None or existing_data.empty:
|
if existing_data is None or existing_data.empty:
|
||||||
print(" 새 파일 생성")
|
example_row = {f['name']: get_default_value(f['type'], f.get('condition') is not None) for f in schema}
|
||||||
example_row = {}
|
# CSV 저장을 위해 리스트는 문자열로 변환 (빈 값)
|
||||||
for field in schema:
|
for k, v in example_row.items():
|
||||||
has_condition = field.get('condition') is not None
|
if isinstance(v, list): example_row[k] = ""
|
||||||
example_row[field['name']] = get_default_value(field['type'], has_condition)
|
|
||||||
return pd.DataFrame([example_row])
|
return pd.DataFrame([example_row])
|
||||||
|
|
||||||
print(f" 기존 데이터: {len(existing_data)}행")
|
|
||||||
new_df = pd.DataFrame()
|
new_df = pd.DataFrame()
|
||||||
|
|
||||||
for field in schema:
|
for field in schema:
|
||||||
col_name = field['name']
|
col = field['name']
|
||||||
if col_name in existing_data.columns:
|
if col in existing_data.columns:
|
||||||
print(f" ✓ {col_name}: 유지")
|
new_df[col] = existing_data[col]
|
||||||
new_df[col_name] = existing_data[col_name]
|
|
||||||
else:
|
else:
|
||||||
has_condition = field.get('condition') is not None
|
val = get_default_value(field['type'], field.get('condition') is not None)
|
||||||
default_val = get_default_value(field['type'], has_condition)
|
new_df[col] = "" if isinstance(val, list) else val
|
||||||
|
|
||||||
if default_val is None:
|
|
||||||
print(f" + {col_name}: 추가 (조건부 필드, 빈 칸)")
|
|
||||||
else:
|
|
||||||
print(f" + {col_name}: 추가 (기본값: {default_val})")
|
|
||||||
|
|
||||||
new_df[col_name] = default_val
|
|
||||||
|
|
||||||
return new_df
|
return new_df
|
||||||
|
|
||||||
|
|
||||||
def sync_single_schema(data_name, page_id):
|
def sync_single_schema(data_name, page_id):
|
||||||
"""단일 스키마 동기화 (CSV 버전)"""
|
print(f"\n🔄 {data_name} 동기화 시작...")
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f"📋 {data_name} 동기화")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. 스키마 읽기
|
|
||||||
print("1️⃣ 스키마 읽기...")
|
|
||||||
schema = parse_notion_table(page_id)
|
schema = parse_notion_table(page_id)
|
||||||
print(f" ✅ {len(schema)}개 필드")
|
|
||||||
|
|
||||||
# 2. 스키마를 JSON으로 저장 (검증용)
|
# 1. 스키마 JSON 저장 (generate_all_classes.py가 읽을 파일)
|
||||||
import json
|
schema_path = GAMEDATA_DIR / f".{data_name}_schema.json"
|
||||||
schema_json_path = GAMEDATA_DIR / f".{data_name}_schema.json"
|
with open(schema_path, 'w', encoding='utf-8') as f:
|
||||||
with open(schema_json_path, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(schema, f, ensure_ascii=False, indent=2)
|
json.dump(schema, f, ensure_ascii=False, indent=2)
|
||||||
print(f" 💾 스키마 저장: {schema_json_path.name}")
|
|
||||||
|
|
||||||
# 3. 기존 파일 확인
|
# 2. CSV 업데이트
|
||||||
csv_path = GAMEDATA_DIR / f"{data_name}.csv"
|
csv_path = GAMEDATA_DIR / f"{data_name}.csv"
|
||||||
print(f"\n2️⃣ 기존 파일: {csv_path}")
|
existing_data = pd.read_csv(csv_path) if csv_path.exists() else None
|
||||||
|
|
||||||
existing_data = None
|
if existing_data is not None:
|
||||||
if csv_path.exists():
|
shutil.copy2(csv_path, BACKUP_DIR / f"{data_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv")
|
||||||
# 백업
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
backup_path = BACKUP_DIR / f"{data_name}_{timestamp}.csv"
|
|
||||||
import shutil
|
|
||||||
shutil.copy2(csv_path, backup_path)
|
|
||||||
print(f" 💾 백업: {backup_path.name}")
|
|
||||||
|
|
||||||
existing_data = pd.read_csv(csv_path)
|
|
||||||
|
|
||||||
# 4. 병합
|
|
||||||
print(f"\n3️⃣ 병합 중...")
|
|
||||||
merged_df = merge_schema_and_data(schema, existing_data)
|
merged_df = merge_schema_and_data(schema, existing_data)
|
||||||
|
|
||||||
# 5. 저장 (CSV)
|
|
||||||
print(f"\n4️⃣ 저장...")
|
|
||||||
merged_df.to_csv(csv_path, index=False, encoding='utf-8-sig')
|
merged_df.to_csv(csv_path, index=False, encoding='utf-8-sig')
|
||||||
print(f" ✅ 완료: {csv_path}")
|
print(f" ✅ 완료: {data_name} (스키마 및 CSV 업데이트)")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n❌ 오류: {e}")
|
print(f" ❌ 실패: {e}")
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print("=" * 60)
|
print("🚀 Notion Schema Sync Start")
|
||||||
print("🔄 Notion → CSV 동기화 (자동 발견)")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
|
|
||||||
if not NOTION_API_KEY:
|
|
||||||
print("❌ NOTION_API_KEY 환경변수가 없습니다")
|
|
||||||
print("💡 설정 방법:")
|
|
||||||
print(' $env:NOTION_API_KEY = "your_key"')
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"📂 데이터 폴더: {GAMEDATA_DIR}")
|
|
||||||
print(f"💾 백업 폴더: {BACKUP_DIR}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# ⭐ 스키마 자동 발견
|
|
||||||
try:
|
try:
|
||||||
SCHEMA_PAGE_IDS = discover_schema_pages()
|
page_ids = discover_schema_pages()
|
||||||
except Exception as e:
|
if not page_ids: return
|
||||||
print(f"\n❌ 스키마 발견 실패: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not SCHEMA_PAGE_IDS:
|
schemas = list(page_ids.keys())
|
||||||
print("\n❌ 발견된 스키마 페이지가 없습니다.")
|
|
||||||
return
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 60)
|
|
||||||
print("동기화할 스키마를 선택하세요:")
|
|
||||||
|
|
||||||
schemas = list(SCHEMA_PAGE_IDS.keys())
|
|
||||||
for idx, name in enumerate(schemas, 1):
|
for idx, name in enumerate(schemas, 1):
|
||||||
print(f" {idx}. {name}")
|
print(f" {idx}. {name}")
|
||||||
print(f" {len(schemas) + 1}. 전체")
|
print(f" {len(schemas) + 1}. 전체")
|
||||||
print()
|
|
||||||
|
|
||||||
try:
|
choice = input("\n번호 선택: ").strip()
|
||||||
choice = input("선택 (번호 입력): ").strip()
|
selected = schemas if choice == str(len(schemas) + 1) else [schemas[int(choice)-1]]
|
||||||
|
|
||||||
if choice == str(len(schemas) + 1):
|
for name in selected:
|
||||||
selected = schemas
|
sync_single_schema(name, page_ids[name])
|
||||||
else:
|
|
||||||
idx = int(choice) - 1
|
|
||||||
if 0 <= idx < len(schemas):
|
|
||||||
selected = [schemas[idx]]
|
|
||||||
else:
|
|
||||||
print("❌ 잘못된 선택입니다.")
|
|
||||||
return
|
|
||||||
|
|
||||||
except (ValueError, KeyboardInterrupt):
|
|
||||||
print("\n⚠️ 취소되었습니다.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 동기화 실행
|
|
||||||
print()
|
|
||||||
success_count = 0
|
|
||||||
|
|
||||||
for schema_name in selected:
|
|
||||||
page_id = SCHEMA_PAGE_IDS[schema_name]
|
|
||||||
|
|
||||||
if sync_single_schema(schema_name, page_id):
|
|
||||||
success_count += 1
|
|
||||||
|
|
||||||
# 최종 결과
|
|
||||||
print()
|
|
||||||
print("=" * 60)
|
|
||||||
print(f"✅ 완료: {success_count}/{len(selected)} 성공")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
if success_count > 0:
|
|
||||||
print()
|
|
||||||
print("💡 다음 단계:")
|
|
||||||
print(" 1. GameData 폴더에서 CSV 파일 확인")
|
|
||||||
print(" 2. 데이터 수정")
|
|
||||||
print(" 3. Git 커밋:")
|
|
||||||
print(" git add GameData/*.csv")
|
|
||||||
print(' git commit -m "Update data from Notion"')
|
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"오류 발생: {e}")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
Reference in New Issue
Block a user