ProjectDDD/Assets/0.Datas/02.Scripts/GenerateGoogleSheet/Core/GoogldSheetManager.cs

690 lines
23 KiB (Stored with Git LFS)
C#

using System;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using System.Reflection;
using System.Collections.Generic;
using System.Collections;
using UnityEngine;
using Newtonsoft.Json.Linq;
using System.Linq;
using Sirenix.OdinInspector;
using UnityEditor;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class GoogleSheetManager : Singleton<GoogleSheetManager>
{
[BoxGroup("기본 설정")] [Tooltip("true: google sheet, false: local json")] [SerializeField]
private bool _isAccessGoogleSheet = true;
[BoxGroup("기본 설정")]
[Tooltip("구글 시트 -> 확장 프로그램 -> Apps Script -> 새 배포(웹 앱) or 배포 관리 -> 웹 앱 URL(~~~/exec)")]
[SerializeField]
private string _googleSheetUrl;
[BoxGroup("기본 설정")]
[Tooltip("적용시킬 시트의 이름을 적고, 여러 개의 시트를 적는 경우 '/'로 구분지어 시트 나열\nex) \"Sheet1/Sheet2\"")]
[SerializeField]
private string _availSheets = "Sheet1/Sheet2";
[BoxGroup("기본 설정")] [Tooltip("Class, Json, So 생성 위치 \"/GenerateGoogleSheet\"")] [SerializeField]
private string _generateFolderPath = "/0.Datas/02.Scripts/GenerateGoogleSheet/AutoCreated";
[Title("버전 복구")] [BoxGroup("버전 복구")] [Tooltip("마지막 업데이트 시간")] [SerializeField, ReadOnly]
private string _lastUpdated;
#if UNITY_EDITOR
[BoxGroup("버전 복구")] [SerializeField, ValueDropdown(nameof(GetVersionOptions))]
private int _restoreIndex;
#endif
[Title("데이터 변경")]
[BoxGroup("데이터 변경")]
[LabelText("수정자 이름")]
[SerializeField, Required("반드시 수정자 이름을 입력해야 합니다\n이력을 남길 때 표시될 사용자 이름입니다.")]
private string _editorName;
private string JsonPath => $"{Application.dataPath}{_generateFolderPath}/GoogleSheetJson.json";
private const string ChangeLogPath = "Assets/0.Datas/02.Scripts/GenerateGoogleSheet/Logs/GoogleSheetChangeLog.asset";
private string[] _availSheetArray;
private string _json;
[SerializeField, ReadOnly]
private bool _refreshTrigger;
private bool _alreadyCreatedSo;
public static async Task<T> LoadSo<T>() where T : ScriptableObject
{
await Addressables.InitializeAsync().Task;
string key = typeof(T).Name;
var handle = Addressables.LoadAssetAsync<T>(key);
await handle.Task;
if (handle.Status == AsyncOperationStatus.Succeeded)
return handle.Result;
Debug.LogError($"[GoogleSheetManager] Addressable 로드 실패: {key}");
return null;
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
[BoxGroup("데이터 변경")]
[Button("데이터 최신화"), EnableIf(nameof(CanFetchData))]
private async void FetchGoogleSheet()
{
_availSheetArray = _availSheets.Split('/');
var prevLog = AssetDatabase.LoadAssetAtPath<GoogleSheetChangeLog>(ChangeLogPath);
string previousJson = prevLog?.Logs.LastOrDefault()?.JsonSnapshot ?? "";
if (_isAccessGoogleSheet)
{
if (!IsValidGoogleSheetUrl(_googleSheetUrl))
{
Debug.LogError("Google Sheet URL이 유효하지 않습니다.");
return;
}
Debug.Log("구글 시트 데이터 읽는 중...");
_json = await LoadDataGoogleSheet(_googleSheetUrl);
}
else
{
Debug.Log("Local Json 파일 읽는 중...");
_json = LoadDataLocalJson();
}
if (_json == null)
{
Debug.Log("Json is null. 최신화 실패");
return;
}
var diffs = GoogleSheetFetchHelper.CompareJsonDiff(previousJson, _json);
if (diffs.Count > 0)
GoogleSheetDiffViewer.ShowWindow(diffs);
bool isJsonSaved = SaveFileOrSkip(JsonPath, _json);
GenerateClassFilesPerSheet(_json);
_lastUpdated = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
if (isJsonSaved)
{
_refreshTrigger = true;
SaveChangeLog(_json);
EditorPrefs.SetBool("GoogleSheetManager_ShouldCreateSO", true);
AssetDatabase.Refresh();
}
}
private bool CanFetchData()
{
return !string.IsNullOrWhiteSpace(_editorName);
}
/// <summary>
/// Json 로그 저장
/// </summary>
private void SaveChangeLog(string json)
{
string logsDirectory = Path.GetDirectoryName(ChangeLogPath);
if (!Directory.Exists(logsDirectory))
{
Directory.CreateDirectory(logsDirectory);
AssetDatabase.ImportAsset(logsDirectory);
}
var log = AssetDatabase.LoadAssetAtPath<GoogleSheetChangeLog>(ChangeLogPath);
if (log == null)
{
log = ScriptableObject.CreateInstance<GoogleSheetChangeLog>();
AssetDatabase.CreateAsset(log, ChangeLogPath);
}
string previousJson = log.Logs.Count > 0 ? log.Logs[^1].JsonSnapshot : null;
// 차이 비교
if (!string.IsNullOrEmpty(previousJson))
{
string diffResult = GoogleSheetDiffHelper.GenerateDiff(previousJson, json);
Debug.Log(diffResult);
}
string saveTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
log.Logs.Add(new GoogleSheetChangeLog.LogEntry
{
Editor = _editorName,
Timestamp = saveTime,
JsonSnapshot = json
});
EditorUtility.SetDirty(log);
AssetDatabase.SaveAssets();
SaveJsonBackup(json, saveTime);
_editorName = null;
}
/// <summary>
/// Json 백업
/// </summary>
private void SaveJsonBackup(string json, string saveTime)
{
string safeSaveTime = saveTime.Replace(":", "-"); // 윈도우 파일 이름 안전 처리
string folderPath = Path.Combine(Application.dataPath, "0.Datas/02.Scripts/GenerateGoogleSheet/Backups");
if (!Directory.Exists(folderPath))
Directory.CreateDirectory(folderPath);
string fileName = $"{safeSaveTime} by {_editorName}.json";
string filePath = Path.Combine(folderPath, fileName);
File.WriteAllText(filePath, json);
}
[BoxGroup("버전 복구")]
[Button("선택한 버전과 현재 비교")]
private void CompareWithSelectedVersion()
{
var log = AssetDatabase.LoadAssetAtPath<GoogleSheetChangeLog>(ChangeLogPath);
if (log == null || _restoreIndex < 0 || _restoreIndex >= log.Logs.Count)
{
Debug.LogWarning("비교할 수 있는 로그가 없습니다.");
return;
}
string restoreJson = log.Logs[_restoreIndex].JsonSnapshot;
string currentJson = File.Exists(JsonPath) ? File.ReadAllText(JsonPath) : "";
List<GoogleSheetDiff> diffs = GoogleSheetFetchHelper.CompareJsonDiff(currentJson, restoreJson);
if (diffs.Count > 0)
{
GoogleSheetDiffViewer.ShowWindow(diffs, true); // 현재 → 선택 버전
Debug.Log("[GoogleSheetManager] 선택한 버전과 현재 버전 간의 변경점을 표시합니다.");
}
else
{
Debug.Log("[GoogleSheetManager] 변경점 없음.");
}
}
[BoxGroup("버전 복구")]
[Button("선택한 버전으로 복구")]
private void RestoreSelectedVersion()
{
var log = AssetDatabase.LoadAssetAtPath<GoogleSheetChangeLog>(ChangeLogPath);
if (log == null || _restoreIndex < 0 || _restoreIndex >= log.Logs.Count)
{
Debug.LogWarning("복원할 수 있는 로그가 없습니다.");
return;
}
string restoreJson = log.Logs[_restoreIndex].JsonSnapshot;
string currentJson = File.Exists(JsonPath) ? File.ReadAllText(JsonPath) : "";
List<GoogleSheetDiff> diffs = GoogleSheetFetchHelper.CompareJsonDiff(currentJson, restoreJson);
if (diffs.Count > 0)
GoogleSheetDiffViewer.ShowWindow(diffs); // 변경 전 → 변경 후
// 복원 처리
_json = restoreJson;
SaveFileOrSkip(JsonPath, _json);
CreateGoogleSheetSo();
_lastUpdated = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
Debug.Log($"[{log.Logs[_restoreIndex].Editor}]의 버전으로 복원 완료");
}
/// <summary>
/// 버전 로그 드롭다운 함수
/// </summary>
private IEnumerable<ValueDropdownItem<int>> GetVersionOptions()
{
var log = AssetDatabase.LoadAssetAtPath<GoogleSheetChangeLog>(ChangeLogPath);
if (log == null)
yield break;
for (int i = 0; i < log.Logs.Count; i++)
{
yield return new ValueDropdownItem<int>(
$"{i} - {log.Logs[i].Timestamp} by {log.Logs[i].Editor}", i);
}
}
/// <summary>
/// 구글 시트 데이터 읽어오기
/// </summary>
private async Task<string> LoadDataGoogleSheet(string url)
{
using (HttpClient client = new HttpClient())
{
try
{
byte[] dataBytes = await client.GetByteArrayAsync(url);
return Encoding.UTF8.GetString(dataBytes);
}
catch (HttpRequestException e)
{
Debug.LogError($"Request error: {e.Message}");
return null;
}
}
}
/// <summary>
/// jSON 데이터 파일 읽어오기
/// </summary>
private string LoadDataLocalJson()
{
if (File.Exists(JsonPath))
{
return File.ReadAllText(JsonPath);
}
Debug.Log($"Json 파일이 존재하지 않습니다\n{JsonPath}");
return null;
}
/// <summary>
/// 파일 생성 및 비교
/// </summary>
private bool SaveFileOrSkip(string path, string contents)
{
string directoryPath = Path.GetDirectoryName(path);
if (!Directory.Exists(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
if (File.Exists(path) && File.ReadAllText(path).Equals(contents))
return false;
File.WriteAllText(path, contents);
return true;
}
private bool IsExistAvailSheets(string sheetName)
{
return Array.Exists(_availSheetArray, x => x == sheetName);
}
/// <summary>
/// 유효한 구글 웹 앱 URL인지 확인
/// </summary>
private bool IsValidGoogleSheetUrl(string url)
{
return !string.IsNullOrEmpty(url)
&& url.StartsWith("https://script.google.com/macros/")
&& url.EndsWith("/exec");
}
private void GenerateClassFilesPerSheet(string jsonInput)
{
JObject jsonObject = JObject.Parse(jsonInput);
string basePath = $"Assets{_generateFolderPath}";
// enum 후보 수집
Dictionary<string, HashSet<string>> enumCandidates = new();
foreach (var jObject in jsonObject)
{
string className = jObject.Key;
if (!IsExistAvailSheets(className)) continue;
var items = (JArray)jObject.Value;
if (items.Count < 2) continue;
for (int i = 1; i < items.Count; i++)
{
foreach (var property in ((JObject)items[i]).Properties())
{
string rawName = property.Name;
if (!rawName.Contains("_Enum")) continue;
string[] parts = rawName.Split(':');
string enumTypeName =
parts.Length > 1 ? parts[1].Replace("_Enum", "") : rawName.Replace("_Enum", "");
string enumValue = NormalizeEnumKey(property.Value.ToString());
if (!enumCandidates.ContainsKey(enumTypeName))
enumCandidates[enumTypeName] = new();
enumCandidates[enumTypeName].Add(enumValue);
}
}
}
// EnumTypes.cs 생성
StringBuilder enumCode = new();
enumCode.AppendLine("// <auto-generated>");
enumCode.AppendLine("using System;\n");
foreach (var kvp in enumCandidates)
{
enumCode.AppendLine($"public enum {kvp.Key} \n{{");
enumCode.AppendLine(" None = 0,");
int index = 1;
foreach (string value in kvp.Value)
{
if (!string.IsNullOrWhiteSpace(value) && value != "None")
enumCode.AppendLine($" {value} = {index++},");
}
enumCode.AppendLine("}\n");
}
File.WriteAllText($"{basePath}/EnumTypes.cs", enumCode.ToString());
AssetDatabase.ImportAsset($"{basePath}/EnumTypes.cs");
// 시트별 클래스 파일 생성
foreach (var jObject in jsonObject)
{
string className = jObject.Key;
if (!IsExistAvailSheets(className)) continue;
var items = (JArray)jObject.Value;
if (items.Count < 2) continue;
string dataCode = GenerateDataClassCode(className, items);
string soCode = GenerateSoClassCode(className);
string dataPath = $"{basePath}/{className}.cs";
string soPath = $"{basePath}/{className}So.cs";
File.WriteAllText(dataPath, dataCode);
File.WriteAllText(soPath, soCode);
AssetDatabase.ImportAsset(dataPath);
AssetDatabase.ImportAsset(soPath);
}
}
private string GenerateSoClassCode(string className)
{
return
$"// <auto-generated> File: {className}So.cs\n" +
"using System.Collections.Generic;\n" +
"using UnityEngine;\n\n" +
$"[CreateAssetMenu(fileName = \"{className}So\", menuName = \"GoogleSheet/{className}So\")]\n" +
$"public class {className}So : ScriptableObject \n" +
$"{{\n public List<{className}> {className}List;\n}}\n";
}
private string GenerateDataClassCode(string className, JArray items)
{
var commentRow = (JObject)items[0];
var sampleRow = (JObject)items[1];
StringBuilder sb = new();
sb.AppendLine("// <auto-generated>");
sb.AppendLine("using System;");
sb.AppendLine("using UnityEngine;");
sb.AppendLine("[Serializable]");
sb.AppendLine($"public class {className} \n{{");
int count = sampleRow.Properties().Count();
string[] types = new string[count];
string[] names = new string[count];
string[] tooltips = new string[count];
foreach (JToken item in items.Skip(1))
{
int i = 0;
foreach (var prop in ((JObject)item).Properties())
{
string rawName = prop.Name;
string propType = GetCSharpType(prop.Value.Type);
string fieldName, enumName;
if (rawName.Contains(":") && rawName.EndsWith("_Enum"))
{
string[] parts = rawName.Split(':');
fieldName = parts[0];
enumName = parts[1].Replace("_Enum", "");
types[i] = enumName;
names[i] = fieldName;
}
else if (rawName.EndsWith("_Enum"))
{
fieldName = rawName.Replace("_Enum", "");
enumName = fieldName;
types[i] = enumName;
names[i] = fieldName;
}
else
{
types[i] ??= propType;
names[i] ??= rawName;
}
tooltips[i] ??= commentRow.TryGetValue(rawName, out var tip) ? tip.ToString() : "";
i++;
}
}
for (int i = 0; i < count; i++)
{
if (!string.IsNullOrWhiteSpace(tooltips[i]))
{
sb.AppendLine($" /// <summary>{tooltips[i]}</summary>");
sb.AppendLine($" [Tooltip(\"{tooltips[i]}\")]");
}
sb.AppendLine($" public {types[i]} {names[i]};\n");
}
sb.AppendLine("}");
return sb.ToString();
}
private string GetCSharpType(JTokenType jsonType)
{
switch (jsonType)
{
case JTokenType.Integer:
return "int";
case JTokenType.Float:
return "float";
case JTokenType.Boolean:
return "bool";
default:
return "string";
}
}
private bool CreateGoogleSheetSo()
{
JObject jsonObject = JObject.Parse(_json);
bool allSuccess = true;
foreach (var sheetPair in jsonObject)
{
string sheetName = sheetPair.Key;
if (!IsExistAvailSheets(sheetName))
continue;
// 1. 데이터 클래스 및 SO 클래스 타입 찾기
Type dataType = FindTypeByName(sheetName);
Type soType = FindTypeByName($"{sheetName}So");
if (dataType == null || soType == null)
{
Debug.LogError($"[GoogleSheetManager] 타입을 찾을 수 없습니다: {sheetName} 또는 {sheetName}So");
allSuccess = false;
continue;
}
// 2. SO 경로 설정 및 불러오기 / 생성
string soDirectory = $"Assets{_generateFolderPath}/So";
if (!Directory.Exists(soDirectory))
{
Directory.CreateDirectory(soDirectory);
AssetDatabase.ImportAsset(soDirectory);
}
string soPath = $"{soDirectory}/{sheetName}So.asset";
ScriptableObject soInstance = AssetDatabase.LoadAssetAtPath<ScriptableObject>(soPath);
if (soInstance == null)
{
soInstance = ScriptableObject.CreateInstance(soType);
AssetDatabase.CreateAsset(soInstance, soPath);
}
GoogleSheetAddressableAutoSetup.AutoRegisterSo(soPath);
// 3. 데이터 파싱
IList list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(dataType));
var dataArray = (JArray)sheetPair.Value;
for (int i = 1; i < dataArray.Count; i++) // 0번은 주석이므로 제외
{
JObject item = (JObject)dataArray[i];
object dataInstance = Activator.CreateInstance(dataType);
foreach (var prop in item.Properties())
{
string rawName = prop.Name;
string fieldName = rawName.Contains(":") ? rawName.Split(':')[0] : rawName;
FieldInfo field = dataType.GetField(fieldName,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (field == null)
{
Debug.LogWarning($"[GoogleSheetManager] 필드 누락: {dataType.Name}.{fieldName}");
continue;
}
try
{
object value;
if (field.FieldType.IsEnum)
{
string enumRaw = prop.Value.ToString();
string formatted = NormalizeEnumKey(enumRaw);
value = Enum.TryParse(field.FieldType, formatted, out var parsed)
? parsed
: Activator.CreateInstance(field.FieldType);
}
else
{
value = Convert.ChangeType(prop.Value.ToString(), field.FieldType);
}
field.SetValue(dataInstance, value);
}
catch (Exception e)
{
Debug.LogWarning(
$"[GoogleSheetManager] 값 할당 실패: {fieldName} = {prop.Value} ({field.FieldType}) → {e.Message}");
}
}
list.Add(dataInstance);
}
// 4. SO의 필드에 리스트 할당
FieldInfo listField = soType.GetField($"{sheetName}List",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (listField != null)
{
listField.SetValue(soInstance, list);
EditorUtility.SetDirty(soInstance);
}
else
{
Debug.LogError($"[GoogleSheetManager] {soType.Name}에 {sheetName}List 필드가 없습니다.");
allSuccess = false;
}
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("✅ 시트별 ScriptableObject 생성 및 데이터 반영 완료");
return allSuccess;
}
private Type FindTypeByName(string name)
{
return AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes())
.FirstOrDefault(t => t.Name == name);
}
private string NormalizeEnumKey(string input)
{
string validName = System.Text.RegularExpressions.Regex.Replace(input, @"[^a-zA-Z0-9_]", "_");
if (char.IsDigit(validName[0]))
validName = "_" + validName;
return char.ToUpper(validName[0]) + validName.Substring(1);
}
private void OnValidate()
{
if (_refreshTrigger && !_alreadyCreatedSo)
{
_refreshTrigger = false;
_alreadyCreatedSo = true;
DelayedSoCreation();
}
}
private void DelayedSoCreation()
{
if (CreateGoogleSheetSo())
{
Debug.Log("Fetch done. SO 업데이트 완료");
}
else
{
Debug.LogWarning("[GoogleSheetManager] SO 생성 실패. 수동으로 Fetch를 다시 시도하세요.");
}
}
[BoxGroup("데이터 변경")]
[Button("런타임 중 데이터 재적용")]
public void ReloadRuntimeData()
{
StartCoroutine(ReloadRoutine());
}
private IEnumerator ReloadRoutine()
{
_availSheetArray = _availSheets.Split('/');
if (_isAccessGoogleSheet)
{
var task = LoadDataGoogleSheet(_googleSheetUrl);
yield return new WaitUntil(() => task.IsCompleted);
_json = task.Result;
}
else
{
_json = LoadDataLocalJson();
}
if (!string.IsNullOrEmpty(_json))
{
CreateGoogleSheetSo();
Debug.Log("런타임 데이터 재적용 완료");
}
}
public void CreateSoAfterScriptReload()
{
if (_json != null)
{
Debug.Log("[GoogleSheetManager] Script Reload 이후 SO 생성 실행");
CreateGoogleSheetSo();
}
}
#endif
}