From 8a5795069f2bcd4f2412e126ce97e1e00e485be8 Mon Sep 17 00:00:00 2001 From: Misaki Date: Tue, 27 Jan 2026 14:39:00 +0900 Subject: [PATCH] Update AssetDatabase --- Ghost.Data/Models/ProjectMetadata.cs | 2 + Ghost.Data/Services/ProjectService.cs | 4 +- .../AssetHandle/AssetDatabase.FileOps.cs | 363 ++++++++++++++ .../AssetHandle/AssetDatabase.Importer.cs | 158 ++++++ .../AssetHandle/AssetDatabase.Loader.cs | 242 ++++++++++ .../AssetHandle/AssetDatabase.Lookup.cs | 203 ++++++++ .../AssetHandle/AssetDatabase.Meta.cs | 256 ++++++++-- .../AssetHandle/AssetDatabase.SQLite.cs | 452 ++++++++++++++++++ .../AssetHandle/AssetDatabase.cs | 200 +++++++- .../AssetHandle/AssetImporter.cs | 80 ++++ .../AssetHandle/AssetImporterAttribute.cs | 2 +- Ghost.Editor.Core/AssetHandle/AssetMeta.cs | 71 ++- .../AssetHandle/ImporterSettings.cs | 2 +- .../AssetHandle/Importers/TextImporter.cs | 70 +++ .../AssetHandle/Importers/TextureImporter.cs | 279 +++++++++++ Ghost.Editor.Core/AssetHandle/README.md | 250 ++++++++++ Ghost.Editor.Core/AssetHandle/TextureAsset.cs | 75 +++ Ghost.Editor.Core/Utilities/FileExtensions.cs | 4 +- Ghost.Engine/Ghost.Engine.csproj | 2 +- .../AssetDatabaseIntegrationTest.cs | 367 ++++++++++++++ Ghost.UnitTest/AssetMetaTest.cs | 94 ++++ Ghost.UnitTest/MSTestSettings.cs | 2 +- Ghost.UnitTest/Test1.cs | 10 - 23 files changed, 3135 insertions(+), 53 deletions(-) create mode 100644 Ghost.Editor.Core/AssetHandle/AssetDatabase.FileOps.cs create mode 100644 Ghost.Editor.Core/AssetHandle/AssetDatabase.Importer.cs create mode 100644 Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs create mode 100644 Ghost.Editor.Core/AssetHandle/AssetDatabase.Lookup.cs create mode 100644 Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs create mode 100644 Ghost.Editor.Core/AssetHandle/AssetImporter.cs create mode 100644 Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs create mode 100644 Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs create mode 100644 Ghost.Editor.Core/AssetHandle/README.md create mode 100644 Ghost.Editor.Core/AssetHandle/TextureAsset.cs create mode 100644 Ghost.UnitTest/AssetDatabaseIntegrationTest.cs create mode 100644 Ghost.UnitTest/AssetMetaTest.cs delete mode 100644 Ghost.UnitTest/Test1.cs diff --git a/Ghost.Data/Models/ProjectMetadata.cs b/Ghost.Data/Models/ProjectMetadata.cs index 89842ba..edd888a 100644 --- a/Ghost.Data/Models/ProjectMetadata.cs +++ b/Ghost.Data/Models/ProjectMetadata.cs @@ -2,6 +2,8 @@ namespace Ghost.Data.Models; public class ProjectMetadata { + public const string PROJECT_FILE_EXTENSION_NAME = "gproj"; + public Guid ID { get; set; diff --git a/Ghost.Data/Services/ProjectService.cs b/Ghost.Data/Services/ProjectService.cs index 9302730..10d2d72 100644 --- a/Ghost.Data/Services/ProjectService.cs +++ b/Ghost.Data/Services/ProjectService.cs @@ -86,7 +86,7 @@ internal partial class ProjectService return Result.Failure("Project folder structure is invalid."); } - var metadataPath = Directory.GetFiles(projectDirectory, $"*.{ProjectMetadata.PROJECT_EXTENSION}", SearchOption.TopDirectoryOnly).FirstOrDefault(); + var metadataPath = Directory.GetFiles(projectDirectory, $"*.{ProjectMetadata.PROJECT_FILE_EXTENSION_NAME}", SearchOption.TopDirectoryOnly).FirstOrDefault(); if (string.IsNullOrWhiteSpace(metadataPath) || !File.Exists(metadataPath)) { return Result.Failure("Project metadata file not found."); @@ -193,7 +193,7 @@ internal partial class ProjectService } var metadata = new ProjectMetadata(projectName, engineVersion); - var metadataPath = Path.Combine(projectPath, $"{projectName}.{ProjectMetadata.PROJECT_EXTENSION}"); + var metadataPath = Path.Combine(projectPath, $"{projectName}.{ProjectMetadata.PROJECT_FILE_EXTENSION_NAME}"); await CreateMetadataFileAsync(metadataPath, metadata); await SetupRequestFolderAsync(projectPath, templatePath); diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.FileOps.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.FileOps.cs new file mode 100644 index 0000000..08f5150 --- /dev/null +++ b/Ghost.Editor.Core/AssetHandle/AssetDatabase.FileOps.cs @@ -0,0 +1,363 @@ +using Ghost.Core; + +namespace Ghost.Editor.Core.AssetHandle; + +public static partial class AssetDatabase +{ + /// + /// Create a new asset at the specified path. + /// Generates metadata and adds it to the database. + /// + /// Path to create the asset at. + /// Content to write to the asset file. + /// Result indicating success or failure. + public static async Task CreateAssetAsync(string assetPath, byte[] content) + { + if (AssetsDirectory == null) + { + return Result.Failure("AssetsDirectory not initialized"); + } + + if (!assetPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase)) + { + return Result.Failure("Asset path must be within the Assets directory"); + } + + if (File.Exists(assetPath)) + { + return Result.Failure("Asset already exists"); + } + + try + { + var directory = Path.GetDirectoryName(assetPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllBytesAsync(assetPath, content); + + // GenerateMetaFileAsync will be called automatically by the file watcher + // But we'll call it directly to ensure it's created immediately + await GenerateMetaFileAsync(assetPath); + + return Result.Success(); + } + catch (Exception ex) + { + return Result.Failure($"Failed to create asset: {ex.Message}"); + } + } + + /// + /// Create an empty asset at the specified path. + /// Generates metadata and adds it to the database. + /// + /// Path to create the asset at. + /// Result indicating success or failure. + public static async Task CreateAssetAsync(string assetPath) + { + return await CreateAssetAsync(assetPath, Array.Empty()); + } + + /// + /// Delete an asset and its metadata. + /// + /// GUID of the asset to delete. + /// Result indicating success or failure. + public static async Task DeleteAssetAsync(Guid guid) + { + var pathResult = GuidToPath(guid); + if (pathResult.IsFailure) + { + return Result.Failure(pathResult.Message); + } + + var fullPathResult = GetFullPath(pathResult.Value); + if (fullPathResult.IsFailure) + { + return Result.Failure(fullPathResult.Message); + } + + try + { + var assetPath = fullPathResult.Value; + + // Delete the asset file + if (File.Exists(assetPath)) + { + File.Delete(assetPath); + } + + // Delete the .gmeta file + var metaPath = assetPath + Utilities.FileExtensions.META_FILE_EXTENSION; + if (File.Exists(metaPath)) + { + File.Delete(metaPath); + } + + // Remove from database + await RemoveAssetFromDatabaseAsync(guid); + + return Result.Success(); + } + catch (Exception ex) + { + return Result.Failure($"Failed to delete asset: {ex.Message}"); + } + } + + /// + /// Delete an asset and its metadata by path. + /// + /// Path to the asset to delete. + /// Result indicating success or failure. + public static async Task DeleteAssetAsync(string assetPath) + { + var guidResult = PathToGuid(assetPath); + if (guidResult.IsFailure) + { + return Result.Failure(guidResult.Message); + } + + return await DeleteAssetAsync(guidResult.Value); + } + + /// + /// Move an asset to a new location. + /// + /// GUID of the asset to move. + /// New path for the asset (relative or absolute). + /// Result indicating success or failure. + public static async Task MoveAssetAsync(Guid guid, string newPath) + { + var oldPathResult = GuidToPath(guid); + if (oldPathResult.IsFailure) + { + return Result.Failure(oldPathResult.Message); + } + + var oldFullPathResult = GetFullPath(oldPathResult.Value); + if (oldFullPathResult.IsFailure) + { + return Result.Failure(oldFullPathResult.Message); + } + + if (AssetsDirectory == null) + { + return Result.Failure("AssetsDirectory not initialized"); + } + + // Ensure new path is absolute and within assets directory + if (!Path.IsPathRooted(newPath)) + { + newPath = Path.Combine(AssetsDirectory.FullName, newPath); + } + + if (!newPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase)) + { + return Result.Failure("New path must be within the Assets directory"); + } + + if (File.Exists(newPath)) + { + return Result.Failure("A file already exists at the new path"); + } + + try + { + var directory = Path.GetDirectoryName(newPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + // Read metadata and calculate hash before moving + var metaResult = await ReadMetaFileAsync(oldFullPathResult.Value); + if (metaResult.IsFailure) + { + return Result.Failure(metaResult.Message); + } + + var fileHash = await CalculateFileHashAsync(oldFullPathResult.Value); + + // Temporarily disable file watcher to prevent race conditions + var watcherWasEnabled = s_watcher?.EnableRaisingEvents ?? false; + if (s_watcher != null) + { + s_watcher.EnableRaisingEvents = false; + } + + try + { + // Move the asset file + File.Move(oldFullPathResult.Value, newPath); + + // Move the .gmeta file + var oldMetaPath = oldFullPathResult.Value + Utilities.FileExtensions.META_FILE_EXTENSION; + var newMetaPath = newPath + Utilities.FileExtensions.META_FILE_EXTENSION; + if (File.Exists(oldMetaPath)) + { + File.Move(oldMetaPath, newMetaPath); + } + + // Update database with new path (hash remains the same since content didn't change) + await UpsertAssetAsync(newPath, metaResult.Value, fileHash); + } + finally + { + // Re-enable file watcher + if (s_watcher != null && watcherWasEnabled) + { + s_watcher.EnableRaisingEvents = true; + } + } + + return Result.Success(); + } + catch (Exception ex) + { + return Result.Failure($"Failed to move asset: {ex.Message}"); + } + } + + /// + /// Move an asset to a new location by path. + /// + /// Current path of the asset. + /// New path for the asset (relative or absolute). + /// Result indicating success or failure. + public static async Task MoveAssetAsync(string oldPath, string newPath) + { + var guidResult = PathToGuid(oldPath); + if (guidResult.IsFailure) + { + return Result.Failure(guidResult.Message); + } + + return await MoveAssetAsync(guidResult.Value, newPath); + } + + /// + /// Copy an asset to a new location with a new GUID. + /// + /// GUID of the asset to copy. + /// New path for the copied asset (relative or absolute). + /// Result containing the new asset's GUID. + public static async Task> CopyAssetAsync(Guid guid, string newPath) + { + var oldPathResult = GuidToPath(guid); + if (oldPathResult.IsFailure) + { + return Result.Failure(oldPathResult.Message); + } + + var oldFullPathResult = GetFullPath(oldPathResult.Value); + if (oldFullPathResult.IsFailure) + { + return Result.Failure(oldFullPathResult.Message); + } + + if (AssetsDirectory == null) + { + return Result.Failure("AssetsDirectory not initialized"); + } + + // Ensure new path is absolute and within assets directory + if (!Path.IsPathRooted(newPath)) + { + newPath = Path.Combine(AssetsDirectory.FullName, newPath); + } + + if (!newPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase)) + { + return Result.Failure("New path must be within the Assets directory"); + } + + if (File.Exists(newPath)) + { + return Result.Failure("A file already exists at the new path"); + } + + try + { + var directory = Path.GetDirectoryName(newPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + File.Copy(oldFullPathResult.Value, newPath); + + // Generate new metadata with new GUID + await GenerateMetaFileAsync(newPath); + + // Get the new GUID + var newGuidResult = PathToGuid(newPath); + if (newGuidResult.IsFailure) + { + return Result.Failure(newGuidResult.Message); + } + + return newGuidResult.Value; + } + catch (Exception ex) + { + return Result.Failure($"Failed to copy asset: {ex.Message}"); + } + } + + /// + /// Copy an asset to a new location by path. + /// + /// Path of the asset to copy. + /// New path for the copied asset (relative or absolute). + /// Result containing the new asset's GUID. + public static async Task> CopyAssetAsync(string sourcePath, string destPath) + { + var guidResult = PathToGuid(sourcePath); + if (guidResult.IsFailure) + { + return Result.Failure(guidResult.Message); + } + + return await CopyAssetAsync(guidResult.Value, destPath); + } + + /// + /// Mark an asset as dirty for re-importing. + /// + /// GUID of the asset to mark dirty. + /// Result indicating success or failure. + public static async Task MarkDirtyAsync(Guid guid) + { + return await MarkAssetDirtyAsync(guid, true); + } + + /// + /// Import all dirty assets. + /// + /// Result indicating success or failure. + public static async Task ImportDirtyAssetsAsync() + { + var dirtyAssets = await GetDirtyAssetsAsync(); + + foreach (var (guid, path) in dirtyAssets) + { + var fullPathResult = GetFullPath(path); + if (fullPathResult.IsFailure) + { + continue; + } + + var result = await ImportAssetAsync(fullPathResult.Value); + if (result.IsSuccess) + { + await MarkAssetDirtyAsync(guid, false); + } + } + + return Result.Success(); + } +} diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Importer.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Importer.cs new file mode 100644 index 0000000..0b24451 --- /dev/null +++ b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Importer.cs @@ -0,0 +1,158 @@ +using Ghost.Core; +using System.Reflection; + +namespace Ghost.Editor.Core.AssetHandle; + +public static partial class AssetDatabase +{ + private static readonly Dictionary s_importerInstances = new(); + + /// + /// Import an asset at the specified path. + /// + /// Full path to the asset file. + /// Result indicating success or failure. + private static async Task ImportAssetAsync(string assetPath) + { + var extension = Path.GetExtension(assetPath); + + if (!s_importerTypeLookup.TryGetValue(extension, out var importerType)) + { + // No importer registered for this file type + return Result.Success(); + } + + // Get or create importer instance + if (!s_importerInstances.TryGetValue(importerType, out var importerInstance)) + { + importerInstance = Activator.CreateInstance(importerType); + if (importerInstance == null) + { + return Result.Failure($"Failed to create importer instance for type {importerType.Name}"); + } + + s_importerInstances[importerType] = importerInstance; + } + + // Read metadata + var metaResult = await ReadMetaFileAsync(assetPath); + if (metaResult.IsFailure) + { + return Result.Failure($"Failed to read asset metadata: {metaResult.Message}"); + } + + // Find and invoke the ImportAsync method + var importMethod = importerType.GetMethod("ImportAsync", BindingFlags.Public | BindingFlags.Instance); + if (importMethod == null) + { + return Result.Failure($"ImportAsync method not found on importer {importerType.Name}"); + } + + try + { + var task = importMethod.Invoke(importerInstance, new object[] { assetPath, metaResult.Value }) as Task; + if (task == null) + { + return Result.Failure("Importer did not return a valid Task"); + } + + var result = await task; + return result; + } + catch (Exception ex) + { + return Result.Failure($"Asset import failed: {ex.Message}"); + } + } + + /// + /// Get the importer type for a specific file extension. + /// + /// File extension (e.g., ".png"). + /// The importer type if found, otherwise null. + public static Type? GetImporterType(string extension) + { + s_importerTypeLookup.TryGetValue(extension, out var importerType); + return importerType; + } + + /// + /// Get all registered importer types and their supported extensions. + /// + /// Dictionary mapping extensions to importer types. + public static Dictionary GetAllImporters() + { + return new Dictionary(s_importerTypeLookup); + } + + /// + /// Export in-memory asset data to disk. + /// The importer will serialize the data into a format it can later import. + /// + /// Type of asset data to export. + /// Full path where the asset should be saved. + /// In-memory asset data to export. + /// Result with the GUID of the exported asset. + public static async Task> ExportAssetAsync(string assetPath, T assetData) where T : class + { + var extension = Path.GetExtension(assetPath); + + if (!s_importerTypeLookup.TryGetValue(extension, out var importerType)) + { + return Result.Failure($"No importer registered for extension {extension}"); + } + + // Get or create importer instance + if (!s_importerInstances.TryGetValue(importerType, out var importerInstance)) + { + importerInstance = Activator.CreateInstance(importerType); + if (importerInstance == null) + { + return Result.Failure($"Failed to create importer instance for type {importerType.Name}"); + } + + s_importerInstances[importerType] = importerInstance; + } + + // Find and invoke the ExportAsync method + var exportMethod = importerType.GetMethod("ExportAsync", BindingFlags.Public | BindingFlags.Instance); + if (exportMethod == null) + { + return Result.Failure($"ExportAsync method not found on importer {importerType.Name}. This importer does not support exporting."); + } + + try + { + // Generate metadata for the new asset + await GenerateMetaFileAsync(assetPath); + + var metaResult = await ReadMetaFileAsync(assetPath); + if (metaResult.IsFailure) + { + return Result.Failure($"Failed to generate metadata: {metaResult.Message}"); + } + + var task = exportMethod.Invoke(importerInstance, new object[] { assetPath, assetData, metaResult.Value }) as Task; + if (task == null) + { + return Result.Failure("Exporter did not return a valid Task"); + } + + var result = await task; + if (result.IsFailure) + { + return Result.Failure(result.Message); + } + + // Calculate file hash and update database + var fileHash = await CalculateFileHashAsync(assetPath); + await UpsertAssetAsync(assetPath, metaResult.Value, fileHash); + + return metaResult.Value.Guid; + } + catch (Exception ex) + { + return Result.Failure($"Asset export failed: {ex.Message}"); + } + } +} diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs new file mode 100644 index 0000000..c3a454c --- /dev/null +++ b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs @@ -0,0 +1,242 @@ +using Ghost.Core; +using Ghost.Data.Services; +using System.Collections.Concurrent; +using System.Text.Json; + +namespace Ghost.Editor.Core.AssetHandle; + +public static partial class AssetDatabase +{ + // Asset cache - stores loaded assets by GUID + private static readonly ConcurrentDictionary s_assetCache = new(); + + // LRU tracking - stores access time for each cached asset + private static readonly ConcurrentDictionary s_assetAccessTime = new(); + + // Maximum number of cached assets before eviction starts + private const int MAX_CACHED_ASSETS = 1000; + + // Percentage of cache to evict when limit is reached (evict oldest 20%) + private const float CACHE_EVICTION_PERCENTAGE = 0.2f; + + /// + /// Get the path to the imported asset data directory. + /// + private static Result GetImportedAssetsDirectory() + { + if (AssetsDirectory == null) + { + return Result.Failure("AssetsDirectory not initialized"); + } + + var cacheDir = Path.Combine(AssetsDirectory.Parent!.FullName, ProjectService.CACHE_FOLDER, "ImportedAssets"); + + if (!Directory.Exists(cacheDir)) + { + Directory.CreateDirectory(cacheDir); + } + + return cacheDir; + } + + /// + /// Get the path where imported asset data is stored for a specific GUID. + /// + /// GUID of the asset. + /// Full path to the imported asset data file. + private static Result GetImportedAssetPath(Guid guid) + { + var importedDirResult = GetImportedAssetsDirectory(); + if (importedDirResult.IsFailure) + { + return Result.Failure(importedDirResult.Message); + } + + // Store imported assets as {GUID}.asset + var assetDataPath = Path.Combine(importedDirResult.Value, $"{guid}.asset"); + return assetDataPath; + } + + /// + /// Load asset by GUID with caching (internal implementation). + /// + /// Type of asset to load. + /// GUID of the asset. + /// The loaded asset. + private static Result LoadAssetInternal(Guid guid) where T : Asset + { + // Check cache first + if (s_assetCache.TryGetValue(guid, out var cachedAsset)) + { + // Update access time for LRU + s_assetAccessTime[guid] = DateTime.UtcNow; + + if (cachedAsset is T typedAsset) + { + return typedAsset; + } + else + { + return Result.Failure($"Cached asset is of type {cachedAsset.GetType().Name}, expected {typeof(T).Name}"); + } + } + + // Asset not in cache, load from disk + var assetPathResult = GetImportedAssetPath(guid); + if (assetPathResult.IsFailure) + { + return Result.Failure(assetPathResult.Message); + } + + var assetDataPath = assetPathResult.Value; + + if (!File.Exists(assetDataPath)) + { + return Result.Failure($"Imported asset data not found at {assetDataPath}. Asset may not have been imported yet."); + } + + try + { + // Read and deserialize asset data + var json = File.ReadAllText(assetDataPath); + var asset = JsonSerializer.Deserialize(json); + + if (asset == null) + { + return Result.Failure("Failed to deserialize asset data"); + } + + // Add to cache + CacheAsset(guid, asset); + + return asset; + } + catch (Exception ex) + { + return Result.Failure($"Failed to load asset: {ex.Message}"); + } + } + + /// + /// Load asset by path with caching. + /// + /// Type of asset to load. + /// Full or relative path to the asset. + /// The loaded asset. + public static Result LoadAssetAtPath(string assetPath) where T : Asset + { + var guidResult = PathToGuid(assetPath); + if (guidResult.IsFailure) + { + return Result.Failure(guidResult.Message); + } + + return LoadAsset(guidResult.Value); + } + + /// + /// Add an asset to the cache with LRU eviction if needed. + /// + private static void CacheAsset(Guid guid, Asset asset) + { + // Check if we need to evict old assets + if (s_assetCache.Count >= MAX_CACHED_ASSETS) + { + EvictOldestAssets(); + } + + s_assetCache[guid] = asset; + s_assetAccessTime[guid] = DateTime.UtcNow; + } + + /// + /// Evict the oldest assets from cache based on LRU. + /// + private static void EvictOldestAssets() + { + var evictionCount = (int)(MAX_CACHED_ASSETS * CACHE_EVICTION_PERCENTAGE); + + // Sort by access time and remove oldest entries + var oldestAssets = s_assetAccessTime + .OrderBy(kvp => kvp.Value) + .Take(evictionCount) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var guid in oldestAssets) + { + s_assetCache.TryRemove(guid, out _); + s_assetAccessTime.TryRemove(guid, out _); + } + } + + /// + /// Unload a specific asset from cache. + /// + /// GUID of the asset to unload. + public static void UnloadAsset(Guid guid) + { + s_assetCache.TryRemove(guid, out _); + s_assetAccessTime.TryRemove(guid, out _); + } + + /// + /// Unload all assets from cache. + /// + public static void UnloadAllAssets() + { + s_assetCache.Clear(); + s_assetAccessTime.Clear(); + } + + /// + /// Check if an asset is currently loaded in cache. + /// + /// GUID of the asset. + /// True if the asset is in cache. + public static bool IsAssetLoaded(Guid guid) + { + return s_assetCache.ContainsKey(guid); + } + + /// + /// Get cache statistics. + /// + /// Tuple of (current cache size, max cache size). + public static (int currentSize, int maxSize) GetCacheStats() + { + return (s_assetCache.Count, MAX_CACHED_ASSETS); + } + + /// + /// Save an imported asset to disk for later loading. + /// This should be called by importers after processing the source file. + /// + /// Type of asset data. + /// GUID of the asset. + /// Processed asset data to save. + /// Result indicating success or failure. + internal static Result SaveImportedAsset(Guid guid, T assetData) where T : Asset + { + var assetPathResult = GetImportedAssetPath(guid); + if (assetPathResult.IsFailure) + { + return Result.Failure(assetPathResult.Message); + } + + try + { + var json = JsonSerializer.Serialize(assetData, s_defaultJsonOptions); + File.WriteAllText(assetPathResult.Value, json); + + // Invalidate cache for this asset so it gets reloaded next time + UnloadAsset(guid); + + return Result.Success(); + } + catch (Exception ex) + { + return Result.Failure($"Failed to save imported asset: {ex.Message}"); + } + } +} diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Lookup.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Lookup.cs new file mode 100644 index 0000000..85d9a6f --- /dev/null +++ b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Lookup.cs @@ -0,0 +1,203 @@ +using Ghost.Core; +using System.Text.Json; + +namespace Ghost.Editor.Core.AssetHandle; + +public static partial class AssetDatabase +{ + /// + /// Get the relative path from the assets directory. + /// + private static Result GetRelativePath(string fullPath) + { + if (AssetsDirectory == null) + { + return Result.Failure("AssetsDirectory not initialized"); + } + + if (!fullPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase)) + { + return Result.Failure("Path is not within assets directory"); + } + + return Path.GetRelativePath(AssetsDirectory.FullName, fullPath); + } + + /// + /// Get the full path from a relative path. + /// + private static Result GetFullPath(string relativePath) + { + if (AssetsDirectory == null) + { + return Result.Failure("AssetsDirectory not initialized"); + } + + return Path.Combine(AssetsDirectory.FullName, relativePath); + } + + /// + /// Find GUID by asset path. + /// + /// Full or relative path to the asset. + /// The GUID of the asset if found. + public static Result PathToGuid(string assetPath) + { + var relativePath = assetPath; + + // Convert to relative path if it's a full path + if (Path.IsPathRooted(assetPath)) + { + var relResult = GetRelativePath(assetPath); + if (relResult.IsFailure) + { + return Result.Failure(relResult.Message); + } + relativePath = relResult.Value; + } + + // Normalize path separators + relativePath = relativePath.Replace('\\', '/'); + + lock (s_dbLock) + { + if (s_pathAssetLookup.TryGetValue(relativePath, out var guid)) + { + return guid; + } + } + + return Result.Failure("Asset not found in database"); + } + + /// + /// Find path by GUID. + /// + /// GUID of the asset. + /// The relative path to the asset if found. + public static Result GuidToPath(Guid guid) + { + lock (s_dbLock) + { + if (s_assetPathLookup.TryGetValue(guid, out var path)) + { + return path; + } + } + + return Result.Failure("Asset GUID not found in database"); + } + + /// + /// Load asset by GUID with caching. + /// + /// Type of asset to load. + /// GUID of the asset. + /// The loaded asset. + public static Result LoadAsset(Guid guid) where T : Asset + { + // Implemented in AssetDatabase.Loader.cs + return LoadAssetInternal(guid); + } + + /// + /// Get asset tags by GUID. + /// + /// GUID of the asset. + /// List of tags associated with the asset. + public static async ValueTask>> GetAssetTagsAsync(Guid guid, CancellationToken token = default) + { + var pathResult = GuidToPath(guid); + if (pathResult.IsFailure) + { + return Result>.Failure(pathResult.Message); + } + + var fullPathResult = GetFullPath(pathResult.Value); + if (fullPathResult.IsFailure) + { + return Result>.Failure(fullPathResult.Message); + } + + var metaResult = await ReadMetaFileAsync(fullPathResult.Value); + if (metaResult.IsFailure) + { + return Result>.Failure(metaResult.Message); + } + + return metaResult.Value.Tags; + } + + /// + /// Set asset tags by GUID. + /// + /// GUID of the asset. + /// New tags for the asset. + /// Result indicating success or failure. + public static async ValueTask SetAssetTagsAsync(Guid guid, List tags) + { + var pathResult = GuidToPath(guid); + if (pathResult.IsFailure) + { + return Result.Failure(pathResult.Message); + } + + var fullPathResult = GetFullPath(pathResult.Value); + if (fullPathResult.IsFailure) + { + return Result.Failure(fullPathResult.Message); + } + + var metaResult = await ReadMetaFileAsync(fullPathResult.Value); + if (metaResult.IsFailure) + { + return Result.Failure(metaResult.Message); + } + + metaResult.Value.Tags = tags; + + // Write updated metadata to .gmeta file + var writeResult = await WriteMetaFileAsync(fullPathResult.Value + Utilities.FileExtensions.META_FILE_EXTENSION, metaResult.Value); + if (writeResult.IsFailure) + { + return writeResult; + } + + // Update database with new tags + var fileHash = await CalculateFileHashAsync(fullPathResult.Value); + return await UpsertAssetAsync(fullPathResult.Value, metaResult.Value, fileHash); + } + + /// + /// Search assets by name pattern. + /// Supports SQL LIKE wildcards: * (any characters) and ? (single character). + /// + /// Search pattern (e.g., "*.txt", "player?", "test*"). + /// List of matching asset GUIDs. + public static async Task> FindAssetsByNameAsync(string namePattern) + { + return await GetAssetsByNameAsync(namePattern); + } + + /// + /// Find assets by tag. + /// + /// Tag to search for. + /// List of asset GUIDs with the specified tag. + public static async Task> FindAssetsByTagAsync(string tag) + { + return await GetAssetsByTagAsync(tag); + } + + /// + /// Get all assets in the database. + /// + /// Dictionary mapping GUIDs to relative paths. + public static IReadOnlyDictionary GetAllAssets() + { + lock (s_dbLock) + { + return s_assetPathLookup.AsReadOnly(); + } + } +} diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Meta.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Meta.cs index 34611f0..b501bfd 100644 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Meta.cs +++ b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Meta.cs @@ -1,6 +1,8 @@ using Ghost.Core; using Ghost.Editor.Core.Utilities; using System.Reflection; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; namespace Ghost.Editor.Core.AssetHandle; @@ -8,8 +10,6 @@ namespace Ghost.Editor.Core.AssetHandle; public static partial class AssetDatabase { private static readonly Dictionary s_importerTypeLookup = new(); - private static readonly Dictionary s_assetPathLookup = new(); - private static readonly Dictionary s_pathAssetLookup = new(); private static void InitializeMetaData() { @@ -31,18 +31,19 @@ public static partial class AssetDatabase s_watcher.Created += OnAssetCreated; s_watcher.Deleted += OnAssetDeleted; s_watcher.Renamed += OnAssetRenamed; + s_watcher.Changed += OnAssetChanged; } - private static Result GetMetaFilePath(string assetPath) + private static Result GetMetaFilePath(string assetPath) { if (Directory.Exists(assetPath)) { - return Error.NotFound; + return Result.Failure("Cannot create metadata for directories"); } if (Path.GetExtension(assetPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) { - return Error.InvalidState; + return Result.Failure("Cannot create metadata for metadata files"); } return assetPath + FileExtensions.META_FILE_EXTENSION; @@ -66,105 +67,294 @@ public static partial class AssetDatabase return null; } - private static async Task WriteMetaFileAsync(string metaFilePath, AssetMeta metaData) + /// + /// Calculate SHA256 hash of a file for change detection. + /// + private static async Task CalculateFileHashAsync(string filePath) { - using var fileStream = File.Create(metaFilePath); - try { - await JsonSerializer.SerializeAsync(fileStream, metaData); + await using var stream = File.OpenRead(filePath); + var hash = await SHA256.HashDataAsync(stream); + return Convert.ToHexString(hash); + } + catch + { + return string.Empty; + } + } + + private static async Task WriteMetaFileAsync(string metaFilePath, AssetMeta metaData) + { + try + { + await using var fileStream = File.Create(metaFilePath); + await JsonSerializer.SerializeAsync(fileStream, metaData, s_defaultJsonOptions); + return Result.Success(); } catch (Exception ex) { return Result.Failure(ex.Message); } - - return Result.Success(); } - internal static async Task GenerateMetaFileAsync(string assetPath) + /// + /// Read metadata from a .gmeta file. + /// + private static async ValueTask> ReadMetaFileAsync(string assetPath, CancellationToken token = default) + { + var metaFileResult = GetMetaFilePath(assetPath); + if (metaFileResult.IsFailure) + { + return Result.Failure(metaFileResult.Message); + } + + if (!File.Exists(metaFileResult.Value)) + { + return Result.Failure("Metadata file does not exist"); + } + + try + { + await using var fileStream = File.OpenRead(metaFileResult.Value); + var meta = await JsonSerializer.DeserializeAsync(fileStream, s_defaultJsonOptions, token); + if (meta == null) + { + return Result.Failure("Failed to deserialize metadata"); + } + + return meta; + } + catch (Exception ex) + { + return Result.Failure($"Failed to read metadata: {ex.Message}"); + } + } + + internal static async ValueTask GenerateMetaFileAsync(string assetPath, CancellationToken token = default) { Result r; var metaFileResult = GetMetaFilePath(assetPath); if (metaFileResult.IsFailure) { - return Result.Failure(metaFileResult.Error); + return Result.Failure(metaFileResult.Message); } if (File.Exists(metaFileResult.Value)) { - using var fileStream = File.OpenRead(metaFileResult.Value); - var existingMeta = await JsonSerializer.DeserializeAsync(fileStream); - if (existingMeta != null && s_assetPathLookup.TryGetValue(existingMeta.Guid, out var path)) + var existingMetaResult = await ReadMetaFileAsync(assetPath); + if (existingMetaResult.IsSuccess) { - if (assetPath != path) + var existingMeta = existingMetaResult.Value; + if (s_assetPathLookup.TryGetValue(existingMeta.Guid, out var path)) { - existingMeta.Guid = Guid.NewGuid(); - r = await WriteMetaFileAsync(metaFileResult.Value, existingMeta); - if (r.IsFailure) + var relResult = GetRelativePath(assetPath); + if (relResult.IsSuccess && assetPath != path) { - return r; + // GUID conflict - regenerate + existingMeta.Guid = Guid.NewGuid(); + r = await WriteMetaFileAsync(metaFileResult.Value, existingMeta); + if (r.IsFailure) + { + return r; + } } } - } - return Result.Success(); + // Calculate file hash and update database + var fileHash = await CalculateFileHashAsync(assetPath); + await UpsertAssetAsync(assetPath, existingMeta, fileHash); + return Result.Success(); + } } + // Calculate initial file hash + var fileHash2 = await CalculateFileHashAsync(assetPath); + var defaultSettings = GetDefaultSettingsForAsset(assetPath); var metaData = new AssetMeta { - Guid = Guid.NewGuid(), - Settings = defaultSettings + Guid = Guid.NewGuid() }; + if (defaultSettings != null) + { + metaData.SetImporterSettings(defaultSettings.GetType().Name, defaultSettings); + } + r = await WriteMetaFileAsync(metaFileResult.Value, metaData); + if (r.IsFailure) + { + return r; + } + + // Add to database + await UpsertAssetAsync(assetPath, metaData, fileHash2); return r; } private static async void OnAssetCreated(object sender, FileSystemEventArgs e) { + // Skip meta files + if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + // Debounce to prevent duplicate events + if (!ShouldProcessFileOperation(e.FullPath)) + { + return; + } + await GenerateMetaFileAsync(e.FullPath); } - private static void OnAssetDeleted(object sender, FileSystemEventArgs e) + private static async void OnAssetDeleted(object sender, FileSystemEventArgs e) { + // Skip meta files + if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + // Debounce to prevent duplicate events + if (!ShouldProcessFileOperation(e.FullPath)) + { + return; + } + var metaFileResult = GetMetaFilePath(e.FullPath); if (metaFileResult.IsSuccess && File.Exists(metaFileResult.Value)) { try { - var meta = JsonSerializer.Deserialize(File.ReadAllText(metaFileResult.Value)); - if (meta != null - && s_assetPathLookup.TryGetValue(meta.Guid, out var path) - && path == e.FullPath) + var metaResult = await ReadMetaFileAsync(e.FullPath); + if (metaResult.IsSuccess) { - s_assetPathLookup.Remove(meta.Guid); + var meta = metaResult.Value; + + // Remove from database + await RemoveAssetFromDatabaseAsync(meta.Guid); + + // Mark dependent assets as dirty + await MarkDependentAssetsDirtyAsync(meta.Guid); } File.Delete(metaFileResult.Value); } catch (Exception ex) { - Logger.LogError(ex); + Console.WriteLine($"Error deleting asset metadata: {ex.Message}"); } } } private static async void OnAssetRenamed(object sender, RenamedEventArgs e) { + // Skip meta files + if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + // Debounce to prevent duplicate events + if (!ShouldProcessFileOperation(e.FullPath)) + { + return; + } + var oldMetaPath = e.OldFullPath + FileExtensions.META_FILE_EXTENSION; var newMetaPath = e.FullPath + FileExtensions.META_FILE_EXTENSION; - if (File.Exists(oldMetaPath)) + if (File.Exists(newMetaPath)) { + // Validate and update + await GenerateMetaFileAsync(e.FullPath); + } + else if (File.Exists(oldMetaPath)) + { + // Move meta file File.Move(oldMetaPath, newMetaPath); + + // Update database with new path and recalculated hash + var metaResult = await ReadMetaFileAsync(e.FullPath); + if (metaResult.IsSuccess) + { + var fileHash = await CalculateFileHashAsync(e.FullPath); + await UpsertAssetAsync(e.FullPath, metaResult.Value, fileHash); + } } else { + // Generate new meta file await GenerateMetaFileAsync(e.FullPath); } + + // Delete old meta if it still exists + if (File.Exists(oldMetaPath) && oldMetaPath != newMetaPath) + { + try + { + File.Delete(oldMetaPath); + } + catch + { + // Ignore + } + } + } + + private static async void OnAssetChanged(object sender, FileSystemEventArgs e) + { + // Skip meta files + if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + // Debounce to prevent duplicate events + if (!ShouldProcessFileOperation(e.FullPath)) + { + return; + } + + // Check if file hash changed + var metaResult = await ReadMetaFileAsync(e.FullPath); + if (metaResult.IsFailure) + { + return; + } + + // Calculate new hash and compare against database + var newHash = await CalculateFileHashAsync(e.FullPath); + var oldHash = await GetFileHashAsync(metaResult.Value.Guid); + + if (oldHash != newHash) + { + // File changed - update database and mark as dirty + await UpsertAssetAsync(e.FullPath, metaResult.Value, newHash); + await MarkAssetDirtyAsync(metaResult.Value.Guid, true); + } + } + + /// + /// Mark all assets that depend on the specified asset as dirty. + /// + private static async Task MarkDependentAssetsDirtyAsync(Guid assetGuid) + { + // Query database for all assets and check their dependencies + var allAssets = GetAllAssets(); + + foreach (var kvp in allAssets) + { + var dependencies = await GetDependenciesAsync(kvp.Key); + if (dependencies.Contains(assetGuid)) + { + await MarkAssetDirtyAsync(kvp.Key, true); + } + } } } diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs new file mode 100644 index 0000000..4c6adce --- /dev/null +++ b/Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs @@ -0,0 +1,452 @@ +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 + } + } +} diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.cs index bda8f5b..2c94012 100644 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.cs +++ b/Ghost.Editor.Core/AssetHandle/AssetDatabase.cs @@ -1,10 +1,39 @@ using Ghost.Data.Services; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Ghost.Editor.Core.AssetHandle; +/// +/// Centralized asset database that manages all assets in the project. +/// Handles asset registration, lookup, importing, and dependency management. +/// Uses SQLite for persistent storage and efficient querying. +/// public static partial class AssetDatabase { private static FileSystemWatcher? s_watcher; + private static readonly Lock s_dbLock = new(); + private static readonly Dictionary s_assetPathLookup = new(); + private static readonly Dictionary s_pathAssetLookup = new(); + + // Debouncing for file system watcher to prevent duplicate events + private static readonly Dictionary s_pendingFileOperations = new(); + private static readonly Lock s_pendingOperationsLock = new(); + private static readonly TimeSpan s_debounceDelay = TimeSpan.FromMilliseconds(100); + + // Initialization guard + private static readonly Lock s_initializationLock = new(); + private static bool s_initialized = false; + + private static readonly JsonSerializerOptions s_defaultJsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) + } + }; public static DirectoryInfo? AssetsDirectory { @@ -12,22 +41,189 @@ public static partial class AssetDatabase private set; } - internal static void Initialize() + /// + /// Initialize the asset database. + /// Must be called after project is loaded. + /// + internal static async void Initialize() { + lock (s_initializationLock) + { + if (s_initialized) + { + return; // Already initialized, skip + } + s_initialized = true; + } + if (ProjectService.CurrentProject.Metadata == null) { throw new InvalidOperationException("Project metadata is not initialized. Ensure that the project is loaded before accessing the AssetDatabase."); } AssetsDirectory = new DirectoryInfo(Path.Combine(Path.GetDirectoryName(ProjectService.CurrentProject.Path)!, ProjectService.ASSETS_FOLDER)); + + // Initialize database + await InitializeDatabaseAsync(); + + // Load asset cache from database + await LoadAssetCacheFromDatabaseAsync(); + + // Initialize file system watcher s_watcher = new FileSystemWatcher { Path = AssetsDirectory.FullName, IncludeSubdirectories = true, - EnableRaisingEvents = true + EnableRaisingEvents = true, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite }; InitializeAssetHandle(); InitializeMetaData(); + + // Validate and fix database on startup + await ValidateAndFixDatabaseAsync(); + } + + /// + /// Validate the asset database and fix any inconsistencies. + /// Checks for missing/corrupted assets and regenerates metadata as needed. + /// + private static async Task ValidateAndFixDatabaseAsync() + { + if (AssetsDirectory == null) + { + return Ghost.Core.Result.Failure("AssetsDirectory not initialized"); + } + + try + { + // Scan all files in assets directory + var allFiles = Directory.GetFiles(AssetsDirectory.FullName, "*.*", SearchOption.AllDirectories) + .Where(f => !f.EndsWith(Utilities.FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // Ensure all files have metadata + foreach (var file in allFiles) + { + var metaPath = file + Utilities.FileExtensions.META_FILE_EXTENSION; + if (!File.Exists(metaPath)) + { + await GenerateMetaFileAsync(file); + } + else + { + // Validate and update database + var metaResult = await ReadMetaFileAsync(file); + if (metaResult.IsSuccess) + { + var fileHash = await CalculateFileHashAsync(file); + await UpsertAssetAsync(file, metaResult.Value, fileHash); + } + else + { + // Corrupted meta file - regenerate + await GenerateMetaFileAsync(file); + } + } + } + + // Remove orphaned entries from database (files that no longer exist) + await RemoveOrphanedEntriesAsync(); + + return Ghost.Core.Result.Success(); + } + catch (Exception ex) + { + return Ghost.Core.Result.Failure($"Failed to validate database: {ex.Message}"); + } + } + + /// + /// Refresh the asset database manually. + /// Scans the project directory for changes. + /// + public static async Task RefreshAsync() + { + return await ValidateAndFixDatabaseAsync(); + } + + /// + /// Check if a file operation should be processed or debounced. + /// Returns true if the operation should proceed. + /// + private static bool ShouldProcessFileOperation(string filePath) + { + lock (s_pendingOperationsLock) + { + var now = DateTime.UtcNow; + + // Clean up old entries + var toRemove = s_pendingFileOperations + .Where(kvp => now - kvp.Value > s_debounceDelay * 2) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var key in toRemove) + { + s_pendingFileOperations.Remove(key); + } + + // Check if this operation was recently processed + if (s_pendingFileOperations.TryGetValue(filePath, out var lastTime)) + { + if (now - lastTime < s_debounceDelay) + { + // Too soon, skip this event + return false; + } + } + + // Update timestamp and allow processing + s_pendingFileOperations[filePath] = now; + return true; + } + } + + /// + /// Register a file operation to prevent the file watcher from processing it. + /// Used by file operations (move, copy, etc.) to prevent duplicate processing. + /// + private static void RegisterFileOperation(string filePath) + { + lock (s_pendingOperationsLock) + { + s_pendingFileOperations[filePath] = DateTime.UtcNow; + } + } + + /// + /// Shutdown the asset database. + /// Disposes resources and closes database connections. + /// + internal static void Shutdown() + { + lock (s_initializationLock) + { + if (!s_initialized) + { + return; // Not initialized, nothing to shutdown + } + + s_watcher?.Dispose(); + s_watcher = null; + + s_dbConnection?.Close(); + s_dbConnection?.Dispose(); + s_dbConnection = null; + + s_assetPathLookup.Clear(); + s_pathAssetLookup.Clear(); + s_importerInstances.Clear(); + s_importerTypeLookup.Clear(); + s_pendingFileOperations.Clear(); + + s_initialized = false; + } } } diff --git a/Ghost.Editor.Core/AssetHandle/AssetImporter.cs b/Ghost.Editor.Core/AssetHandle/AssetImporter.cs new file mode 100644 index 0000000..17f144d --- /dev/null +++ b/Ghost.Editor.Core/AssetHandle/AssetImporter.cs @@ -0,0 +1,80 @@ +using Ghost.Core; + +namespace Ghost.Editor.Core.AssetHandle; + +/// +/// Base class for all asset importers. +/// Asset importers process source files and convert them into engine-ready formats. +/// +/// The type of importer settings this importer uses. +internal abstract class AssetImporter + where TSettings : ImporterSettings, new() +{ + /// + /// Import the asset at the specified path with the given settings. + /// + /// Full path to the source asset file. + /// Metadata for the asset. + /// Result indicating success or failure. + public abstract Task ImportAsync(string assetPath, AssetMeta meta); + + /// + /// Export in-memory asset data to disk. + /// Override this method to support creating assets from code. + /// + /// Type of asset data to export. + /// Full path where the asset should be saved. + /// In-memory asset data to serialize. + /// Metadata for the asset. + /// Result indicating success or failure. + public virtual Task ExportAsync(string assetPath, T assetData, AssetMeta meta) where T : class + { + return Task.FromResult(Result.Failure("This importer does not support exporting assets.")); + } + + /// + /// Get the settings for this importer from the metadata. + /// Creates default settings if none exist. + /// + /// Asset metadata. + /// The importer settings. + protected TSettings GetSettings(AssetMeta meta) + { + var typeName = GetType().Name; + var settings = meta.GetImporterSettings(typeName); + + if (settings != null) + { + return settings; + } + + var defaultSettings = new TSettings(); + meta.SetImporterSettings(typeName, defaultSettings); + return defaultSettings; + } + + /// + /// Validate dependencies referenced by this asset. + /// Dependencies are extracted from asset content during import and stored in the database. + /// + /// List of dependency GUIDs extracted from the asset. + /// Result indicating if all dependencies are valid. + protected virtual ValueTask ValidateDependenciesAsync(List dependencies) + { + foreach (var dependencyGuid in dependencies) + { + var path = AssetDatabase.GuidToPath(dependencyGuid); + if (path.IsFailure) + { + return ValueTask.FromResult(Result.Failure($"Missing dependency: {dependencyGuid}")); + } + + if (!File.Exists(path.Value)) + { + return ValueTask.FromResult(Result.Failure($"Dependency file does not exist: {path.Value}")); + } + } + + return ValueTask.FromResult(Result.Success()); + } +} diff --git a/Ghost.Editor.Core/AssetHandle/AssetImporterAttribute.cs b/Ghost.Editor.Core/AssetHandle/AssetImporterAttribute.cs index 8dd9363..7b2756e 100644 --- a/Ghost.Editor.Core/AssetHandle/AssetImporterAttribute.cs +++ b/Ghost.Editor.Core/AssetHandle/AssetImporterAttribute.cs @@ -1,7 +1,7 @@ namespace Ghost.Editor.Core.AssetHandle; [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] -public class AssetImporterAttribute : Attribute +internal class AssetImporterAttribute : Attribute { public string[] SupportedExtensions { diff --git a/Ghost.Editor.Core/AssetHandle/AssetMeta.cs b/Ghost.Editor.Core/AssetHandle/AssetMeta.cs index 65c4cbe..4659ee4 100644 --- a/Ghost.Editor.Core/AssetHandle/AssetMeta.cs +++ b/Ghost.Editor.Core/AssetHandle/AssetMeta.cs @@ -1,16 +1,85 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + namespace Ghost.Editor.Core.AssetHandle; +/// +/// Metadata for an asset, stored in .gmeta files. +/// Contains GUID, version, tags, and importer settings. +/// FileHash and Dependencies are stored in the database only, not in .gmeta files. +/// internal class AssetMeta { + /// + /// Unique identifier for the asset. + /// + [JsonPropertyName("Guid")] public Guid Guid { get; set; } - public ImporterSettings? Settings + /// + /// Version of the asset pipeline (not the asset itself). + /// Used for migration when the asset pipeline is redesigned. + /// + [JsonPropertyName("Version")] + public int Version { get; set; + } = 1; + + /// + /// Tags for categorizing and searching assets. + /// + [JsonPropertyName("Tags")] + public List Tags + { + get; + set; + } = new(); + + /// + /// Importer settings specific to this asset. + /// The key is the importer type name, and the value is a JSON element containing the settings. + /// Use GetImporterSettings<T>() and SetImporterSettings<T>() to work with strongly-typed settings. + /// + [JsonPropertyName("ImporterSettings")] + public Dictionary ImporterSettings + { + get; + set; + } = new(); + + /// + /// Get importer settings of a specific type. + /// + public T? GetImporterSettings(string importerName) where T : ImporterSettings + { + if (ImporterSettings.TryGetValue(importerName, out var element)) + { + return element.Deserialize(); + } + return null; + } + + /// + /// Set importer settings. + /// + public void SetImporterSettings(string importerName, T settings) where T : ImporterSettings + { + var element = JsonSerializer.SerializeToElement(settings); + ImporterSettings[importerName] = element; + } + + /// + /// Set importer settings (non-generic overload). + /// + internal void SetImporterSettings(string importerName, ImporterSettings settings) + { + var element = JsonSerializer.SerializeToElement(settings, settings.GetType()); + ImporterSettings[importerName] = element; } } diff --git a/Ghost.Editor.Core/AssetHandle/ImporterSettings.cs b/Ghost.Editor.Core/AssetHandle/ImporterSettings.cs index c754f2b..120f335 100644 --- a/Ghost.Editor.Core/AssetHandle/ImporterSettings.cs +++ b/Ghost.Editor.Core/AssetHandle/ImporterSettings.cs @@ -1,5 +1,5 @@ namespace Ghost.Editor.Core.AssetHandle; -public abstract class ImporterSettings +internal abstract class ImporterSettings { } \ No newline at end of file diff --git a/Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs b/Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs new file mode 100644 index 0000000..ef928bf --- /dev/null +++ b/Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs @@ -0,0 +1,70 @@ +using Ghost.Core; + +namespace Ghost.Editor.Core.AssetHandle.Importers; + +/// +/// Example importer settings for text assets. +/// +internal class TextImporterSettings : ImporterSettings +{ + public string Encoding + { + get; + set; + } = "UTF-8"; + + public bool TrimWhitespace + { + get; + set; + } = false; +} + +/// +/// Example importer for text files (.txt, .md). +/// This is a simple test importer to demonstrate the asset import system. +/// +[AssetImporter(".txt", ".md")] +internal class TextImporter : AssetImporter +{ + public override async Task ImportAsync(string assetPath, AssetMeta meta) + { + var settings = GetSettings(meta); + + // Text files typically don't have dependencies + // If they did, you would extract them from the content here + var dependencies = new List(); + + // Validate dependencies + var depResult = await ValidateDependenciesAsync(dependencies); + if (depResult.IsFailure) + { + return depResult; + } + + try + { + // Read the file + var content = await File.ReadAllTextAsync(assetPath); + + if (settings.TrimWhitespace) + { + content = content.Trim(); + } + + // TODO: Process the text content + // For example: + // - Convert to a specific format + // - Extract metadata + // - Generate assets + // - Save to output folder + + // For now, just report success + return Result.Success(); + } + catch (Exception ex) + { + return Result.Failure($"Failed to import text asset: {ex.Message}"); + } + } +} diff --git a/Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs b/Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs new file mode 100644 index 0000000..d6306e0 --- /dev/null +++ b/Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs @@ -0,0 +1,279 @@ +using Ghost.Core; +using System.Text.Json; + +namespace Ghost.Editor.Core.AssetHandle.Importers; + +/// +/// Importer settings for texture assets. +/// +internal class TextureImporterSettings : ImporterSettings +{ + /// + /// Whether to generate mipmaps for the texture. + /// + public bool GenerateMipmaps + { + get; + set; + } = true; + + /// + /// Whether the texture uses sRGB color space. + /// + public bool SRGB + { + get; + set; + } = true; + + /// + /// Maximum texture size. Images larger than this will be downscaled. + /// + public uint MaxSize + { + get; + set; + } = 2048; + + /// + /// Texture compression format. + /// Options: "None", "BC1", "BC3", "BC7" + /// + public string CompressionFormat + { + get; + set; + } = "None"; + + /// + /// Texture filter mode. + /// Options: "Point", "Bilinear", "Trilinear" + /// + public string FilterMode + { + get; + set; + } = "Bilinear"; + + /// + /// Texture wrap mode. + /// Options: "Repeat", "Clamp", "Mirror" + /// + public string WrapMode + { + get; + set; + } = "Repeat"; +} + +/// +/// Importer for texture files (.png, .jpg, .jpeg, .dds, .tga, .bmp). +/// Processes image files and converts them into engine-ready texture assets. +/// +[AssetImporter(".png", ".jpg", ".jpeg", ".dds", ".tga", ".bmp")] +internal class TextureImporter : AssetImporter +{ + public override async Task ImportAsync(string assetPath, AssetMeta meta) + { + var settings = GetSettings(meta); + + // Textures typically don't reference other assets as dependencies + // If they did (e.g., normal maps referencing base textures), extract here + var dependencies = new List(); + + // Validate dependencies + var depResult = await ValidateDependenciesAsync(dependencies); + if (depResult.IsFailure) + { + return depResult; + } + + try + { + // Check if file exists + if (!File.Exists(assetPath)) + { + return Result.Failure($"Source texture file not found: {assetPath}"); + } + + // Get image dimensions (simplified - in real implementation would use image library) + var (width, height) = await GetImageDimensionsAsync(assetPath); + + if (width == 0 || height == 0) + { + return Result.Failure("Failed to read image dimensions"); + } + + // Apply max size constraint + if (width > settings.MaxSize || height > settings.MaxSize) + { + var scale = Math.Min(settings.MaxSize / (float)width, settings.MaxSize / (float)height); + width = (uint)(width * scale); + height = (uint)(height * scale); + } + + // Calculate mipmap count + uint mipLevels = 1; + if (settings.GenerateMipmaps) + { + mipLevels = CalculateMipLevels(width, height); + } + + // Determine format + var format = settings.CompressionFormat == "None" ? "RGBA8" : settings.CompressionFormat; + + // Create texture asset + var textureAsset = new TextureAsset(meta.Guid, Path.GetFileNameWithoutExtension(assetPath)) + { + Width = width, + Height = height, + MipLevels = mipLevels, + Format = format, + IsSRGB = settings.SRGB, + SourcePath = assetPath + }; + + // Save the imported asset data + var saveResult = AssetDatabase.SaveImportedAsset(meta.Guid, textureAsset); + if (saveResult.IsFailure) + { + return Result.Failure($"Failed to save texture asset: {saveResult.Message}"); + } + + // In a real implementation, you would: + // 1. Load the image using a library like ImageSharp or StbImageSharp + // 2. Resize if needed + // 3. Generate mipmaps + // 4. Compress if needed + // 5. Save the processed texture data to the ImportedAssets folder + // 6. Update the hash in database + + return Result.Success(); + } + catch (Exception ex) + { + return Result.Failure($"Failed to import texture: {ex.Message}"); + } + } + + /// + /// Get image dimensions from file. + /// Simplified implementation - in production, use an image library. + /// + private async Task<(uint width, uint height)> GetImageDimensionsAsync(string imagePath) + { + // This is a placeholder implementation + // In a real implementation, you would use a library like: + // - ImageSharp + // - StbImageSharp + // - DirectXTex (for DDS files) + + var extension = Path.GetExtension(imagePath).ToLowerInvariant(); + + if (extension == ".dds") + { + // For DDS files, read the header + // DDS header format: https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-header + return await ReadDDSHeaderAsync(imagePath); + } + else + { + // For PNG/JPG/etc, we would use an image library + // For now, return placeholder values + return await Task.FromResult<(uint, uint)>((1024, 1024)); + } + } + + /// + /// Read DDS file header to get dimensions. + /// + private async Task<(uint width, uint height)> ReadDDSHeaderAsync(string ddsPath) + { + try + { + await using var stream = File.OpenRead(ddsPath); + using var reader = new BinaryReader(stream); + + // Read magic number (should be "DDS ") + var magic = reader.ReadUInt32(); + if (magic != 0x20534444) // "DDS " in little-endian + { + return (0, 0); + } + + // Read header size (should be 124) + var headerSize = reader.ReadUInt32(); + if (headerSize != 124) + { + return (0, 0); + } + + // Skip flags + reader.ReadUInt32(); + + // Read height and width + var height = reader.ReadUInt32(); + var width = reader.ReadUInt32(); + + return (width, height); + } + catch + { + return (0, 0); + } + } + + /// + /// Export a texture asset from memory to disk. + /// + public override async Task ExportAsync(string assetPath, T assetData, AssetMeta meta) + { + if (assetData is not TextureAsset textureAsset) + { + return Result.Failure($"Asset data is not a TextureAsset, got {typeof(T).Name}"); + } + + try + { + // In a real implementation, you would: + // 1. Convert the texture data to the appropriate format + // 2. Write the image file (PNG, DDS, etc.) + // 3. Save metadata + + // For now, just save metadata as JSON + var json = JsonSerializer.Serialize(textureAsset, new JsonSerializerOptions + { + WriteIndented = true + }); + + await File.WriteAllTextAsync(assetPath, json); + + return Result.Success(); + } + catch (Exception ex) + { + return Result.Failure($"Failed to export texture: {ex.Message}"); + } + } + + /// + /// Calculate number of mipmap levels for a given texture size. + /// + private static uint CalculateMipLevels(uint width, uint height) + { + if (width == 0 || height == 0) + { + return 0; + } + + uint count = 1; + while (width > 1 || height > 1) + { + width >>= 1; + height >>= 1; + count++; + } + + return count; + } +} diff --git a/Ghost.Editor.Core/AssetHandle/README.md b/Ghost.Editor.Core/AssetHandle/README.md new file mode 100644 index 0000000..e1a856a --- /dev/null +++ b/Ghost.Editor.Core/AssetHandle/README.md @@ -0,0 +1,250 @@ +# Asset Database Implementation + +This is the complete implementation of the GhostEngine Asset Database system based on the plan in `AssetDBPlan.md`. + +## Structure + +The asset database is implemented as a partial class split across multiple files: + +### Core Files + +- **AssetDatabase.cs** - Main entry point with initialization and shutdown logic +- **AssetDatabase.Meta.cs** - Metadata file management and file system watching +- **AssetDatabase.SQLite.cs** - SQLite database operations for caching +- **AssetDatabase.Lookup.cs** - GUID/Path lookup and search operations +- **AssetDatabase.FileOps.cs** - File operations (create, delete, move, copy) +- **AssetDatabase.Importer.cs** - Asset importing framework +- **AssetDatabase.Open.cs** - Asset opening handlers (existing file) + +### Supporting Files + +- **Asset.cs** - Base class for all assets +- **AssetMeta.cs** - Metadata structure (stored in .gmeta files) +- **AssetImporter.cs** - Base class for all asset importers +- **AssetImporterAttribute.cs** - Attribute to mark importer classes +- **AssetOpenHandlerAttribute.cs** - Attribute for custom open handlers +- **ImporterSettings.cs** - Base class for importer settings + +### Example Importer + +- **Importers/TextImporter.cs** - Example importer for .txt and .md files + +## Features Implemented + +### Core API (AssetDatabase.Lookup.cs) + +- ✅ `PathToGuid(string assetPath)` - Find GUID by path +- ✅ `GuidToPath(Guid guid)` - Find path by GUID +- ✅ `LoadAsset(Guid guid)` - Load asset by GUID (TODO: needs asset loader) +- ✅ `GetAssetTagsAsync(Guid guid)` - Get asset tags +- ✅ `SetAssetTagsAsync(Guid guid, List tags)` - Set asset tags +- ✅ `FindAssetsByName(string namePattern)` - Search by name +- ✅ `FindAssetsByTagAsync(string tag)` - Search by tag +- ✅ `GetAllAssets()` - Get all assets in database + +### File Operations (AssetDatabase.FileOps.cs) + +- ✅ `CreateAssetAsync(string assetPath, byte[] content)` - Create new asset +- ✅ `DeleteAssetAsync(Guid guid)` - Delete asset +- ✅ `MoveAssetAsync(Guid guid, string newPath)` - Move/rename asset +- ✅ `CopyAssetAsync(Guid guid, string newPath)` - Copy asset with new GUID +- ✅ `RefreshAsync()` - Refresh database manually +- ✅ `MarkDirtyAsync(Guid guid)` - Mark asset for re-import +- ✅ `ImportDirtyAssetsAsync()` - Import all dirty assets + +### Background Services (AssetDatabase.Meta.cs) + +- ✅ File system watcher for automatic change detection +- ✅ Automatic metadata generation on file creation +- ✅ Automatic metadata cleanup on file deletion +- ✅ Automatic metadata movement on file rename +- ✅ File hash comparison for change detection +- ✅ Automatic dirty marking on file modification +- ✅ Dependent asset tracking and dirty propagation + +### Database (AssetDatabase.SQLite.cs) + +- ✅ SQLite for persistent storage and efficient querying +- ✅ In-memory cache for fast lookups +- ✅ Automatic database creation and schema management +- ✅ Asset indexing by GUID and path +- ✅ Dirty flag tracking for re-import +- ✅ Tag-based search support + +### Validation (AssetDatabase.cs) + +- ✅ Validate and fix database on project load +- ✅ Check for missing/corrupted metadata files +- ✅ Regenerate metadata when necessary +- ✅ Database consistency checks + +## Metadata File Format + +Assets have associated `.gmeta` files stored alongside them: + +```json +{ + "Guid": "123e4567-e89b-12d3-a456-426614174000", + "Version": 1, + "Tags": ["Environment", "Texture"], + "FileHash": "ABC123...", + "Dependencies": [ + "456e7890-e89b-12d3-a456-426614174001" + ], + "ImporterSettings": { + "TextureImporter": { + "MaxSize": 2048, + "MipLevels": 1 + } + } +} +``` + +## Usage Examples + +### Finding Assets + +```csharp +// Find by path +var guidResult = AssetDatabase.PathToGuid("Assets/Textures/logo.png"); +if (guidResult.IsSuccess) +{ + var guid = guidResult.Value; + // Use guid... +} + +// Find by GUID +var pathResult = AssetDatabase.GuidToPath(myGuid); +if (pathResult.IsSuccess) +{ + var path = pathResult.Value; + // Use path... +} + +// Search by name +var results = AssetDatabase.FindAssetsByName("logo"); + +// Search by tag +var textureAssets = await AssetDatabase.FindAssetsByTagAsync("Texture"); +``` + +### Creating and Managing Assets + +```csharp +// Create new asset +var content = Encoding.UTF8.GetBytes("Hello, World!"); +await AssetDatabase.CreateAssetAsync("Assets/test.txt", content); + +// Move asset +await AssetDatabase.MoveAssetAsync(guid, "Assets/NewFolder/test.txt"); + +// Copy asset +var newGuid = await AssetDatabase.CopyAssetAsync(guid, "Assets/test_copy.txt"); + +// Delete asset +await AssetDatabase.DeleteAssetAsync(guid); +``` + +### Working with Tags + +```csharp +// Get tags +var tagsResult = await AssetDatabase.GetAssetTagsAsync(guid); +if (tagsResult.IsSuccess) +{ + var tags = tagsResult.Value; +} + +// Set tags +await AssetDatabase.SetAssetTagsAsync(guid, new List { "UI", "Icon" }); +``` + +### Asset Importing + +```csharp +// Mark asset dirty for re-import +await AssetDatabase.MarkDirtyAsync(guid); + +// Import all dirty assets +await AssetDatabase.ImportDirtyAssetsAsync(); +``` + +## Creating Custom Importers + +To create a custom asset importer: + +1. Create a settings class inheriting from `ImporterSettings` +2. Create an importer class inheriting from `AssetImporter` +3. Add the `[AssetImporter]` attribute with supported extensions + +Example: + +```csharp +public class MyImporterSettings : ImporterSettings +{ + public bool SomeOption { get; set; } = true; +} + +[AssetImporter(".myext")] +public class MyImporter : AssetImporter +{ + public override async Task ImportAsync(string assetPath, AssetMeta meta) + { + var settings = GetSettings(meta); + + // Validate dependencies + var depResult = await ValidateDependenciesAsync(meta); + if (depResult.IsFailure) + { + return depResult; + } + + // Import logic here... + + return Result.Success(); + } +} +``` + +## Architecture Notes + +### Source of Truth + +The `.gmeta` files are the **source of truth** for asset information. The SQLite database is used only for: +- Caching for fast lookups +- Efficient querying and search operations +- Tracking dirty state + +If the database becomes inconsistent, it can be regenerated from the `.gmeta` files by calling `RefreshAsync()`. + +### Thread Safety + +All database operations use locks to ensure thread safety. File system watcher events are handled asynchronously to avoid blocking the main thread. + +### Error Handling + +The system uses the `Result` pattern for railway-oriented programming. All operations return `Result` or `Result` to indicate success or failure without throwing exceptions for expected failures. + +## Testing + +Unit tests should be added to verify: +- Metadata file generation and parsing +- Database consistency +- File operations (create, delete, move, copy) +- Asset importing +- Dependency tracking +- Tag management +- Search functionality + +## Future Improvements + +- Asset loader implementation (`LoadAsset`) +- Asset browser UI +- More sophisticated dependency resolution +- Asset preview generation +- Asset versioning and migration +- Orphaned entry cleanup in database +- Better error reporting and logging +- Asset import progress tracking +- Parallel asset importing +- Asset thumbnail generation diff --git a/Ghost.Editor.Core/AssetHandle/TextureAsset.cs b/Ghost.Editor.Core/AssetHandle/TextureAsset.cs new file mode 100644 index 0000000..9757b8c --- /dev/null +++ b/Ghost.Editor.Core/AssetHandle/TextureAsset.cs @@ -0,0 +1,75 @@ +namespace Ghost.Editor.Core.AssetHandle; + +/// +/// Represents a texture asset. +/// +public class TextureAsset : Asset +{ + public override string Name + { + get; + set; + } + + /// + /// Width of the texture in pixels. + /// + public uint Width + { + get; + set; + } + + /// + /// Height of the texture in pixels. + /// + public uint Height + { + get; + set; + } + + /// + /// Number of mipmap levels. + /// + public uint MipLevels + { + get; + set; + } + + /// + /// Texture format (e.g., "RGBA8", "BC1", "BC7"). + /// + public string Format + { + get; + set; + } + + /// + /// Whether the texture uses sRGB color space. + /// + public bool IsSRGB + { + get; + set; + } + + /// + /// Relative path to the source image file. + /// + public string SourcePath + { + get; + set; + } + + public TextureAsset(Guid id, string name) : base(id) + { + Name = name; + Format = "RGBA8"; + IsSRGB = true; + SourcePath = string.Empty; + } +} diff --git a/Ghost.Editor.Core/Utilities/FileExtensions.cs b/Ghost.Editor.Core/Utilities/FileExtensions.cs index 8bfa52b..cc25701 100644 --- a/Ghost.Editor.Core/Utilities/FileExtensions.cs +++ b/Ghost.Editor.Core/Utilities/FileExtensions.cs @@ -1,10 +1,12 @@ +using Ghost.Data.Models; + namespace Ghost.Editor.Core.Utilities; internal static class FileExtensions { public const string META_FILE_EXTENSION = ".gmeta"; - public const string PROJECT_FILE_EXTENSION = ".gproj"; + public const string PROJECT_FILE_EXTENSION = "." + ProjectMetadata.PROJECT_FILE_EXTENSION_NAME; public const string TEMPLATE_FILE_EXTENSION = ".gtmpl"; public const string SCENE_FILE_EXTENSION = ".gscene"; public const string ASSET_FILE_EXTENSION = ".gasset"; diff --git a/Ghost.Engine/Ghost.Engine.csproj b/Ghost.Engine/Ghost.Engine.csproj index 7f068e1..c5a5de8 100644 --- a/Ghost.Engine/Ghost.Engine.csproj +++ b/Ghost.Engine/Ghost.Engine.csproj @@ -26,7 +26,7 @@ - + diff --git a/Ghost.UnitTest/AssetDatabaseIntegrationTest.cs b/Ghost.UnitTest/AssetDatabaseIntegrationTest.cs new file mode 100644 index 0000000..a40ab9c --- /dev/null +++ b/Ghost.UnitTest/AssetDatabaseIntegrationTest.cs @@ -0,0 +1,367 @@ +using Ghost.Editor.Core.AssetHandle; +using Ghost.Data.Services; + +namespace Ghost.UnitTest; + +/// +/// Comprehensive integration tests for AssetDatabase. +/// Tests database operations, file system watchers, searching, importing, and race conditions. +/// +[TestClass] +[DoNotParallelize] // AssetDatabase is a singleton, tests must run sequentially +public class AssetDatabaseIntegrationTest +{ + private string _testProjectDir = string.Empty; + private string _testAssetsDir = string.Empty; + + public TestContext TestContext { get; set; } + + [TestInitialize] + public async Task Setup() + { + // Create temporary test project structure + _testProjectDir = Path.Combine(Path.GetTempPath(), "GhostAssetDBIntegration_" + Guid.NewGuid().ToString()); + _testAssetsDir = Path.Combine(_testProjectDir, ProjectService.ASSETS_FOLDER); + + Directory.CreateDirectory(_testProjectDir); + Directory.CreateDirectory(_testAssetsDir); + Directory.CreateDirectory(Path.Combine(_testProjectDir, ProjectService.CACHE_FOLDER)); + Directory.CreateDirectory(Path.Combine(_testProjectDir, ProjectService.CONFIG_FOLDER)); + + Console.WriteLine($"Test project directory: {_testProjectDir}"); + Console.WriteLine($"Test assets directory: {_testAssetsDir}"); + + // Create a minimal project file with required metadata + var projectPath = Path.Combine(_testProjectDir, "TestProject.gproj"); + + // Create a proper ProjectMetadata instance + var metadata = new Ghost.Data.Models.ProjectMetadata("TestProject", new Version(1, 0, 0)); + + await using var fileStream = File.Create(projectPath); + await System.Text.Json.JsonSerializer.SerializeAsync(fileStream, metadata, Ghost.Data.JsonContext.Default.ProjectMetadata, TestContext.CancellationToken); + await fileStream.FlushAsync(TestContext.CancellationToken); + fileStream.Close(); + + // Set CurrentProject directly + var projectMetadataInfo = new Ghost.Data.Models.ProjectMetadataInfo(projectPath, metadata); + ProjectService.CurrentProject = projectMetadataInfo; + + // Initialize AssetDatabase + AssetDatabase.Initialize(); + + // Give the file system watcher time to start + await Task.Delay(100, TestContext.CancellationToken); + } + + [TestCleanup] + public void Cleanup() + { + // Shutdown AssetDatabase to release file watchers + try + { + AssetDatabase.Shutdown(); + } + catch + { + // Ignore shutdown errors + } + + // Clean up test directory + if (Directory.Exists(_testProjectDir)) + { + try + { + // Add delay to allow file handles to be released + System.Threading.Thread.Sleep(100); + Directory.Delete(_testProjectDir, true); + } + catch + { + // Ignore cleanup errors + } + } + } + + [TestMethod] + public async Task TestAutoMetaGeneration_WhenFileCreated() + { + // Create a test file directly in the file system + var testFile = Path.Combine(_testAssetsDir, "test.txt"); + await File.WriteAllTextAsync(testFile, "Hello World", TestContext.CancellationToken); + + // Wait a bit for file system watcher to react + await Task.Delay(200, TestContext.CancellationToken); + + // Check if meta file was auto-generated + var metaFile = testFile + ".gmeta"; + Assert.IsTrue(File.Exists(metaFile), "Meta file should be auto-generated"); + + // Verify meta file content + var metaContent = await File.ReadAllTextAsync(metaFile, TestContext.CancellationToken); + Assert.Contains("Guid", metaContent, "Meta file should contain GUID"); + } + + [TestMethod] + public async Task TestFindAssetsByName_WithWildcards() + { + // Create test files + await File.WriteAllTextAsync(Path.Combine(_testAssetsDir, "player.txt"), "data", TestContext.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(_testAssetsDir, "player1.txt"), "data", TestContext.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(_testAssetsDir, "player2.txt"), "data", TestContext.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(_testAssetsDir, "enemy.txt"), "data", TestContext.CancellationToken); + + // Wait for database to update + await Task.Delay(200, TestContext.CancellationToken); + + // Test wildcard search: player* + var results = await AssetDatabase.FindAssetsByNameAsync("player*"); + Assert.HasCount(3, results, "Should find 3 files matching 'player*'"); + + // Test single character wildcard: player? + results = await AssetDatabase.FindAssetsByNameAsync("player?.txt"); + Assert.HasCount(2, results, "Should find 2 files matching 'player?.txt'"); + + // Test exact match + results = await AssetDatabase.FindAssetsByNameAsync("enemy.txt"); + Assert.HasCount(1, results, "Should find 1 file matching 'enemy.txt'"); + } + + [TestMethod] + public async Task TestFileRename_ViaFileSystem() + { + // Create a file + var originalPath = Path.Combine(_testAssetsDir, "original.txt"); + await File.WriteAllTextAsync(originalPath, "data", TestContext.CancellationToken); + await Task.Delay(200, TestContext.CancellationToken); + + // Get the GUID before rename + var guidResult = AssetDatabase.PathToGuid(originalPath); + Assert.IsTrue(guidResult.IsSuccess, "Should be able to get GUID before rename"); + var guid = guidResult.Value; + + // Rename via file system + var newPath = Path.Combine(_testAssetsDir, "renamed.txt"); + File.Move(originalPath, newPath); + await Task.Delay(200, TestContext.CancellationToken); + + // Check if meta file was also moved + var newMetaPath = newPath + ".gmeta"; + Assert.IsTrue(File.Exists(newMetaPath), "Meta file should be moved with the asset"); + + // Verify GUID is preserved + var newGuidResult = AssetDatabase.PathToGuid(newPath); + Assert.IsTrue(newGuidResult.IsSuccess, "Should be able to get GUID after rename"); + Assert.AreEqual(guid, newGuidResult.Value, "GUID should be preserved after rename"); + } + + [TestMethod] + public async Task TestFileDelete_ViaFileSystem() + { + // Create a file + var filePath = Path.Combine(_testAssetsDir, "todelete.txt"); + await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken); + await Task.Delay(200, TestContext.CancellationToken); + + var guidResult = AssetDatabase.PathToGuid(filePath); + Assert.IsTrue(guidResult.IsSuccess); + var guid = guidResult.Value; + + // Delete via file system + File.Delete(filePath); + await Task.Delay(200, TestContext.CancellationToken); + + // Meta file should also be deleted + var metaPath = filePath + ".gmeta"; + Assert.IsFalse(File.Exists(metaPath), "Meta file should be deleted with asset"); + + // Asset should be removed from database + var pathResult = AssetDatabase.GuidToPath(guid); + Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database"); + } + + [TestMethod] + public async Task TestFileCreate_ViaAPI() + { + var filePath = Path.Combine(_testAssetsDir, "apiCreated.txt"); + + // Create via API + var result = await AssetDatabase.CreateAssetAsync(filePath); + Assert.IsTrue(result.IsSuccess, "Should create asset successfully"); + + // File and meta should exist + Assert.IsTrue(File.Exists(filePath), "Asset file should exist"); + Assert.IsTrue(File.Exists(filePath + ".gmeta"), "Meta file should exist"); + + // Should be in database + var guidResult = AssetDatabase.PathToGuid(filePath); + Assert.IsTrue(guidResult.IsSuccess, "Asset should be in database"); + } + + [TestMethod] + public async Task TestFileMove_ViaAPI() + { + // Create initial file + var sourcePath = Path.Combine(_testAssetsDir, "source.txt"); + await File.WriteAllTextAsync(sourcePath, "data", TestContext.CancellationToken); + await Task.Delay(200, TestContext.CancellationToken); + + var guid = AssetDatabase.PathToGuid(sourcePath).Value; + + // Create subdirectory + var subDir = Path.Combine(_testAssetsDir, "SubFolder"); + Directory.CreateDirectory(subDir); + + var destPath = Path.Combine(subDir, "source.txt"); + + // Move via API + var result = await AssetDatabase.MoveAssetAsync(sourcePath, destPath); + Assert.IsTrue(result.IsSuccess, $"Should move asset successfully. Error: {result.Message}"); + + // Old file should not exist + Assert.IsFalse(File.Exists(sourcePath), "Source file should not exist"); + Assert.IsFalse(File.Exists(sourcePath + ".gmeta"), "Source meta should not exist"); + + // New file should exist + Assert.IsTrue(File.Exists(destPath), "Destination file should exist"); + Assert.IsTrue(File.Exists(destPath + ".gmeta"), "Destination meta should exist"); + + // GUID should be preserved + var newGuid = AssetDatabase.PathToGuid(destPath).Value; + Assert.AreEqual(guid, newGuid, "GUID should be preserved"); + } + + [TestMethod] + public async Task TestFileCopy_ViaAPI() + { + // Create initial file + var sourcePath = Path.Combine(_testAssetsDir, "tocopy.txt"); + await File.WriteAllTextAsync(sourcePath, "data", TestContext.CancellationToken); + await Task.Delay(200, TestContext.CancellationToken); + + var sourceGuid = AssetDatabase.PathToGuid(sourcePath).Value; + var destPath = Path.Combine(_testAssetsDir, "copied.txt"); + + // Copy via API + var result = await AssetDatabase.CopyAssetAsync(sourcePath, destPath); + Assert.IsTrue(result.IsSuccess, "Should copy asset successfully"); + + // Both files should exist + Assert.IsTrue(File.Exists(sourcePath), "Source file should still exist"); + Assert.IsTrue(File.Exists(destPath), "Destination file should exist"); + + // Both should have different GUIDs + var destGuid = AssetDatabase.PathToGuid(destPath).Value; + Assert.AreNotEqual(sourceGuid, destGuid, "Copied asset should have different GUID"); + } + + [TestMethod] + public async Task TestFileDelete_ViaAPI() + { + // Create initial file + var filePath = Path.Combine(_testAssetsDir, "todelete2.txt"); + await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken); + await Task.Delay(200, TestContext.CancellationToken); + + var guid = AssetDatabase.PathToGuid(filePath).Value; + + // Delete via API + var result = await AssetDatabase.DeleteAssetAsync(filePath); + Assert.IsTrue(result.IsSuccess, "Should delete asset successfully"); + + // File and meta should not exist + Assert.IsFalse(File.Exists(filePath), "File should be deleted"); + Assert.IsFalse(File.Exists(filePath + ".gmeta"), "Meta should be deleted"); + + // Should be removed from database + var pathResult = AssetDatabase.GuidToPath(guid); + Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database"); + } + + [TestMethod] + public async Task TestRaceCondition_MultipleFileCreations() + { + // Create multiple files simultaneously to test debouncing + var tasks = new List(); + var fileNames = new List(); + + for (int i = 0; i < 10; i++) + { + var fileName = $"race{i}.txt"; + fileNames.Add(fileName); + var filePath = Path.Combine(_testAssetsDir, fileName); + + tasks.Add(Task.Run(async () => + { + await File.WriteAllTextAsync(filePath, $"data{i}", TestContext.CancellationToken); + }, TestContext.CancellationToken)); + } + + await Task.WhenAll(tasks); + await Task.Delay(500, TestContext.CancellationToken); // Wait for all file system events + + // All files should have exactly one meta file + foreach (var fileName in fileNames) + { + var filePath = Path.Combine(_testAssetsDir, fileName); + var metaPath = filePath + ".gmeta"; + + Assert.IsTrue(File.Exists(metaPath), $"Meta file should exist for {fileName}"); + + // Read meta and verify it's valid JSON + var metaContent = await File.ReadAllTextAsync(metaPath, TestContext.CancellationToken); + Assert.Contains("Guid", metaContent, $"Meta file should be valid for {fileName}"); + } + } + + [TestMethod] + public async Task TestTagSearching() + { + // Create files and add tags + var file1 = Path.Combine(_testAssetsDir, "tagged1.txt"); + var file2 = Path.Combine(_testAssetsDir, "tagged2.txt"); + var file3 = Path.Combine(_testAssetsDir, "untagged.txt"); + + await File.WriteAllTextAsync(file1, "data", TestContext.CancellationToken); + await File.WriteAllTextAsync(file2, "data", TestContext.CancellationToken); + await File.WriteAllTextAsync(file3, "data", TestContext.CancellationToken); + await Task.Delay(200, TestContext.CancellationToken); + + var guid1 = AssetDatabase.PathToGuid(file1).Value; + var guid2 = AssetDatabase.PathToGuid(file2).Value; + + // Add tags + await AssetDatabase.SetAssetTagsAsync(guid1, new List { "Test", "Player" }); + await AssetDatabase.SetAssetTagsAsync(guid2, new List { "Test", "Enemy" }); + + // Search by tag + var testAssets = await AssetDatabase.FindAssetsByTagAsync("Test"); + Assert.HasCount(2, testAssets, "Should find 2 assets with 'Test' tag"); + + var playerAssets = await AssetDatabase.FindAssetsByTagAsync("Player"); + Assert.HasCount(1, playerAssets, "Should find 1 asset with 'Player' tag"); + } + + [TestMethod] + public async Task TestRefreshAsync_DoesNotDuplicateMetadata() + { + // Create a file + var filePath = Path.Combine(_testAssetsDir, "refresh.txt"); + await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken); + await Task.Delay(200, TestContext.CancellationToken); + + var guid1 = AssetDatabase.PathToGuid(filePath).Value; + + // Call RefreshAsync multiple times + await AssetDatabase.RefreshAsync(); + await AssetDatabase.RefreshAsync(); + await AssetDatabase.RefreshAsync(); + + // GUID should remain the same + var guid2 = AssetDatabase.PathToGuid(filePath).Value; + Assert.AreEqual(guid1, guid2, "GUID should not change after refresh"); + + // Only one meta file should exist + var metaFiles = Directory.GetFiles(_testAssetsDir, "refresh.txt.gmeta"); + Assert.HasCount(1, metaFiles, "Should have exactly one meta file"); + } +} diff --git a/Ghost.UnitTest/AssetMetaTest.cs b/Ghost.UnitTest/AssetMetaTest.cs new file mode 100644 index 0000000..0d13dc1 --- /dev/null +++ b/Ghost.UnitTest/AssetMetaTest.cs @@ -0,0 +1,94 @@ +using Ghost.Editor.Core.AssetHandle; +using Ghost.Editor.Core.AssetHandle.Importers; +using System.Text.Json; + +namespace Ghost.UnitTest; + +[TestClass] +public class AssetMetaTest +{ + [TestMethod] + public void TestMetaSerialization() + { + var meta = new AssetMeta + { + Guid = Guid.NewGuid(), + Version = 1, + Tags = new List { "Test", "Asset" } + }; + + var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true }); + + Assert.IsNotNull(json); + Assert.Contains("Guid", json); + Assert.Contains("Version", json); + Assert.Contains("Tags", json); + } + + [TestMethod] + public void TestMetaDeserialization() + { + var guid = Guid.NewGuid(); + + var json = $@"{{ + ""Guid"": ""{guid}"", + ""Version"": 1, + ""Tags"": [""Test"", ""Asset""] + }}"; + + var meta = JsonSerializer.Deserialize(json); + + Assert.IsNotNull(meta); + Assert.AreEqual(guid, meta.Guid); + Assert.AreEqual(1, meta.Version); + Assert.HasCount(2, meta.Tags); + Assert.Contains("Test", meta.Tags); + } + + [TestMethod] + public void TestMetaWithSettings() + { + var meta = new AssetMeta + { + Guid = Guid.NewGuid(), + Version = 1 + }; + + // Add importer settings using the new API + var settings = new TextImporterSettings + { + Encoding = "UTF-8", + TrimWhitespace = true + }; + + meta.SetImporterSettings("TextImporter", settings); + + var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true }); + var deserialized = JsonSerializer.Deserialize(json); + + Assert.IsNotNull(deserialized); + Assert.Contains("TextImporter", deserialized.ImporterSettings.Keys); + + // Test retrieving the settings + var retrievedSettings = deserialized.GetImporterSettings("TextImporter"); + Assert.IsNotNull(retrievedSettings); + Assert.AreEqual("UTF-8", retrievedSettings.Encoding); + Assert.IsTrue(retrievedSettings.TrimWhitespace); + } + + [TestMethod] + public void TestFileHashAndDependenciesNotSerialized() + { + var meta = new AssetMeta + { + Guid = Guid.NewGuid(), + Version = 1 + }; + + var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true }); + + // FileHash and Dependencies should NOT be in the serialized JSON + Assert.DoesNotContain("FileHash", json); + Assert.DoesNotContain("Dependencies", json); + } +} diff --git a/Ghost.UnitTest/MSTestSettings.cs b/Ghost.UnitTest/MSTestSettings.cs index aaf278c..300f5b1 100644 --- a/Ghost.UnitTest/MSTestSettings.cs +++ b/Ghost.UnitTest/MSTestSettings.cs @@ -1 +1 @@ -[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/Ghost.UnitTest/Test1.cs b/Ghost.UnitTest/Test1.cs deleted file mode 100644 index 46bccf7..0000000 --- a/Ghost.UnitTest/Test1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Ghost.UnitTest; - -[TestClass] -public sealed class Test1 -{ - [TestMethod] - public void TestMethod1() - { - } -}