Update AssetDatabase
This commit is contained in:
@@ -2,6 +2,8 @@ namespace Ghost.Data.Models;
|
|||||||
|
|
||||||
public class ProjectMetadata
|
public class ProjectMetadata
|
||||||
{
|
{
|
||||||
|
public const string PROJECT_FILE_EXTENSION_NAME = "gproj";
|
||||||
|
|
||||||
public Guid ID
|
public Guid ID
|
||||||
{
|
{
|
||||||
get; set;
|
get; set;
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ internal partial class ProjectService
|
|||||||
return Result<ProjectMetadataInfo>.Failure("Project folder structure is invalid.");
|
return Result<ProjectMetadataInfo>.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))
|
if (string.IsNullOrWhiteSpace(metadataPath) || !File.Exists(metadataPath))
|
||||||
{
|
{
|
||||||
return Result<ProjectMetadataInfo>.Failure("Project metadata file not found.");
|
return Result<ProjectMetadataInfo>.Failure("Project metadata file not found.");
|
||||||
@@ -193,7 +193,7 @@ internal partial class ProjectService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var metadata = new ProjectMetadata(projectName, engineVersion);
|
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 CreateMetadataFileAsync(metadataPath, metadata);
|
||||||
await SetupRequestFolderAsync(projectPath, templatePath);
|
await SetupRequestFolderAsync(projectPath, templatePath);
|
||||||
|
|
||||||
|
|||||||
363
Ghost.Editor.Core/AssetHandle/AssetDatabase.FileOps.cs
Normal file
363
Ghost.Editor.Core/AssetHandle/AssetDatabase.FileOps.cs
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
public static partial class AssetDatabase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new asset at the specified path.
|
||||||
|
/// Generates metadata and adds it to the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="assetPath">Path to create the asset at.</param>
|
||||||
|
/// <param name="content">Content to write to the asset file.</param>
|
||||||
|
/// <returns>Result indicating success or failure.</returns>
|
||||||
|
public static async Task<Result> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create an empty asset at the specified path.
|
||||||
|
/// Generates metadata and adds it to the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="assetPath">Path to create the asset at.</param>
|
||||||
|
/// <returns>Result indicating success or failure.</returns>
|
||||||
|
public static async Task<Result> CreateAssetAsync(string assetPath)
|
||||||
|
{
|
||||||
|
return await CreateAssetAsync(assetPath, Array.Empty<byte>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete an asset and its metadata.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guid">GUID of the asset to delete.</param>
|
||||||
|
/// <returns>Result indicating success or failure.</returns>
|
||||||
|
public static async Task<Result> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete an asset and its metadata by path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="assetPath">Path to the asset to delete.</param>
|
||||||
|
/// <returns>Result indicating success or failure.</returns>
|
||||||
|
public static async Task<Result> DeleteAssetAsync(string assetPath)
|
||||||
|
{
|
||||||
|
var guidResult = PathToGuid(assetPath);
|
||||||
|
if (guidResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result.Failure(guidResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await DeleteAssetAsync(guidResult.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Move an asset to a new location.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guid">GUID of the asset to move.</param>
|
||||||
|
/// <param name="newPath">New path for the asset (relative or absolute).</param>
|
||||||
|
/// <returns>Result indicating success or failure.</returns>
|
||||||
|
public static async Task<Result> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Move an asset to a new location by path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="oldPath">Current path of the asset.</param>
|
||||||
|
/// <param name="newPath">New path for the asset (relative or absolute).</param>
|
||||||
|
/// <returns>Result indicating success or failure.</returns>
|
||||||
|
public static async Task<Result> MoveAssetAsync(string oldPath, string newPath)
|
||||||
|
{
|
||||||
|
var guidResult = PathToGuid(oldPath);
|
||||||
|
if (guidResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result.Failure(guidResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await MoveAssetAsync(guidResult.Value, newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copy an asset to a new location with a new GUID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guid">GUID of the asset to copy.</param>
|
||||||
|
/// <param name="newPath">New path for the copied asset (relative or absolute).</param>
|
||||||
|
/// <returns>Result containing the new asset's GUID.</returns>
|
||||||
|
public static async Task<Result<Guid>> CopyAssetAsync(Guid guid, string newPath)
|
||||||
|
{
|
||||||
|
var oldPathResult = GuidToPath(guid);
|
||||||
|
if (oldPathResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result<Guid>.Failure(oldPathResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldFullPathResult = GetFullPath(oldPathResult.Value);
|
||||||
|
if (oldFullPathResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result<Guid>.Failure(oldFullPathResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AssetsDirectory == null)
|
||||||
|
{
|
||||||
|
return Result<Guid>.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<Guid>.Failure("New path must be within the Assets directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(newPath))
|
||||||
|
{
|
||||||
|
return Result<Guid>.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<Guid>.Failure(newGuidResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newGuidResult.Value;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result<Guid>.Failure($"Failed to copy asset: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copy an asset to a new location by path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sourcePath">Path of the asset to copy.</param>
|
||||||
|
/// <param name="destPath">New path for the copied asset (relative or absolute).</param>
|
||||||
|
/// <returns>Result containing the new asset's GUID.</returns>
|
||||||
|
public static async Task<Result<Guid>> CopyAssetAsync(string sourcePath, string destPath)
|
||||||
|
{
|
||||||
|
var guidResult = PathToGuid(sourcePath);
|
||||||
|
if (guidResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result<Guid>.Failure(guidResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await CopyAssetAsync(guidResult.Value, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mark an asset as dirty for re-importing.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guid">GUID of the asset to mark dirty.</param>
|
||||||
|
/// <returns>Result indicating success or failure.</returns>
|
||||||
|
public static async Task<Result> MarkDirtyAsync(Guid guid)
|
||||||
|
{
|
||||||
|
return await MarkAssetDirtyAsync(guid, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Import all dirty assets.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Result indicating success or failure.</returns>
|
||||||
|
public static async Task<Result> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
158
Ghost.Editor.Core/AssetHandle/AssetDatabase.Importer.cs
Normal file
158
Ghost.Editor.Core/AssetHandle/AssetDatabase.Importer.cs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
public static partial class AssetDatabase
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Type, object> s_importerInstances = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Import an asset at the specified path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="assetPath">Full path to the asset file.</param>
|
||||||
|
/// <returns>Result indicating success or failure.</returns>
|
||||||
|
private static async Task<Result> 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<Result>;
|
||||||
|
if (task == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Importer did not return a valid Task<Result>");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await task;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Asset import failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the importer type for a specific file extension.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="extension">File extension (e.g., ".png").</param>
|
||||||
|
/// <returns>The importer type if found, otherwise null.</returns>
|
||||||
|
public static Type? GetImporterType(string extension)
|
||||||
|
{
|
||||||
|
s_importerTypeLookup.TryGetValue(extension, out var importerType);
|
||||||
|
return importerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all registered importer types and their supported extensions.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Dictionary mapping extensions to importer types.</returns>
|
||||||
|
public static Dictionary<string, Type> GetAllImporters()
|
||||||
|
{
|
||||||
|
return new Dictionary<string, Type>(s_importerTypeLookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Export in-memory asset data to disk.
|
||||||
|
/// The importer will serialize the data into a format it can later import.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Type of asset data to export.</typeparam>
|
||||||
|
/// <param name="assetPath">Full path where the asset should be saved.</param>
|
||||||
|
/// <param name="assetData">In-memory asset data to export.</param>
|
||||||
|
/// <returns>Result with the GUID of the exported asset.</returns>
|
||||||
|
public static async Task<Result<Guid>> ExportAssetAsync<T>(string assetPath, T assetData) where T : class
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(assetPath);
|
||||||
|
|
||||||
|
if (!s_importerTypeLookup.TryGetValue(extension, out var importerType))
|
||||||
|
{
|
||||||
|
return Result<Guid>.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<Guid>.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<Guid>.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<Guid>.Failure($"Failed to generate metadata: {metaResult.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var task = exportMethod.Invoke(importerInstance, new object[] { assetPath, assetData, metaResult.Value }) as Task<Result>;
|
||||||
|
if (task == null)
|
||||||
|
{
|
||||||
|
return Result<Guid>.Failure("Exporter did not return a valid Task<Result>");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await task;
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return Result<Guid>.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<Guid>.Failure($"Asset export failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
242
Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs
Normal file
242
Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs
Normal file
@@ -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<Guid, Asset> s_assetCache = new();
|
||||||
|
|
||||||
|
// LRU tracking - stores access time for each cached asset
|
||||||
|
private static readonly ConcurrentDictionary<Guid, DateTime> 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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the path to the imported asset data directory.
|
||||||
|
/// </summary>
|
||||||
|
private static Result<string> GetImportedAssetsDirectory()
|
||||||
|
{
|
||||||
|
if (AssetsDirectory == null)
|
||||||
|
{
|
||||||
|
return Result<string>.Failure("AssetsDirectory not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheDir = Path.Combine(AssetsDirectory.Parent!.FullName, ProjectService.CACHE_FOLDER, "ImportedAssets");
|
||||||
|
|
||||||
|
if (!Directory.Exists(cacheDir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(cacheDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the path where imported asset data is stored for a specific GUID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guid">GUID of the asset.</param>
|
||||||
|
/// <returns>Full path to the imported asset data file.</returns>
|
||||||
|
private static Result<string> GetImportedAssetPath(Guid guid)
|
||||||
|
{
|
||||||
|
var importedDirResult = GetImportedAssetsDirectory();
|
||||||
|
if (importedDirResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result<string>.Failure(importedDirResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store imported assets as {GUID}.asset
|
||||||
|
var assetDataPath = Path.Combine(importedDirResult.Value, $"{guid}.asset");
|
||||||
|
return assetDataPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load asset by GUID with caching (internal implementation).
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Type of asset to load.</typeparam>
|
||||||
|
/// <param name="guid">GUID of the asset.</param>
|
||||||
|
/// <returns>The loaded asset.</returns>
|
||||||
|
private static Result<T> LoadAssetInternal<T>(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<T>.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<T>.Failure(assetPathResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var assetDataPath = assetPathResult.Value;
|
||||||
|
|
||||||
|
if (!File.Exists(assetDataPath))
|
||||||
|
{
|
||||||
|
return Result<T>.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<T>(json);
|
||||||
|
|
||||||
|
if (asset == null)
|
||||||
|
{
|
||||||
|
return Result<T>.Failure("Failed to deserialize asset data");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to cache
|
||||||
|
CacheAsset(guid, asset);
|
||||||
|
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result<T>.Failure($"Failed to load asset: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load asset by path with caching.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Type of asset to load.</typeparam>
|
||||||
|
/// <param name="assetPath">Full or relative path to the asset.</param>
|
||||||
|
/// <returns>The loaded asset.</returns>
|
||||||
|
public static Result<T> LoadAssetAtPath<T>(string assetPath) where T : Asset
|
||||||
|
{
|
||||||
|
var guidResult = PathToGuid(assetPath);
|
||||||
|
if (guidResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result<T>.Failure(guidResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoadAsset<T>(guidResult.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add an asset to the cache with LRU eviction if needed.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evict the oldest assets from cache based on LRU.
|
||||||
|
/// </summary>
|
||||||
|
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 _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unload a specific asset from cache.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guid">GUID of the asset to unload.</param>
|
||||||
|
public static void UnloadAsset(Guid guid)
|
||||||
|
{
|
||||||
|
s_assetCache.TryRemove(guid, out _);
|
||||||
|
s_assetAccessTime.TryRemove(guid, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unload all assets from cache.
|
||||||
|
/// </summary>
|
||||||
|
public static void UnloadAllAssets()
|
||||||
|
{
|
||||||
|
s_assetCache.Clear();
|
||||||
|
s_assetAccessTime.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if an asset is currently loaded in cache.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guid">GUID of the asset.</param>
|
||||||
|
/// <returns>True if the asset is in cache.</returns>
|
||||||
|
public static bool IsAssetLoaded(Guid guid)
|
||||||
|
{
|
||||||
|
return s_assetCache.ContainsKey(guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get cache statistics.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Tuple of (current cache size, max cache size).</returns>
|
||||||
|
public static (int currentSize, int maxSize) GetCacheStats()
|
||||||
|
{
|
||||||
|
return (s_assetCache.Count, MAX_CACHED_ASSETS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save an imported asset to disk for later loading.
|
||||||
|
/// This should be called by importers after processing the source file.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Type of asset data.</typeparam>
|
||||||
|
/// <param name="guid">GUID of the asset.</param>
|
||||||
|
/// <param name="assetData">Processed asset data to save.</param>
|
||||||
|
/// <returns>Result indicating success or failure.</returns>
|
||||||
|
internal static Result SaveImportedAsset<T>(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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
203
Ghost.Editor.Core/AssetHandle/AssetDatabase.Lookup.cs
Normal file
203
Ghost.Editor.Core/AssetHandle/AssetDatabase.Lookup.cs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
public static partial class AssetDatabase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get the relative path from the assets directory.
|
||||||
|
/// </summary>
|
||||||
|
private static Result<string> GetRelativePath(string fullPath)
|
||||||
|
{
|
||||||
|
if (AssetsDirectory == null)
|
||||||
|
{
|
||||||
|
return Result<string>.Failure("AssetsDirectory not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fullPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Result<string>.Failure("Path is not within assets directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetRelativePath(AssetsDirectory.FullName, fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the full path from a relative path.
|
||||||
|
/// </summary>
|
||||||
|
private static Result<string> GetFullPath(string relativePath)
|
||||||
|
{
|
||||||
|
if (AssetsDirectory == null)
|
||||||
|
{
|
||||||
|
return Result<string>.Failure("AssetsDirectory not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(AssetsDirectory.FullName, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Find GUID by asset path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="assetPath">Full or relative path to the asset.</param>
|
||||||
|
/// <returns>The GUID of the asset if found.</returns>
|
||||||
|
public static Result<Guid> 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<Guid>.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<Guid>.Failure("Asset not found in database");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Find path by GUID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guid">GUID of the asset.</param>
|
||||||
|
/// <returns>The relative path to the asset if found.</returns>
|
||||||
|
public static Result<string> GuidToPath(Guid guid)
|
||||||
|
{
|
||||||
|
lock (s_dbLock)
|
||||||
|
{
|
||||||
|
if (s_assetPathLookup.TryGetValue(guid, out var path))
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result<string>.Failure("Asset GUID not found in database");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load asset by GUID with caching.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Type of asset to load.</typeparam>
|
||||||
|
/// <param name="guid">GUID of the asset.</param>
|
||||||
|
/// <returns>The loaded asset.</returns>
|
||||||
|
public static Result<T> LoadAsset<T>(Guid guid) where T : Asset
|
||||||
|
{
|
||||||
|
// Implemented in AssetDatabase.Loader.cs
|
||||||
|
return LoadAssetInternal<T>(guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get asset tags by GUID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guid">GUID of the asset.</param>
|
||||||
|
/// <returns>List of tags associated with the asset.</returns>
|
||||||
|
public static async ValueTask<Result<List<string>>> GetAssetTagsAsync(Guid guid, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var pathResult = GuidToPath(guid);
|
||||||
|
if (pathResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result<List<string>>.Failure(pathResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullPathResult = GetFullPath(pathResult.Value);
|
||||||
|
if (fullPathResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result<List<string>>.Failure(fullPathResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var metaResult = await ReadMetaFileAsync(fullPathResult.Value);
|
||||||
|
if (metaResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result<List<string>>.Failure(metaResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return metaResult.Value.Tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set asset tags by GUID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guid">GUID of the asset.</param>
|
||||||
|
/// <param name="tags">New tags for the asset.</param>
|
||||||
|
/// <returns>Result indicating success or failure.</returns>
|
||||||
|
public static async ValueTask<Result> SetAssetTagsAsync(Guid guid, List<string> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Search assets by name pattern.
|
||||||
|
/// Supports SQL LIKE wildcards: * (any characters) and ? (single character).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="namePattern">Search pattern (e.g., "*.txt", "player?", "test*").</param>
|
||||||
|
/// <returns>List of matching asset GUIDs.</returns>
|
||||||
|
public static async Task<List<Guid>> FindAssetsByNameAsync(string namePattern)
|
||||||
|
{
|
||||||
|
return await GetAssetsByNameAsync(namePattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Find assets by tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tag">Tag to search for.</param>
|
||||||
|
/// <returns>List of asset GUIDs with the specified tag.</returns>
|
||||||
|
public static async Task<List<Guid>> FindAssetsByTagAsync(string tag)
|
||||||
|
{
|
||||||
|
return await GetAssetsByTagAsync(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all assets in the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Dictionary mapping GUIDs to relative paths.</returns>
|
||||||
|
public static IReadOnlyDictionary<Guid, string> GetAllAssets()
|
||||||
|
{
|
||||||
|
lock (s_dbLock)
|
||||||
|
{
|
||||||
|
return s_assetPathLookup.AsReadOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Editor.Core.Utilities;
|
using Ghost.Editor.Core.Utilities;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
@@ -8,8 +10,6 @@ namespace Ghost.Editor.Core.AssetHandle;
|
|||||||
public static partial class AssetDatabase
|
public static partial class AssetDatabase
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<string, Type> s_importerTypeLookup = new();
|
private static readonly Dictionary<string, Type> s_importerTypeLookup = new();
|
||||||
private static readonly Dictionary<Guid, string> s_assetPathLookup = new();
|
|
||||||
private static readonly Dictionary<string, Guid> s_pathAssetLookup = new();
|
|
||||||
|
|
||||||
private static void InitializeMetaData()
|
private static void InitializeMetaData()
|
||||||
{
|
{
|
||||||
@@ -31,18 +31,19 @@ public static partial class AssetDatabase
|
|||||||
s_watcher.Created += OnAssetCreated;
|
s_watcher.Created += OnAssetCreated;
|
||||||
s_watcher.Deleted += OnAssetDeleted;
|
s_watcher.Deleted += OnAssetDeleted;
|
||||||
s_watcher.Renamed += OnAssetRenamed;
|
s_watcher.Renamed += OnAssetRenamed;
|
||||||
|
s_watcher.Changed += OnAssetChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Result<string, Error> GetMetaFilePath(string assetPath)
|
private static Result<string> GetMetaFilePath(string assetPath)
|
||||||
{
|
{
|
||||||
if (Directory.Exists(assetPath))
|
if (Directory.Exists(assetPath))
|
||||||
{
|
{
|
||||||
return Error.NotFound;
|
return Result<string>.Failure("Cannot create metadata for directories");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Path.GetExtension(assetPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
|
if (Path.GetExtension(assetPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return Error.InvalidState;
|
return Result<string>.Failure("Cannot create metadata for metadata files");
|
||||||
}
|
}
|
||||||
|
|
||||||
return assetPath + FileExtensions.META_FILE_EXTENSION;
|
return assetPath + FileExtensions.META_FILE_EXTENSION;
|
||||||
@@ -66,40 +67,92 @@ public static partial class AssetDatabase
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Result> WriteMetaFileAsync(string metaFilePath, AssetMeta metaData)
|
/// <summary>
|
||||||
|
/// Calculate SHA256 hash of a file for change detection.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string> CalculateFileHashAsync(string filePath)
|
||||||
{
|
{
|
||||||
using var fileStream = File.Create(metaFilePath);
|
|
||||||
|
|
||||||
try
|
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<Result> 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Failure(ex.Message);
|
return Result.Failure(ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Success();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static async Task<Result> GenerateMetaFileAsync(string assetPath)
|
/// <summary>
|
||||||
|
/// Read metadata from a .gmeta file.
|
||||||
|
/// </summary>
|
||||||
|
private static async ValueTask<Result<AssetMeta>> ReadMetaFileAsync(string assetPath, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var metaFileResult = GetMetaFilePath(assetPath);
|
||||||
|
if (metaFileResult.IsFailure)
|
||||||
|
{
|
||||||
|
return Result<AssetMeta>.Failure(metaFileResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(metaFileResult.Value))
|
||||||
|
{
|
||||||
|
return Result<AssetMeta>.Failure("Metadata file does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var fileStream = File.OpenRead(metaFileResult.Value);
|
||||||
|
var meta = await JsonSerializer.DeserializeAsync<AssetMeta>(fileStream, s_defaultJsonOptions, token);
|
||||||
|
if (meta == null)
|
||||||
|
{
|
||||||
|
return Result<AssetMeta>.Failure("Failed to deserialize metadata");
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result<AssetMeta>.Failure($"Failed to read metadata: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async ValueTask<Result> GenerateMetaFileAsync(string assetPath, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
Result r;
|
Result r;
|
||||||
|
|
||||||
var metaFileResult = GetMetaFilePath(assetPath);
|
var metaFileResult = GetMetaFilePath(assetPath);
|
||||||
if (metaFileResult.IsFailure)
|
if (metaFileResult.IsFailure)
|
||||||
{
|
{
|
||||||
return Result.Failure(metaFileResult.Error);
|
return Result.Failure(metaFileResult.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (File.Exists(metaFileResult.Value))
|
if (File.Exists(metaFileResult.Value))
|
||||||
{
|
{
|
||||||
using var fileStream = File.OpenRead(metaFileResult.Value);
|
var existingMetaResult = await ReadMetaFileAsync(assetPath);
|
||||||
var existingMeta = await JsonSerializer.DeserializeAsync<AssetMeta>(fileStream);
|
if (existingMetaResult.IsSuccess)
|
||||||
if (existingMeta != null && s_assetPathLookup.TryGetValue(existingMeta.Guid, out var path))
|
|
||||||
{
|
{
|
||||||
if (assetPath != path)
|
var existingMeta = existingMetaResult.Value;
|
||||||
|
if (s_assetPathLookup.TryGetValue(existingMeta.Guid, out var path))
|
||||||
{
|
{
|
||||||
|
var relResult = GetRelativePath(assetPath);
|
||||||
|
if (relResult.IsSuccess && assetPath != path)
|
||||||
|
{
|
||||||
|
// GUID conflict - regenerate
|
||||||
existingMeta.Guid = Guid.NewGuid();
|
existingMeta.Guid = Guid.NewGuid();
|
||||||
r = await WriteMetaFileAsync(metaFileResult.Value, existingMeta);
|
r = await WriteMetaFileAsync(metaFileResult.Value, existingMeta);
|
||||||
if (r.IsFailure)
|
if (r.IsFailure)
|
||||||
@@ -109,62 +162,199 @@ public static partial class AssetDatabase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate file hash and update database
|
||||||
|
var fileHash = await CalculateFileHashAsync(assetPath);
|
||||||
|
await UpsertAssetAsync(assetPath, existingMeta, fileHash);
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate initial file hash
|
||||||
|
var fileHash2 = await CalculateFileHashAsync(assetPath);
|
||||||
|
|
||||||
var defaultSettings = GetDefaultSettingsForAsset(assetPath);
|
var defaultSettings = GetDefaultSettingsForAsset(assetPath);
|
||||||
var metaData = new AssetMeta
|
var metaData = new AssetMeta
|
||||||
{
|
{
|
||||||
Guid = Guid.NewGuid(),
|
Guid = Guid.NewGuid()
|
||||||
Settings = defaultSettings
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (defaultSettings != null)
|
||||||
|
{
|
||||||
|
metaData.SetImporterSettings(defaultSettings.GetType().Name, defaultSettings);
|
||||||
|
}
|
||||||
|
|
||||||
r = await WriteMetaFileAsync(metaFileResult.Value, metaData);
|
r = await WriteMetaFileAsync(metaFileResult.Value, metaData);
|
||||||
|
if (r.IsFailure)
|
||||||
|
{
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to database
|
||||||
|
await UpsertAssetAsync(assetPath, metaData, fileHash2);
|
||||||
|
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async void OnAssetCreated(object sender, FileSystemEventArgs e)
|
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);
|
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);
|
var metaFileResult = GetMetaFilePath(e.FullPath);
|
||||||
if (metaFileResult.IsSuccess && File.Exists(metaFileResult.Value))
|
if (metaFileResult.IsSuccess && File.Exists(metaFileResult.Value))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var meta = JsonSerializer.Deserialize<AssetMeta>(File.ReadAllText(metaFileResult.Value));
|
var metaResult = await ReadMetaFileAsync(e.FullPath);
|
||||||
if (meta != null
|
if (metaResult.IsSuccess)
|
||||||
&& s_assetPathLookup.TryGetValue(meta.Guid, out var path)
|
|
||||||
&& path == e.FullPath)
|
|
||||||
{
|
{
|
||||||
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);
|
File.Delete(metaFileResult.Value);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex);
|
Console.WriteLine($"Error deleting asset metadata: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async void OnAssetRenamed(object sender, RenamedEventArgs e)
|
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 oldMetaPath = e.OldFullPath + FileExtensions.META_FILE_EXTENSION;
|
||||||
var newMetaPath = e.FullPath + 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);
|
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
|
else
|
||||||
{
|
{
|
||||||
|
// Generate new meta file
|
||||||
await GenerateMetaFileAsync(e.FullPath);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mark all assets that depend on the specified asset as dirty.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
452
Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs
Normal file
452
Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialize the SQLite database for asset caching.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add or update an asset in the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="assetPath">Full path to the asset file.</param>
|
||||||
|
/// <param name="meta">Asset metadata from .gmeta file.</param>
|
||||||
|
/// <param name="fileHash">SHA256 hash of the asset file content.</param>
|
||||||
|
/// <param name="dependencies">List of GUIDs this asset depends on (extracted during import).</param>
|
||||||
|
private static async ValueTask<Result> UpsertAssetAsync(string assetPath, AssetMeta meta, string fileHash, List<Guid>? 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<Guid>()));
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove an asset from the database.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<Result> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mark an asset as dirty for re-importing.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<Result> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all dirty assets that need re-importing.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<List<(Guid guid, string path)>> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load all assets from the database into memory cache.
|
||||||
|
/// </summary>
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get assets by tag.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<List<Guid>> GetAssetsByTagAsync(string tag)
|
||||||
|
{
|
||||||
|
var result = new List<Guid>();
|
||||||
|
|
||||||
|
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<List<string>>(tagsJson);
|
||||||
|
if (tags != null && tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
result.Add(guid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the file hash for an asset from the database.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the dependencies for an asset from the database.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<List<Guid>> GetDependenciesAsync(Guid guid)
|
||||||
|
{
|
||||||
|
if (s_dbConnection == null)
|
||||||
|
{
|
||||||
|
return new List<Guid>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<List<Guid>>(json ?? "[]") ?? new List<Guid>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
|
||||||
|
return new List<Guid>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Find assets by name pattern using database query with wildcards.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="namePattern">Pattern supporting * (any chars) and ? (single char).</param>
|
||||||
|
private static async Task<List<Guid>> GetAssetsByNameAsync(string namePattern)
|
||||||
|
{
|
||||||
|
var results = new List<Guid>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove orphaned entries from database (assets that no longer exist on disk).
|
||||||
|
/// </summary>
|
||||||
|
private static async Task RemoveOrphanedEntriesAsync()
|
||||||
|
{
|
||||||
|
if (s_dbConnection == null || AssetsDirectory == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var orphanedGuids = new List<Guid>();
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,39 @@
|
|||||||
using Ghost.Data.Services;
|
using Ghost.Data.Services;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static partial class AssetDatabase
|
public static partial class AssetDatabase
|
||||||
{
|
{
|
||||||
private static FileSystemWatcher? s_watcher;
|
private static FileSystemWatcher? s_watcher;
|
||||||
|
private static readonly Lock s_dbLock = new();
|
||||||
|
private static readonly Dictionary<Guid, string> s_assetPathLookup = new();
|
||||||
|
private static readonly Dictionary<string, Guid> s_pathAssetLookup = new();
|
||||||
|
|
||||||
|
// Debouncing for file system watcher to prevent duplicate events
|
||||||
|
private static readonly Dictionary<string, DateTime> 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
|
public static DirectoryInfo? AssetsDirectory
|
||||||
{
|
{
|
||||||
@@ -12,22 +41,189 @@ public static partial class AssetDatabase
|
|||||||
private set;
|
private set;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void Initialize()
|
/// <summary>
|
||||||
|
/// Initialize the asset database.
|
||||||
|
/// Must be called after project is loaded.
|
||||||
|
/// </summary>
|
||||||
|
internal static async void Initialize()
|
||||||
{
|
{
|
||||||
|
lock (s_initializationLock)
|
||||||
|
{
|
||||||
|
if (s_initialized)
|
||||||
|
{
|
||||||
|
return; // Already initialized, skip
|
||||||
|
}
|
||||||
|
s_initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (ProjectService.CurrentProject.Metadata == null)
|
if (ProjectService.CurrentProject.Metadata == null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Project metadata is not initialized. Ensure that the project is loaded before accessing the AssetDatabase.");
|
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));
|
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
|
s_watcher = new FileSystemWatcher
|
||||||
{
|
{
|
||||||
Path = AssetsDirectory.FullName,
|
Path = AssetsDirectory.FullName,
|
||||||
IncludeSubdirectories = true,
|
IncludeSubdirectories = true,
|
||||||
EnableRaisingEvents = true
|
EnableRaisingEvents = true,
|
||||||
|
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite
|
||||||
};
|
};
|
||||||
|
|
||||||
InitializeAssetHandle();
|
InitializeAssetHandle();
|
||||||
InitializeMetaData();
|
InitializeMetaData();
|
||||||
|
|
||||||
|
// Validate and fix database on startup
|
||||||
|
await ValidateAndFixDatabaseAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validate the asset database and fix any inconsistencies.
|
||||||
|
/// Checks for missing/corrupted assets and regenerates metadata as needed.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<Ghost.Core.Result> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refresh the asset database manually.
|
||||||
|
/// Scans the project directory for changes.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<Ghost.Core.Result> RefreshAsync()
|
||||||
|
{
|
||||||
|
return await ValidateAndFixDatabaseAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if a file operation should be processed or debounced.
|
||||||
|
/// Returns true if the operation should proceed.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register a file operation to prevent the file watcher from processing it.
|
||||||
|
/// Used by file operations (move, copy, etc.) to prevent duplicate processing.
|
||||||
|
/// </summary>
|
||||||
|
private static void RegisterFileOperation(string filePath)
|
||||||
|
{
|
||||||
|
lock (s_pendingOperationsLock)
|
||||||
|
{
|
||||||
|
s_pendingFileOperations[filePath] = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shutdown the asset database.
|
||||||
|
/// Disposes resources and closes database connections.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
80
Ghost.Editor.Core/AssetHandle/AssetImporter.cs
Normal file
80
Ghost.Editor.Core/AssetHandle/AssetImporter.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for all asset importers.
|
||||||
|
/// Asset importers process source files and convert them into engine-ready formats.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TSettings">The type of importer settings this importer uses.</typeparam>
|
||||||
|
internal abstract class AssetImporter<TSettings>
|
||||||
|
where TSettings : ImporterSettings, new()
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Import the asset at the specified path with the given settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="assetPath">Full path to the source asset file.</param>
|
||||||
|
/// <param name="meta">Metadata for the asset.</param>
|
||||||
|
/// <returns>Result indicating success or failure.</returns>
|
||||||
|
public abstract Task<Result> ImportAsync(string assetPath, AssetMeta meta);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Export in-memory asset data to disk.
|
||||||
|
/// Override this method to support creating assets from code.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Type of asset data to export.</typeparam>
|
||||||
|
/// <param name="assetPath">Full path where the asset should be saved.</param>
|
||||||
|
/// <param name="assetData">In-memory asset data to serialize.</param>
|
||||||
|
/// <param name="meta">Metadata for the asset.</param>
|
||||||
|
/// <returns>Result indicating success or failure.</returns>
|
||||||
|
public virtual Task<Result> ExportAsync<T>(string assetPath, T assetData, AssetMeta meta) where T : class
|
||||||
|
{
|
||||||
|
return Task.FromResult(Result.Failure("This importer does not support exporting assets."));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the settings for this importer from the metadata.
|
||||||
|
/// Creates default settings if none exist.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="meta">Asset metadata.</param>
|
||||||
|
/// <returns>The importer settings.</returns>
|
||||||
|
protected TSettings GetSettings(AssetMeta meta)
|
||||||
|
{
|
||||||
|
var typeName = GetType().Name;
|
||||||
|
var settings = meta.GetImporterSettings<TSettings>(typeName);
|
||||||
|
|
||||||
|
if (settings != null)
|
||||||
|
{
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultSettings = new TSettings();
|
||||||
|
meta.SetImporterSettings(typeName, defaultSettings);
|
||||||
|
return defaultSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validate dependencies referenced by this asset.
|
||||||
|
/// Dependencies are extracted from asset content during import and stored in the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dependencies">List of dependency GUIDs extracted from the asset.</param>
|
||||||
|
/// <returns>Result indicating if all dependencies are valid.</returns>
|
||||||
|
protected virtual ValueTask<Result> ValidateDependenciesAsync(List<Guid> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace Ghost.Editor.Core.AssetHandle;
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
|
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
|
||||||
public class AssetImporterAttribute : Attribute
|
internal class AssetImporterAttribute : Attribute
|
||||||
{
|
{
|
||||||
public string[] SupportedExtensions
|
public string[] SupportedExtensions
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,16 +1,85 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.AssetHandle;
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
internal class AssetMeta
|
internal class AssetMeta
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier for the asset.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("Guid")]
|
||||||
public Guid Guid
|
public Guid Guid
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
set;
|
set;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ImporterSettings? Settings
|
/// <summary>
|
||||||
|
/// Version of the asset pipeline (not the asset itself).
|
||||||
|
/// Used for migration when the asset pipeline is redesigned.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("Version")]
|
||||||
|
public int Version
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
set;
|
set;
|
||||||
|
} = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tags for categorizing and searching assets.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("Tags")]
|
||||||
|
public List<string> Tags
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
} = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("ImporterSettings")]
|
||||||
|
public Dictionary<string, JsonElement> ImporterSettings
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
} = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get importer settings of a specific type.
|
||||||
|
/// </summary>
|
||||||
|
public T? GetImporterSettings<T>(string importerName) where T : ImporterSettings
|
||||||
|
{
|
||||||
|
if (ImporterSettings.TryGetValue(importerName, out var element))
|
||||||
|
{
|
||||||
|
return element.Deserialize<T>();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set importer settings.
|
||||||
|
/// </summary>
|
||||||
|
public void SetImporterSettings<T>(string importerName, T settings) where T : ImporterSettings
|
||||||
|
{
|
||||||
|
var element = JsonSerializer.SerializeToElement(settings);
|
||||||
|
ImporterSettings[importerName] = element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set importer settings (non-generic overload).
|
||||||
|
/// </summary>
|
||||||
|
internal void SetImporterSettings(string importerName, ImporterSettings settings)
|
||||||
|
{
|
||||||
|
var element = JsonSerializer.SerializeToElement(settings, settings.GetType());
|
||||||
|
ImporterSettings[importerName] = element;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
namespace Ghost.Editor.Core.AssetHandle;
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
public abstract class ImporterSettings
|
internal abstract class ImporterSettings
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
70
Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs
Normal file
70
Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandle.Importers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Example importer settings for text assets.
|
||||||
|
/// </summary>
|
||||||
|
internal class TextImporterSettings : ImporterSettings
|
||||||
|
{
|
||||||
|
public string Encoding
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
} = "UTF-8";
|
||||||
|
|
||||||
|
public bool TrimWhitespace
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
} = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Example importer for text files (.txt, .md).
|
||||||
|
/// This is a simple test importer to demonstrate the asset import system.
|
||||||
|
/// </summary>
|
||||||
|
[AssetImporter(".txt", ".md")]
|
||||||
|
internal class TextImporter : AssetImporter<TextImporterSettings>
|
||||||
|
{
|
||||||
|
public override async Task<Result> 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<Guid>();
|
||||||
|
|
||||||
|
// 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
279
Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs
Normal file
279
Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.AssetHandle.Importers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Importer settings for texture assets.
|
||||||
|
/// </summary>
|
||||||
|
internal class TextureImporterSettings : ImporterSettings
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to generate mipmaps for the texture.
|
||||||
|
/// </summary>
|
||||||
|
public bool GenerateMipmaps
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
} = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the texture uses sRGB color space.
|
||||||
|
/// </summary>
|
||||||
|
public bool SRGB
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
} = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum texture size. Images larger than this will be downscaled.
|
||||||
|
/// </summary>
|
||||||
|
public uint MaxSize
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
} = 2048;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Texture compression format.
|
||||||
|
/// Options: "None", "BC1", "BC3", "BC7"
|
||||||
|
/// </summary>
|
||||||
|
public string CompressionFormat
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
} = "None";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Texture filter mode.
|
||||||
|
/// Options: "Point", "Bilinear", "Trilinear"
|
||||||
|
/// </summary>
|
||||||
|
public string FilterMode
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
} = "Bilinear";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Texture wrap mode.
|
||||||
|
/// Options: "Repeat", "Clamp", "Mirror"
|
||||||
|
/// </summary>
|
||||||
|
public string WrapMode
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
} = "Repeat";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Importer for texture files (.png, .jpg, .jpeg, .dds, .tga, .bmp).
|
||||||
|
/// Processes image files and converts them into engine-ready texture assets.
|
||||||
|
/// </summary>
|
||||||
|
[AssetImporter(".png", ".jpg", ".jpeg", ".dds", ".tga", ".bmp")]
|
||||||
|
internal class TextureImporter : AssetImporter<TextureImporterSettings>
|
||||||
|
{
|
||||||
|
public override async Task<Result> 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<Guid>();
|
||||||
|
|
||||||
|
// 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get image dimensions from file.
|
||||||
|
/// Simplified implementation - in production, use an image library.
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read DDS file header to get dimensions.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Export a texture asset from memory to disk.
|
||||||
|
/// </summary>
|
||||||
|
public override async Task<Result> ExportAsync<T>(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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculate number of mipmap levels for a given texture size.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
250
Ghost.Editor.Core/AssetHandle/README.md
Normal file
250
Ghost.Editor.Core/AssetHandle/README.md
Normal file
@@ -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<T>(Guid guid)` - Load asset by GUID (TODO: needs asset loader)
|
||||||
|
- ✅ `GetAssetTagsAsync(Guid guid)` - Get asset tags
|
||||||
|
- ✅ `SetAssetTagsAsync(Guid guid, List<string> 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<string> { "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<TSettings>`
|
||||||
|
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<MyImporterSettings>
|
||||||
|
{
|
||||||
|
public override async Task<Result> 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<T>` 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<T>`)
|
||||||
|
- 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
|
||||||
75
Ghost.Editor.Core/AssetHandle/TextureAsset.cs
Normal file
75
Ghost.Editor.Core/AssetHandle/TextureAsset.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
namespace Ghost.Editor.Core.AssetHandle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a texture asset.
|
||||||
|
/// </summary>
|
||||||
|
public class TextureAsset : Asset
|
||||||
|
{
|
||||||
|
public override string Name
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Width of the texture in pixels.
|
||||||
|
/// </summary>
|
||||||
|
public uint Width
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Height of the texture in pixels.
|
||||||
|
/// </summary>
|
||||||
|
public uint Height
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of mipmap levels.
|
||||||
|
/// </summary>
|
||||||
|
public uint MipLevels
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Texture format (e.g., "RGBA8", "BC1", "BC7").
|
||||||
|
/// </summary>
|
||||||
|
public string Format
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the texture uses sRGB color space.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSRGB
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relative path to the source image file.
|
||||||
|
/// </summary>
|
||||||
|
public string SourcePath
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TextureAsset(Guid id, string name) : base(id)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Format = "RGBA8";
|
||||||
|
IsSRGB = true;
|
||||||
|
SourcePath = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
|
using Ghost.Data.Models;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Utilities;
|
namespace Ghost.Editor.Core.Utilities;
|
||||||
|
|
||||||
internal static class FileExtensions
|
internal static class FileExtensions
|
||||||
{
|
{
|
||||||
public const string META_FILE_EXTENSION = ".gmeta";
|
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 TEMPLATE_FILE_EXTENSION = ".gtmpl";
|
||||||
public const string SCENE_FILE_EXTENSION = ".gscene";
|
public const string SCENE_FILE_EXTENSION = ".gscene";
|
||||||
public const string ASSET_FILE_EXTENSION = ".gasset";
|
public const string ASSET_FILE_EXTENSION = ".gasset";
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Ghost.Core\Ghost.Core.csproj" />
|
<ProjectReference Include="..\Ghost.Core\Ghost.Core.csproj" />
|
||||||
<ProjectReference Include="..\Ghost.Entities\Ghost.Entities.csproj" />
|
<ProjectReference Include="..\Ghost.Entities\Ghost.Entities.csproj" />
|
||||||
<ProjectReference Include="..\Ghost.Generator\Ghost.Generator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
<!--<ProjectReference Include="..\Ghost.Generator\Ghost.Generator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />-->
|
||||||
<ProjectReference Include="..\Ghost.Graphics\Ghost.Graphics.csproj" />
|
<ProjectReference Include="..\Ghost.Graphics\Ghost.Graphics.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
367
Ghost.UnitTest/AssetDatabaseIntegrationTest.cs
Normal file
367
Ghost.UnitTest/AssetDatabaseIntegrationTest.cs
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
using Ghost.Editor.Core.AssetHandle;
|
||||||
|
using Ghost.Data.Services;
|
||||||
|
|
||||||
|
namespace Ghost.UnitTest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Comprehensive integration tests for AssetDatabase.
|
||||||
|
/// Tests database operations, file system watchers, searching, importing, and race conditions.
|
||||||
|
/// </summary>
|
||||||
|
[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<Task>();
|
||||||
|
var fileNames = new List<string>();
|
||||||
|
|
||||||
|
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<string> { "Test", "Player" });
|
||||||
|
await AssetDatabase.SetAssetTagsAsync(guid2, new List<string> { "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");
|
||||||
|
}
|
||||||
|
}
|
||||||
94
Ghost.UnitTest/AssetMetaTest.cs
Normal file
94
Ghost.UnitTest/AssetMetaTest.cs
Normal file
@@ -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<string> { "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<AssetMeta>(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<AssetMeta>(json);
|
||||||
|
|
||||||
|
Assert.IsNotNull(deserialized);
|
||||||
|
Assert.Contains("TextImporter", deserialized.ImporterSettings.Keys);
|
||||||
|
|
||||||
|
// Test retrieving the settings
|
||||||
|
var retrievedSettings = deserialized.GetImporterSettings<TextImporterSettings>("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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
|
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Ghost.UnitTest;
|
|
||||||
|
|
||||||
[TestClass]
|
|
||||||
public sealed class Test1
|
|
||||||
{
|
|
||||||
[TestMethod]
|
|
||||||
public void TestMethod1()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user