using Ghost.Core; using Ghost.Data.Services; using Microsoft.Data.Sqlite; using System.Text.Json; namespace Ghost.Editor.Core.AssetHandle; public static partial class AssetDatabase { private static SqliteConnection? s_dbConnection; /// /// Initialize the SQLite database for asset caching. /// private static async Task InitializeDatabaseAsync() { if (AssetsDirectory == null) { throw new InvalidOperationException("AssetsDirectory is not set. Initialize() must be called first."); } var dbPath = Path.Combine(AssetsDirectory.Parent!.FullName, ProjectService.CACHE_FOLDER, "AssetDatabase.db"); var cacheDir = Path.GetDirectoryName(dbPath); if (!Directory.Exists(cacheDir)) { Directory.CreateDirectory(cacheDir!); } var connectionString = new SqliteConnectionStringBuilder { DataSource = dbPath, Mode = SqliteOpenMode.ReadWriteCreate, Cache = SqliteCacheMode.Shared }.ToString(); s_dbConnection = new SqliteConnection(connectionString); await s_dbConnection.OpenAsync(); // Create tables await using var cmd = s_dbConnection.CreateCommand(); cmd.CommandText = @" CREATE TABLE IF NOT EXISTS Assets ( Guid TEXT PRIMARY KEY, Path TEXT NOT NULL, Version INTEGER NOT NULL, Tags TEXT, FileHash TEXT, DependencyGuids TEXT, IsDirty INTEGER NOT NULL DEFAULT 0, LastModified INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_path ON Assets(Path); CREATE INDEX IF NOT EXISTS idx_dirty ON Assets(IsDirty); "; await cmd.ExecuteNonQueryAsync(); } /// /// Add or update an asset in the database. /// /// Full path to the asset file. /// Asset metadata from .gmeta file. /// SHA256 hash of the asset file content. /// List of GUIDs this asset depends on (extracted during import). private static async ValueTask UpsertAssetAsync(string assetPath, AssetMeta meta, string fileHash, List? dependencies = null, CancellationToken token = default) { if (s_dbConnection == null) { return Result.Failure("Database not initialized"); } var relativePath = GetRelativePath(assetPath); if (relativePath.IsFailure) { return Result.Failure(relativePath.Message); } try { lock (s_dbLock) { // If this GUID already exists with a different path, remove the old path mapping if (s_assetPathLookup.TryGetValue(meta.Guid, out var oldPath) && oldPath != relativePath.Value) { s_pathAssetLookup.Remove(oldPath); } // Update lookups with new path (normalize path separators for consistency) var normalizedPath = relativePath.Value.Replace('\\', '/'); s_assetPathLookup[meta.Guid] = normalizedPath; s_pathAssetLookup[normalizedPath] = meta.Guid; } await using var cmd = s_dbConnection.CreateCommand(); cmd.CommandText = @" INSERT OR REPLACE INTO Assets (Guid, Path, Version, Tags, FileHash, DependencyGuids, LastModified) VALUES (@guid, @path, @version, @tags, @fileHash, @deps, @modified) "; cmd.Parameters.AddWithValue("@guid", meta.Guid.ToString()); cmd.Parameters.AddWithValue("@path", relativePath.Value); cmd.Parameters.AddWithValue("@version", meta.Version); cmd.Parameters.AddWithValue("@tags", JsonSerializer.Serialize(meta.Tags)); cmd.Parameters.AddWithValue("@fileHash", fileHash); cmd.Parameters.AddWithValue("@deps", JsonSerializer.Serialize(dependencies ?? new List())); cmd.Parameters.AddWithValue("@modified", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); await cmd.ExecuteNonQueryAsync(token); return Result.Success(); } catch (Exception ex) { return Result.Failure($"Failed to upsert asset: {ex.Message}"); } } /// /// Remove an asset from the database. /// private static async Task RemoveAssetFromDatabaseAsync(Guid guid) { if (s_dbConnection == null) { return Result.Failure("Database not initialized"); } try { lock (s_dbLock) { if (s_assetPathLookup.TryGetValue(guid, out var path)) { s_assetPathLookup.Remove(guid); s_pathAssetLookup.Remove(path); } } await using var cmd = s_dbConnection.CreateCommand(); cmd.CommandText = "DELETE FROM Assets WHERE Guid = @guid"; cmd.Parameters.AddWithValue("@guid", guid.ToString()); await cmd.ExecuteNonQueryAsync(); return Result.Success(); } catch (Exception ex) { return Result.Failure($"Failed to remove asset: {ex.Message}"); } } /// /// Mark an asset as dirty for re-importing. /// private static async Task MarkAssetDirtyAsync(Guid guid, bool isDirty = true) { if (s_dbConnection == null) { return Result.Failure("Database not initialized"); } try { await using var cmd = s_dbConnection.CreateCommand(); cmd.CommandText = "UPDATE Assets SET IsDirty = @dirty WHERE Guid = @guid"; cmd.Parameters.AddWithValue("@dirty", isDirty ? 1 : 0); cmd.Parameters.AddWithValue("@guid", guid.ToString()); await cmd.ExecuteNonQueryAsync(); return Result.Success(); } catch (Exception ex) { return Result.Failure($"Failed to mark asset dirty: {ex.Message}"); } } /// /// Get all dirty assets that need re-importing. /// private static async Task> GetDirtyAssetsAsync() { var result = new List<(Guid guid, string path)>(); if (s_dbConnection == null) { return result; } try { await using var cmd = s_dbConnection.CreateCommand(); cmd.CommandText = "SELECT Guid, Path FROM Assets WHERE IsDirty = 1"; await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { var guidStr = reader.GetString(0); var path = reader.GetString(1); if (Guid.TryParse(guidStr, out var guid)) { result.Add((guid, path)); } } } catch { // Silently fail - we'll return empty list } return result; } /// /// Load all assets from the database into memory cache. /// private static async Task LoadAssetCacheFromDatabaseAsync() { if (s_dbConnection == null) { return; } try { await using var cmd = s_dbConnection.CreateCommand(); cmd.CommandText = "SELECT Guid, Path FROM Assets"; await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { var guidStr = reader.GetString(0); var path = reader.GetString(1); if (Guid.TryParse(guidStr, out var guid)) { lock (s_dbLock) { s_assetPathLookup[guid] = path; s_pathAssetLookup[path] = guid; } } } } catch (Exception ex) { Console.WriteLine($"Failed to load asset cache: {ex.Message}"); } } /// /// Get assets by tag. /// private static async Task> GetAssetsByTagAsync(string tag) { var result = new List(); if (s_dbConnection == null) { return result; } try { await using var cmd = s_dbConnection.CreateCommand(); cmd.CommandText = "SELECT Guid, Tags FROM Assets"; await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { var guidStr = reader.GetString(0); var tagsJson = reader.GetString(1); if (Guid.TryParse(guidStr, out var guid)) { var tags = JsonSerializer.Deserialize>(tagsJson); if (tags != null && tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) { result.Add(guid); } } } } catch { // Silently fail } return result; } /// /// Get the file hash for an asset from the database. /// private static async Task GetFileHashAsync(Guid guid) { if (s_dbConnection == null) { return null; } try { await using var cmd = s_dbConnection.CreateCommand(); cmd.CommandText = "SELECT FileHash FROM Assets WHERE Guid = @guid"; cmd.Parameters.AddWithValue("@guid", guid.ToString()); var result = await cmd.ExecuteScalarAsync(); return result?.ToString(); } catch { return null; } } /// /// Get the dependencies for an asset from the database. /// private static async Task> GetDependenciesAsync(Guid guid) { if (s_dbConnection == null) { return new List(); } try { await using var cmd = s_dbConnection.CreateCommand(); cmd.CommandText = "SELECT DependencyGuids FROM Assets WHERE Guid = @guid"; cmd.Parameters.AddWithValue("@guid", guid.ToString()); var result = await cmd.ExecuteScalarAsync(); if (result != null) { var json = result.ToString(); return JsonSerializer.Deserialize>(json ?? "[]") ?? new List(); } } catch { // Silently fail } return new List(); } /// /// Find assets by name pattern using database query with wildcards. /// /// Pattern supporting * (any chars) and ? (single char). private static async Task> GetAssetsByNameAsync(string namePattern) { var results = new List(); if (s_dbConnection == null) { return results; } try { // Convert wildcard pattern to SQL LIKE pattern var sqlPattern = namePattern.Replace('*', '%').Replace('?', '_'); await using var cmd = s_dbConnection.CreateCommand(); // Extract just the filename from the path for matching // SQLite doesn't have a built-in path manipulation, so we search in the full path // and filter by checking if the pattern matches the filename part cmd.CommandText = @" SELECT Guid, Path FROM Assets WHERE Path LIKE '%' || @pattern || '%' "; cmd.Parameters.AddWithValue("@pattern", sqlPattern); await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { var guidStr = reader.GetString(0); var path = reader.GetString(1); // Extract filename and check if it matches the pattern var fileName = Path.GetFileName(path); // Convert pattern to regex for proper matching var regexPattern = "^" + System.Text.RegularExpressions.Regex.Escape(namePattern) .Replace("\\*", ".*") .Replace("\\?", ".") + "$"; if (System.Text.RegularExpressions.Regex.IsMatch(fileName, regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase)) { if (Guid.TryParse(guidStr, out var guid)) { results.Add(guid); } } } } catch { // Silently fail } return results; } /// /// Remove orphaned entries from database (assets that no longer exist on disk). /// private static async Task RemoveOrphanedEntriesAsync() { if (s_dbConnection == null || AssetsDirectory == null) { return; } try { var orphanedGuids = new List(); await using var cmd = s_dbConnection.CreateCommand(); cmd.CommandText = "SELECT Guid, Path FROM Assets"; await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { var guidStr = reader.GetString(0); var path = reader.GetString(1); if (Guid.TryParse(guidStr, out var guid)) { // Check if file exists var fullPath = Path.Combine(AssetsDirectory.FullName, path); if (!File.Exists(fullPath)) { orphanedGuids.Add(guid); } } } // Remove orphaned entries foreach (var guid in orphanedGuids) { await RemoveAssetFromDatabaseAsync(guid); } } catch { // Silently fail - cleanup is best effort } } }