forked from Misaki/GhostEngine
Add simple scene graph
This commit is contained in:
@@ -1,17 +1,19 @@
|
||||
using Ghost.Editor.Core.Progress;
|
||||
using Ghost.Editor.Core.Resources;
|
||||
using Ghost.Editor.Core.Serializer;
|
||||
using Ghost.Editor.Core.Utilities;
|
||||
using Ghost.Engine.Core;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Ghost.Editor.Core.SceneGraph;
|
||||
|
||||
public enum OpenWorldMode
|
||||
{
|
||||
Single,
|
||||
Additive,
|
||||
AdditiveWithoutLoading
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Editor-specific scene manager that uses JSON serialization for human-readable scene files.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This manager provides JSON-based serialization suitable for editor workflows.
|
||||
/// Runtime applications should use SceneManager with binary serialization.
|
||||
/// </remarks>
|
||||
public static class EditorSceneManager
|
||||
{
|
||||
// TODO: Use guid keys instead of string paths for better performance and uniqueness
|
||||
@@ -21,6 +23,10 @@ public static class EditorSceneManager
|
||||
public static event Action<SceneNode>? OnWorldLoaded;
|
||||
public static event Action<SceneNode>? OnWorldUnloaded;
|
||||
|
||||
/// <summary>
|
||||
/// Loads a scene from a JSON file.
|
||||
/// </summary>
|
||||
/// <param name="worldPath">The path to the JSON scene file.</param>
|
||||
public static async Task LoadSceneAsync(string worldPath)
|
||||
{
|
||||
if (s_loadedWorlds.ContainsKey(worldPath)
|
||||
@@ -33,21 +39,34 @@ public static class EditorSceneManager
|
||||
var progressService = EditorApplication.GetService<IProgressService>();
|
||||
progressService.ShowIndeterminateProgress("Loading world...");
|
||||
|
||||
// Unload existing scenes
|
||||
foreach (var world in s_loadedWorlds)
|
||||
{
|
||||
world.Value.Unload();
|
||||
OnWorldUnloaded?.Invoke(world.Value);
|
||||
}
|
||||
|
||||
s_loadedWorlds.Clear();
|
||||
|
||||
// TODO: Get or create World instance for editor
|
||||
// For now, keep compatibility with old SceneNode deserialization
|
||||
await using var readStream = new FileStream(worldPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var deserializedScene = await JsonSerializer.DeserializeAsync<SceneNode>(readStream, Engine.Resources.EngineResource.defaultSerializerOptions) ?? throw new Exception("Deserialization failed.");
|
||||
|
||||
s_loadedWorlds.Clear();
|
||||
|
||||
s_loadedWorlds[worldPath] = deserializedScene;
|
||||
await deserializedScene.LoadAsync();
|
||||
|
||||
progressService.HideProgress();
|
||||
OnWorldLoaded?.Invoke(deserializedScene);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a scene to a JSON file using the new serializer.
|
||||
/// </summary>
|
||||
/// <param name="scene">The scene to save.</param>
|
||||
/// <param name="filePath">The path to save the JSON scene file.</param>
|
||||
public static async Task SaveSceneAsync(Scene scene, string filePath)
|
||||
{
|
||||
await SceneSerializer.SaveSceneAsync(scene.World, scene.ID, filePath, scene.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
using Ghost.Editor.Core.AssetHandle;
|
||||
using Ghost.Editor.Core.Inspector;
|
||||
using Ghost.Editor.Core.Resources;
|
||||
using Ghost.Editor.Core.Serializer;
|
||||
using Ghost.Engine.Components;
|
||||
using Ghost.Engine.Core;
|
||||
using Ghost.Engine.IO;
|
||||
using Ghost.Entities;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Ghost.Editor.Core.SceneGraph;
|
||||
|
||||
[CustomSerializer(typeof(SceneNodeSerializer))]
|
||||
public partial class SceneNode : SceneGraphNode, IEquatable<SceneNode>
|
||||
{
|
||||
private Scene _scene;
|
||||
|
||||
115
Ghost.Editor.Core/Serializer/Converters/EntityJsonConverter.cs
Normal file
115
Ghost.Editor.Core/Serializer/Converters/EntityJsonConverter.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using Ghost.Entities;
|
||||
using Ghost.Engine.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ghost.Editor.Core.Serializer.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for Entity that handles automatic ID remapping during deserialization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// During serialization, writes the file-local entity ID.
|
||||
/// During deserialization, reads the file-local ID and translates it to the runtime Entity
|
||||
/// using the active SerializationContext.
|
||||
/// </remarks>
|
||||
public class EntityJsonConverter : JsonConverter<Entity>
|
||||
{
|
||||
public override Entity Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
{
|
||||
return Entity.Invalid;
|
||||
}
|
||||
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
throw new JsonException("Expected StartObject token for Entity.");
|
||||
}
|
||||
|
||||
int fileId = -1;
|
||||
int generation = -1;
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndObject)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (reader.TokenType == JsonTokenType.PropertyName)
|
||||
{
|
||||
var propertyName = reader.GetString();
|
||||
reader.Read();
|
||||
|
||||
switch (propertyName)
|
||||
{
|
||||
case "ID":
|
||||
case "id":
|
||||
fileId = reader.GetInt32();
|
||||
break;
|
||||
case "Generation":
|
||||
case "generation":
|
||||
generation = reader.GetInt32();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileId == Entity.INVALID_ID)
|
||||
{
|
||||
return Entity.Invalid;
|
||||
}
|
||||
|
||||
// If we have a serialization context, remap the file ID to runtime entity
|
||||
var context = SerializationContext.Current;
|
||||
if (context != null)
|
||||
{
|
||||
if (context.TryGetEntity(fileId, out var runtimeEntity))
|
||||
{
|
||||
return runtimeEntity;
|
||||
}
|
||||
|
||||
// If entity not found in map, return invalid
|
||||
return Entity.Invalid;
|
||||
}
|
||||
|
||||
// No context means we're not in a deserialization scope - should not happen
|
||||
throw new InvalidOperationException("Entity deserialization requires an active SerializationContext.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, Entity value, JsonSerializerOptions options)
|
||||
{
|
||||
if (!value.IsValid)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WriteStartObject();
|
||||
|
||||
// If we have a serialization context, write the file-local ID
|
||||
var context = SerializationContext.Current;
|
||||
if (context != null)
|
||||
{
|
||||
if (context.TryGetFileId(value, out var fileId))
|
||||
{
|
||||
writer.WriteNumber("ID", fileId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Entity not in context - register it now
|
||||
var newFileId = context.RegisterEntityForSerialization(value);
|
||||
writer.WriteNumber("ID", newFileId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No context - write the runtime ID (for debugging or non-scene serialization)
|
||||
writer.WriteNumber("ID", value.ID);
|
||||
}
|
||||
|
||||
writer.WriteNumber("Generation", value.Generation);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
using Ghost.Editor.Core.SceneGraph;
|
||||
using Ghost.Engine.IO;
|
||||
using Ghost.Engine.Utilities;
|
||||
using Ghost.Entities;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Ghost.Editor.Core.Serializer;
|
||||
|
||||
internal class SceneNodeSerializer : CustomSerializer<SceneNode>
|
||||
{
|
||||
private static class Property
|
||||
{
|
||||
public const string NAME = "Name";
|
||||
public const string ENTITIES = "Entities";
|
||||
public const string ID = "ID";
|
||||
public const string ENTITY_ID = "EntityID";
|
||||
public const string COMPONENTS = "Components";
|
||||
public const string DATA = "Data";
|
||||
public const string SYSTEMS = "Systems";
|
||||
}
|
||||
|
||||
public override bool CanConvert(Type typeToConvert)
|
||||
{
|
||||
return typeToConvert == typeof(SceneNode) || typeToConvert.IsSubclassOf(typeof(SceneNode));
|
||||
}
|
||||
|
||||
public unsafe override void SerializeJson(Utf8JsonWriter writer, SceneNode value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteObject(() =>
|
||||
{
|
||||
writer.WriteString(Property.NAME, value.Name);
|
||||
writer.WriteStartArray(Property.ENTITIES);
|
||||
|
||||
for (var i = 0; i < value.Scene.World.ComponentManager.ArchetypeCount; i++)
|
||||
{
|
||||
ref var archetype = ref value.Scene.World.ComponentManager.GetArchetypeReference(i);
|
||||
|
||||
for (var j = 0; j < archetype.ChunkCount; j++)
|
||||
{
|
||||
ref var chunk = ref archetype.GetChunkReference(j);
|
||||
for (var k = 0; k < chunk._count; k++)
|
||||
{
|
||||
foreach (var layout in archetype._layouts)
|
||||
{
|
||||
var type = ComponentRegistry.s_runtimeIDToType[layout.componentID];
|
||||
var size = ComponentRegistry.GetComponentInfo(layout.componentID).size;
|
||||
|
||||
if (type.AssemblyQualifiedName == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("Type", type.AssemblyQualifiedName);
|
||||
writer.WritePropertyName("Data");
|
||||
|
||||
var pComponentData = chunk.GetUnsafePtr() + layout.offset + (k * size);
|
||||
ComponentSerializerRegistry.SerializeJson(layout.componentID, writer, pComponentData, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WriteArray(Property.SYSTEMS, value.Scene.World.SystemManager.Systems, system =>
|
||||
{
|
||||
var name = system.GetType().AssemblyQualifiedName;
|
||||
if (name == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WriteStringValue(name);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public override SceneNode? DeserializeJson(ref Utf8JsonReader reader, JsonSerializerOptions options)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
|
||||
//var element = JsonDocument.ParseValue(ref reader).RootElement;
|
||||
//var name = element.GetProperty(Property.NAME).GetString() ?? "New World";
|
||||
|
||||
//var world = World.Create(EngineCore.JobScheduler);
|
||||
//var result = new WorldNode(world, name);
|
||||
|
||||
//foreach (var entityElement in element.GetProperty(Property.ENTITIES).EnumerateArray())
|
||||
//{
|
||||
// var entityName = entityElement.GetProperty(Property.NAME).GetString() ?? "New Entity";
|
||||
// var entityID = entityElement.GetProperty(Property.ID).GetInt32();
|
||||
// var entity = new Entity(entityID, 0);
|
||||
// var node = new EntityNode(result, entity, entityName);
|
||||
|
||||
// world.EntityManager.AddEntityInternal(entity);
|
||||
// result.EntityNodeLookup[entity] = node;
|
||||
//}
|
||||
|
||||
//foreach (var componentElement in element.GetProperty(Property.COMPONENTS).EnumerateObject())
|
||||
//{
|
||||
// var typeName = componentElement.Name;
|
||||
// var space = Type.GetType(typeName) ?? throw new Exception($"Type {typeName} not found.");
|
||||
|
||||
// foreach (var dataElement in componentElement.Value.EnumerateArray())
|
||||
// {
|
||||
// var entityID = dataElement.GetProperty(Property.ENTITY_ID).GetInt32();
|
||||
// var entity = new Entity(entityID, 0);
|
||||
|
||||
// var dataProperty = dataElement.GetProperty(Property.DATA);
|
||||
// var component = JsonSerializer.Deserialize(dataProperty.GetRawText(), space, options);
|
||||
// if (component is IComponent data)
|
||||
// {
|
||||
// world.EntityManager.AddComponent(entity, data, space);
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
//foreach (var systemElement in element.GetProperty(Property.SYSTEMS).EnumerateArray())
|
||||
//{
|
||||
// var typeString = systemElement.GetString();
|
||||
// if (string.IsNullOrEmpty(typeString))
|
||||
// {
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// var systemType = Type.GetType(typeString);
|
||||
// if (systemType == null)
|
||||
// {
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// world.SystemStorage.AddSystem(systemType);
|
||||
//}
|
||||
|
||||
//return result;
|
||||
}
|
||||
|
||||
public override void SerializeBinary(BinaryWriter writer, SceneNode value)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override SceneNode? DeserializeBinary(BinaryReader reader)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
258
Ghost.Editor.Core/Serializer/SceneSerializer.cs
Normal file
258
Ghost.Editor.Core/Serializer/SceneSerializer.cs
Normal file
@@ -0,0 +1,258 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Editor.Core.Serializer.Converters;
|
||||
using Ghost.Engine.Components;
|
||||
using Ghost.Engine.Core;
|
||||
using Ghost.Engine.IO;
|
||||
using Ghost.Entities;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ghost.Editor.Core.Serializer;
|
||||
|
||||
/// <summary>
|
||||
/// Handles JSON serialization and deserialization of scenes.
|
||||
/// </summary>
|
||||
public static class SceneSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_serializerOptions;
|
||||
|
||||
static SceneSerializer()
|
||||
{
|
||||
s_serializerOptions = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
IncludeFields = true,
|
||||
Converters =
|
||||
{
|
||||
new EntityJsonConverter(),
|
||||
new JsonStringEnumConverter()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the serialized data for a single entity.
|
||||
/// </summary>
|
||||
private class SerializedEntity
|
||||
{
|
||||
public int FileID { get; set; }
|
||||
public List<SerializedComponent> Components { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a serialized component with its type and data.
|
||||
/// </summary>
|
||||
private class SerializedComponent
|
||||
{
|
||||
public string TypeName { get; set; } = string.Empty;
|
||||
public JsonElement Data { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the complete scene file structure.
|
||||
/// </summary>
|
||||
private class SceneFile
|
||||
{
|
||||
public string Name { get; set; } = "Untitled Scene";
|
||||
public int Version { get; set; } = 1;
|
||||
public List<SerializedEntity> Entities { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a scene to a JSON file.
|
||||
/// </summary>
|
||||
/// <param name="world">The world containing the entities.</param>
|
||||
/// <param name="sceneID">The scene ID to save.</param>
|
||||
/// <param name="filePath">The path to save the scene file.</param>
|
||||
/// <param name="sceneName">Optional scene name.</param>
|
||||
public static async Task SaveSceneAsync(World world, short sceneID, string filePath, string? sceneName = null)
|
||||
{
|
||||
using var context = SerializationContext.Create();
|
||||
|
||||
var sceneFile = new SceneFile
|
||||
{
|
||||
Name = sceneName ?? Path.GetFileNameWithoutExtension(filePath),
|
||||
Entities = new List<SerializedEntity>()
|
||||
};
|
||||
|
||||
// Query all entities with the specified SceneID
|
||||
var queryID = new QueryBuilder()
|
||||
.WithAll<SceneID>()
|
||||
.Build(world);
|
||||
|
||||
var entities = new List<Entity>();
|
||||
|
||||
world.ComponentManager.GetEntityQueryReference(queryID).ForEach<SceneID>((Entity entity, ref SceneID sceneIDComponent) =>
|
||||
{
|
||||
if (sceneIDComponent.id == sceneID)
|
||||
{
|
||||
entities.Add(entity);
|
||||
}
|
||||
});
|
||||
|
||||
// Serialize each entity
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var fileId = context.RegisterEntityForSerialization(entity);
|
||||
var serializedEntity = new SerializedEntity
|
||||
{
|
||||
FileID = fileId,
|
||||
Components = new List<SerializedComponent>()
|
||||
};
|
||||
|
||||
// Get entity location to find archetype
|
||||
var locationResult = world.EntityManager.GetEntityLocation(entity);
|
||||
if (locationResult.Error != Error.None)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var location = locationResult.Value;
|
||||
ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.archetypeID);
|
||||
|
||||
// Serialize each component
|
||||
foreach (var layout in archetype._layouts)
|
||||
{
|
||||
var componentType = ComponentRegistry.s_runtimeIDToType[layout.componentID];
|
||||
|
||||
if (componentType == null || componentType.AssemblyQualifiedName == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get component data
|
||||
unsafe
|
||||
{
|
||||
var pComponentData = archetype.GetComponentData(location.chunkIndex, location.rowIndex, layout.componentID);
|
||||
if (pComponentData == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Serialize component to JSON
|
||||
// We need to box the unmanaged data to serialize it
|
||||
var boxedData = System.Runtime.InteropServices.Marshal.PtrToStructure((IntPtr)pComponentData, componentType);
|
||||
var componentJson = JsonSerializer.Serialize(boxedData, componentType, s_serializerOptions);
|
||||
var jsonElement = JsonDocument.Parse(componentJson).RootElement;
|
||||
|
||||
serializedEntity.Components.Add(new SerializedComponent
|
||||
{
|
||||
TypeName = componentType.AssemblyQualifiedName,
|
||||
Data = jsonElement
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sceneFile.Entities.Add(serializedEntity);
|
||||
}
|
||||
|
||||
// Write to file
|
||||
var json = JsonSerializer.Serialize(sceneFile, s_serializerOptions);
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a scene from a JSON file into the specified world.
|
||||
/// </summary>
|
||||
/// <param name="world">The world to load the scene into.</param>
|
||||
/// <param name="filePath">The path to the scene file.</param>
|
||||
/// <param name="newSceneID">The new scene ID to assign to loaded entities.</param>
|
||||
/// <returns>The number of entities loaded.</returns>
|
||||
public static async Task<int> LoadSceneAsync(World world, string filePath, short newSceneID)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Scene file not found: {filePath}");
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
var sceneFile = JsonSerializer.Deserialize<SceneFile>(json, s_serializerOptions);
|
||||
|
||||
if (sceneFile == null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to deserialize scene file.");
|
||||
}
|
||||
|
||||
using var context = SerializationContext.Create();
|
||||
|
||||
// Pass 1: Create all entities and build the ID mapping
|
||||
var fileIdToEntity = new Dictionary<int, Entity>();
|
||||
|
||||
foreach (var serializedEntity in sceneFile.Entities)
|
||||
{
|
||||
var entity = world.EntityManager.CreateEntity();
|
||||
fileIdToEntity[serializedEntity.FileID] = entity;
|
||||
context.RegisterEntity(serializedEntity.FileID, entity);
|
||||
|
||||
// Add SceneID component
|
||||
world.EntityManager.AddComponent(entity, new SceneID { id = newSceneID });
|
||||
}
|
||||
|
||||
// Pass 2: Deserialize components (with automatic entity reference remapping)
|
||||
foreach (var serializedEntity in sceneFile.Entities)
|
||||
{
|
||||
if (!fileIdToEntity.TryGetValue(serializedEntity.FileID, out var entity))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var serializedComponent in serializedEntity.Components)
|
||||
{
|
||||
var componentType = Type.GetType(serializedComponent.TypeName);
|
||||
if (componentType == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip SceneID as we already added it
|
||||
if (componentType == typeof(SceneID))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Deserialize the component data
|
||||
var componentData = JsonSerializer.Deserialize(serializedComponent.Data.GetRawText(), componentType, s_serializerOptions);
|
||||
|
||||
if (componentData == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add component to entity
|
||||
unsafe
|
||||
{
|
||||
var componentID = ComponentRegistry.GetComponentID(componentType);
|
||||
if (componentID.IsInvalid)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// For unmanaged components, we can use pointer magic
|
||||
if (componentType.IsValueType)
|
||||
{
|
||||
var pinnedData = System.Runtime.InteropServices.GCHandle.Alloc(componentData, System.Runtime.InteropServices.GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
var ptr = pinnedData.AddrOfPinnedObject().ToPointer();
|
||||
world.EntityManager.AddComponent(entity, componentID, ptr);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pinnedData.Free();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log error but continue loading other components
|
||||
Console.WriteLine($"Failed to deserialize component {serializedComponent.TypeName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fileIdToEntity.Count;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user