using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net.Http; using System.Text; using System.Threading.Tasks; using UnityEditor; using UnityEngine; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace Streamingle.Background.Editor { /// /// Notion 배경 씬 동기화 매니저 /// public class NotionBackgroundSync { private const string NOTION_API_VERSION = "2022-06-28"; private const string NOTION_API_URL = "https://api.notion.com/v1"; private readonly NotionSyncSettings _settings; private readonly BackgroundSceneDatabase _database; private readonly HttpClient _httpClient; public event Action OnProgress; public event Action OnError; public event Action OnCompleted; public NotionBackgroundSync(NotionSyncSettings settings, BackgroundSceneDatabase database) { _settings = settings; _database = database; _httpClient = new HttpClient(); _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_settings.notionApiToken}"); _httpClient.DefaultRequestHeaders.Add("Notion-Version", NOTION_API_VERSION); } /// /// 모든 배경 씬을 Notion에 동기화 /// public async Task SyncAllToNotion() { if (!_settings.IsValid()) { OnError?.Invoke("Notion 설정이 올바르지 않습니다. 설정을 확인하세요."); return; } try { OnProgress?.Invoke("데이터베이스 속성 확인 중..."); // 0. 데이터베이스 속성 확인 및 생성 await EnsureDatabaseProperties(); OnProgress?.Invoke("기존 Notion 항목 조회 중..."); // 1. 기존 Notion 데이터베이스 항목 조회 var existingPages = await GetExistingPages(); int total = _database.scenes.Count; int current = 0; foreach (var sceneInfo in _database.scenes) { current++; OnProgress?.Invoke($"동기화 중: {sceneInfo.sceneName} ({current}/{total})"); // 기존 페이지가 있는지 확인 string existingPageId = FindExistingPage(existingPages, sceneInfo.scenePath); if (existingPageId != null) { // 업데이트 await UpdatePage(existingPageId, sceneInfo); } else { // 새로 생성 await CreatePage(sceneInfo); } // API Rate Limit 방지 await Task.Delay(350); } OnProgress?.Invoke("동기화 완료!"); OnCompleted?.Invoke(); } catch (Exception ex) { OnError?.Invoke($"동기화 실패: {ex.Message}"); UnityEngine.Debug.LogException(ex); } } /// /// 단일 배경 씬을 Notion에 동기화 /// public async Task SyncSingleToNotion(BackgroundSceneInfo sceneInfo) { if (!_settings.IsValid()) { OnError?.Invoke("Notion 설정이 올바르지 않습니다."); return; } try { OnProgress?.Invoke("데이터베이스 속성 확인 중..."); await EnsureDatabaseProperties(); OnProgress?.Invoke($"동기화 중: {sceneInfo.sceneName}"); var existingPages = await GetExistingPages(); string existingPageId = FindExistingPage(existingPages, sceneInfo.scenePath); if (existingPageId != null) { await UpdatePage(existingPageId, sceneInfo); } else { await CreatePage(sceneInfo); } OnProgress?.Invoke("동기화 완료!"); OnCompleted?.Invoke(); } catch (Exception ex) { OnError?.Invoke($"동기화 실패: {ex.Message}"); } } /// /// 데이터베이스 속성 확인 및 누락된 속성 생성 /// private async Task EnsureDatabaseProperties() { // 데이터베이스 정보 조회 var response = await _httpClient.GetAsync($"{NOTION_API_URL}/databases/{_settings.notionDatabaseId}"); var responseText = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { throw new Exception($"데이터베이스 조회 실패: {responseText}"); } var dbInfo = JObject.Parse(responseText); var existingProperties = dbInfo["properties"] as JObject; // 필요한 속성들 정의 var requiredProperties = new Dictionary { // Title 속성은 이미 존재하므로 이름만 확인 ["카테고리"] = new JObject { ["select"] = new JObject { ["options"] = new JArray() } }, ["씬 경로"] = new JObject { ["rich_text"] = new JObject() }, ["폴더 이름"] = new JObject { ["rich_text"] = new JObject() }, ["썸네일"] = new JObject { ["url"] = new JObject() }, ["마지막 동기화"] = new JObject { ["date"] = new JObject() }, ["마지막 사용"] = new JObject { ["date"] = new JObject() } }; // 누락된 속성 찾기 var propertiesToAdd = new JObject(); foreach (var prop in requiredProperties) { bool found = false; foreach (var existing in existingProperties) { if (existing.Key == prop.Key) { found = true; break; } } if (!found) { propertiesToAdd[prop.Key] = prop.Value; UnityEngine.Debug.Log($"[Notion] 속성 추가 예정: {prop.Key}"); } } // 누락된 속성이 있으면 추가 if (propertiesToAdd.Count > 0) { OnProgress?.Invoke($"누락된 속성 {propertiesToAdd.Count}개 생성 중..."); var updateBody = new JObject { ["properties"] = propertiesToAdd }; var content = new StringContent(updateBody.ToString(), Encoding.UTF8, "application/json"); var request = new HttpRequestMessage(new HttpMethod("PATCH"), $"{NOTION_API_URL}/databases/{_settings.notionDatabaseId}") { Content = content }; var updateResponse = await _httpClient.SendAsync(request); var updateResponseText = await updateResponse.Content.ReadAsStringAsync(); if (!updateResponse.IsSuccessStatusCode) { UnityEngine.Debug.LogWarning($"[Notion] 속성 추가 실패 (무시하고 계속): {updateResponseText}"); } else { UnityEngine.Debug.Log($"[Notion] 데이터베이스 속성 {propertiesToAdd.Count}개 추가됨"); } } // Title 속성 이름 확인 (기본 "이름" 또는 "Name") _titlePropertyName = FindTitlePropertyName(existingProperties); UnityEngine.Debug.Log($"[Notion] Title 속성 이름: {_titlePropertyName}"); } private string _titlePropertyName = "이름"; /// /// Title 타입 속성의 이름 찾기 /// private string FindTitlePropertyName(JObject properties) { foreach (var prop in properties) { var propType = prop.Value["type"]?.Value(); if (propType == "title") { return prop.Key; } } return "이름"; // 기본값 } /// /// 기존 Notion 페이지 목록 조회 /// private async Task> GetExistingPages() { var pages = new List(); string cursor = null; do { var requestBody = new JObject { ["page_size"] = 100 }; if (cursor != null) { requestBody["start_cursor"] = cursor; } var content = new StringContent(requestBody.ToString(), Encoding.UTF8, "application/json"); var response = await _httpClient.PostAsync( $"{NOTION_API_URL}/databases/{_settings.notionDatabaseId}/query", content); var responseText = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { throw new Exception($"Notion API 오류: {responseText}"); } var result = JObject.Parse(responseText); var results = result["results"] as JArray; if (results != null) { foreach (var page in results) { pages.Add(page as JObject); } } cursor = result["has_more"]?.Value() == true ? result["next_cursor"]?.Value() : null; } while (cursor != null); return pages; } /// /// 씬 경로로 기존 페이지 찾기 /// private string FindExistingPage(List pages, string scenePath) { foreach (var page in pages) { var properties = page["properties"] as JObject; if (properties == null) continue; // "씬 경로" 또는 "ScenePath" 속성에서 검색 var pathProperty = properties["씬 경로"] ?? properties["ScenePath"]; if (pathProperty == null) continue; var richText = pathProperty["rich_text"] as JArray; if (richText != null && richText.Count > 0) { var text = richText[0]["plain_text"]?.Value(); if (text == scenePath) { return page["id"]?.Value(); } } } return null; } /// /// 새 페이지 생성 /// private async Task CreatePage(BackgroundSceneInfo sceneInfo) { var properties = BuildProperties(sceneInfo); var requestBody = new JObject { ["parent"] = new JObject { ["database_id"] = _settings.notionDatabaseId }, ["properties"] = properties }; // 썸네일이 있으면 커버 이미지로 추가 if (!string.IsNullOrEmpty(sceneInfo.thumbnailPath)) { // Git 커밋 상태 확인 bool isCommitted = CheckIfFileIsCommitted(sceneInfo.thumbnailPath); if (!isCommitted) { UnityEngine.Debug.LogWarning($"[Notion] 썸네일이 Git에 커밋되지 않음: {sceneInfo.thumbnailPath}\n" + "Notion에서 이미지가 표시되지 않을 수 있습니다. Git commit & push 후 다시 동기화하세요."); } string imageUrl = _settings.GetGitRawUrl(sceneInfo.thumbnailPath); requestBody["cover"] = new JObject { ["type"] = "external", ["external"] = new JObject { ["url"] = imageUrl } }; } var content = new StringContent(requestBody.ToString(), Encoding.UTF8, "application/json"); var response = await _httpClient.PostAsync($"{NOTION_API_URL}/pages", content); if (!response.IsSuccessStatusCode) { var responseText = await response.Content.ReadAsStringAsync(); throw new Exception($"페이지 생성 실패: {responseText}"); } UnityEngine.Debug.Log($"[Notion] 페이지 생성됨: {sceneInfo.sceneName}"); } /// /// 기존 페이지 업데이트 /// private async Task UpdatePage(string pageId, BackgroundSceneInfo sceneInfo) { var properties = BuildProperties(sceneInfo); var requestBody = new JObject { ["properties"] = properties }; // 썸네일이 있으면 커버 이미지도 업데이트 if (!string.IsNullOrEmpty(sceneInfo.thumbnailPath)) { // Git 커밋 상태 확인 bool isCommitted = CheckIfFileIsCommitted(sceneInfo.thumbnailPath); if (!isCommitted) { UnityEngine.Debug.LogWarning($"[Notion] 썸네일이 Git에 커밋되지 않음: {sceneInfo.thumbnailPath}\n" + "Notion에서 이미지가 표시되지 않을 수 있습니다. Git commit & push 후 다시 동기화하세요."); } string imageUrl = _settings.GetGitRawUrl(sceneInfo.thumbnailPath); requestBody["cover"] = new JObject { ["type"] = "external", ["external"] = new JObject { ["url"] = imageUrl } }; } var content = new StringContent(requestBody.ToString(), Encoding.UTF8, "application/json"); var request = new HttpRequestMessage(new HttpMethod("PATCH"), $"{NOTION_API_URL}/pages/{pageId}") { Content = content }; var response = await _httpClient.SendAsync(request); if (!response.IsSuccessStatusCode) { var responseText = await response.Content.ReadAsStringAsync(); throw new Exception($"페이지 업데이트 실패: {responseText}"); } UnityEngine.Debug.Log($"[Notion] 페이지 업데이트됨: {sceneInfo.sceneName}"); } /// /// Notion 속성 빌드 /// private JObject BuildProperties(BackgroundSceneInfo sceneInfo) { var properties = new JObject(); // 이름 (Title) - 동적으로 찾은 속성 이름 사용 properties[_titlePropertyName] = new JObject { ["title"] = new JArray { new JObject { ["text"] = new JObject { ["content"] = sceneInfo.sceneName } } } }; // 카테고리 (Select) string category = ExtractCategory(sceneInfo.categoryName); properties["카테고리"] = new JObject { ["select"] = new JObject { ["name"] = category } }; // 씬 경로 (Rich Text) properties["씬 경로"] = new JObject { ["rich_text"] = new JArray { new JObject { ["text"] = new JObject { ["content"] = sceneInfo.scenePath } } } }; // 썸네일 URL (URL) if (!string.IsNullOrEmpty(sceneInfo.thumbnailPath)) { string imageUrl = _settings.GetGitRawUrl(sceneInfo.thumbnailPath); properties["썸네일"] = new JObject { ["url"] = imageUrl }; } // 폴더 이름 (Rich Text) properties["폴더 이름"] = new JObject { ["rich_text"] = new JArray { new JObject { ["text"] = new JObject { ["content"] = sceneInfo.categoryName } } } }; // 마지막 동기화 (Date) properties["마지막 동기화"] = new JObject { ["date"] = new JObject { ["start"] = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss") } }; return properties; } /// /// 카테고리 이름에서 태그 부분 추출 (예: "[공용]농구장" → "공용") /// private string ExtractCategory(string folderName) { if (string.IsNullOrEmpty(folderName)) return "기타"; int startBracket = folderName.IndexOf('['); int endBracket = folderName.IndexOf(']'); if (startBracket >= 0 && endBracket > startBracket) { return folderName.Substring(startBracket + 1, endBracket - startBracket - 1); } return folderName; } /// /// 파일이 Git에 커밋되어 있는지 확인 /// /// Unity Asset 경로 (예: Assets/ResourcesData/...) /// 커밋 여부 (true: 커밋됨, false: 커밋 안됨 또는 오류) private bool CheckIfFileIsCommitted(string assetPath) { try { // Unity 프로젝트 루트 경로 string projectPath = Path.GetDirectoryName(Application.dataPath); string filePath = Path.Combine(projectPath, assetPath).Replace("\\", "/"); // 파일이 존재하는지 먼저 확인 if (!File.Exists(filePath)) { return false; } // git ls-files로 파일이 추적되고 있는지 확인 var lsFilesProcess = new Process { StartInfo = new ProcessStartInfo { FileName = "git", Arguments = $"ls-files \"{assetPath}\"", WorkingDirectory = projectPath, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8 } }; lsFilesProcess.Start(); string lsFilesOutput = lsFilesProcess.StandardOutput.ReadToEnd().Trim(); lsFilesProcess.WaitForExit(); // 파일이 Git에 추적되고 있지 않으면 if (string.IsNullOrEmpty(lsFilesOutput)) { return false; } // git status로 변경 사항 확인 (커밋되지 않은 변경이 있는지) var statusProcess = new Process { StartInfo = new ProcessStartInfo { FileName = "git", Arguments = $"status --porcelain \"{assetPath}\"", WorkingDirectory = projectPath, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8 } }; statusProcess.Start(); string statusOutput = statusProcess.StandardOutput.ReadToEnd().Trim(); statusProcess.WaitForExit(); // status 출력이 비어있으면 커밋된 상태 (변경 없음) // 출력이 있으면 modified/added/untracked 등의 상태 return string.IsNullOrEmpty(statusOutput); } catch (Exception ex) { UnityEngine.Debug.LogWarning($"[Notion] Git 상태 확인 실패: {ex.Message}"); return false; } } /// /// 사용 이력 기록 (Notion에) /// public async Task RecordUsage(BackgroundSceneInfo sceneInfo) { if (!_settings.trackUsageHistory || !_settings.IsValid()) return; try { var existingPages = await GetExistingPages(); string pageId = FindExistingPage(existingPages, sceneInfo.scenePath); if (pageId != null) { // "마지막 사용" 날짜 업데이트 var properties = new JObject { ["마지막 사용"] = new JObject { ["date"] = new JObject { ["start"] = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss") } } }; var requestBody = new JObject { ["properties"] = properties }; var content = new StringContent(requestBody.ToString(), Encoding.UTF8, "application/json"); var request = new HttpRequestMessage(new HttpMethod("PATCH"), $"{NOTION_API_URL}/pages/{pageId}") { Content = content }; await _httpClient.SendAsync(request); UnityEngine.Debug.Log($"[Notion] 사용 이력 기록됨: {sceneInfo.sceneName}"); } } catch (Exception ex) { UnityEngine.Debug.LogWarning($"[Notion] 사용 이력 기록 실패: {ex.Message}"); } } public void Dispose() { _httpClient?.Dispose(); } } /// /// Notion 동기화 에디터 윈도우 /// public class NotionSyncWindow : EditorWindow { private NotionSyncSettings _settings; private BackgroundSceneDatabase _database; private string _statusMessage = ""; private bool _isSyncing; private float _progress; [MenuItem("Streamingle/Notion Background Sync")] public static void ShowWindow() { var window = GetWindow("Notion 동기화"); window.minSize = new Vector2(400, 350); window.Show(); } private void OnEnable() { LoadSettings(); } private void LoadSettings() { _settings = AssetDatabase.LoadAssetAtPath( "Assets/Resources/Settings/NotionSyncSettings.asset"); _database = AssetDatabase.LoadAssetAtPath( "Assets/Resources/Settings/BackgroundSceneDatabase.asset"); } private void OnGUI() { EditorGUILayout.Space(10); EditorGUILayout.LabelField("Notion 배경 씬 동기화", EditorStyles.boldLabel); EditorGUILayout.Space(10); // 설정 확인 if (_settings == null) { EditorGUILayout.HelpBox("NotionSyncSettings을 찾을 수 없습니다.\n" + "Assets/Resources/Settings/NotionSyncSettings.asset을 생성하세요.", MessageType.Warning); if (GUILayout.Button("설정 파일 생성")) { CreateSettingsFile(); } return; } // 설정 표시 EditorGUILayout.BeginVertical(EditorStyles.helpBox); EditorGUILayout.LabelField("설정", EditorStyles.boldLabel); EditorGUI.BeginChangeCheck(); _settings.notionApiToken = EditorGUILayout.PasswordField("Notion API Token", _settings.notionApiToken); _settings.notionDatabaseId = EditorGUILayout.TextField("Database ID", _settings.notionDatabaseId); EditorGUILayout.Space(5); _settings.gitServerUrl = EditorGUILayout.TextField("Git Server URL", _settings.gitServerUrl); _settings.gitRepoPath = EditorGUILayout.TextField("Repo Path", _settings.gitRepoPath); _settings.gitBranch = EditorGUILayout.TextField("Branch", _settings.gitBranch); EditorGUILayout.Space(5); _settings.autoSyncOnCapture = EditorGUILayout.Toggle("썸네일 캡처 시 자동 동기화", _settings.autoSyncOnCapture); _settings.trackUsageHistory = EditorGUILayout.Toggle("사용 이력 추적", _settings.trackUsageHistory); if (EditorGUI.EndChangeCheck()) { EditorUtility.SetDirty(_settings); AssetDatabase.SaveAssets(); } EditorGUILayout.EndVertical(); // 유효성 검사 EditorGUILayout.Space(10); if (!_settings.HasNotionToken) { EditorGUILayout.HelpBox("Notion API Token이 필요합니다.\n" + "https://www.notion.so/my-integrations 에서 발급하세요.", MessageType.Warning); } if (!_settings.HasValidDatabaseId) { EditorGUILayout.HelpBox("Notion Database ID가 필요합니다.\n" + "데이터베이스 URL에서 32자리 ID를 복사하세요.", MessageType.Warning); } // Git URL 미리보기 EditorGUILayout.Space(10); EditorGUILayout.LabelField("이미지 URL 예시:", EditorStyles.boldLabel); string exampleUrl = _settings.GetGitRawUrl("Assets/ResourcesData/Background/Example/Scene/Example.png"); EditorGUILayout.SelectableLabel(exampleUrl, EditorStyles.miniLabel, GUILayout.Height(20)); // 동기화 버튼 EditorGUILayout.Space(20); GUI.enabled = _settings.IsValid() && !_isSyncing && _database != null; if (GUILayout.Button("전체 동기화", GUILayout.Height(35))) { SyncAll(); } GUI.enabled = true; // 상태 메시지 if (!string.IsNullOrEmpty(_statusMessage)) { EditorGUILayout.Space(10); EditorGUILayout.HelpBox(_statusMessage, MessageType.Info); } // 데이터베이스 정보 if (_database != null) { EditorGUILayout.Space(10); EditorGUILayout.LabelField($"배경 씬 수: {_database.scenes.Count}개", EditorStyles.miniLabel); } } private void CreateSettingsFile() { string dirPath = "Assets/Resources/Settings"; if (!Directory.Exists(dirPath)) { Directory.CreateDirectory(dirPath); } _settings = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(_settings, $"{dirPath}/NotionSyncSettings.asset"); AssetDatabase.SaveAssets(); UnityEngine.Debug.Log("NotionSyncSettings 파일이 생성되었습니다."); } private async void SyncAll() { _isSyncing = true; _statusMessage = "동기화 시작..."; Repaint(); var sync = new NotionBackgroundSync(_settings, _database); sync.OnProgress += (msg) => { _statusMessage = msg; Repaint(); }; sync.OnError += (msg) => { _statusMessage = $"오류: {msg}"; _isSyncing = false; Repaint(); }; sync.OnCompleted += () => { _statusMessage = "동기화 완료!"; _isSyncing = false; Repaint(); }; await sync.SyncAllToNotion(); sync.Dispose(); } } }