From 1647aa2f1e776dc967b3d8ff8fb4440ae4885173 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 14 Nov 2025 01:03:59 +0800 Subject: [PATCH] :recycle: Proper folder system to index --- DysonNetwork.Drive/AppDatabase.cs | 1 + .../Index/FileIndexController.cs | 505 +++++++++++- DysonNetwork.Drive/Index/FileIndexService.cs | 329 ++++++-- DysonNetwork.Drive/Index/FolderService.cs | 313 ++++++++ DysonNetwork.Drive/Index/README.md | 203 ++++- .../20251113165508_AddFileFolders.Designer.cs | 722 ++++++++++++++++++ .../20251113165508_AddFileFolders.cs | 120 +++ .../Migrations/AppDatabaseModelSnapshot.cs | 98 ++- .../Startup/ServiceCollectionExtensions.cs | 1 + DysonNetwork.Shared/Models/CloudFileIndex.cs | 66 +- DysonNetwork.Shared/Models/CloudFolder.cs | 103 +++ 11 files changed, 2334 insertions(+), 127 deletions(-) create mode 100644 DysonNetwork.Drive/Index/FolderService.cs create mode 100644 DysonNetwork.Drive/Migrations/20251113165508_AddFileFolders.Designer.cs create mode 100644 DysonNetwork.Drive/Migrations/20251113165508_AddFileFolders.cs create mode 100644 DysonNetwork.Shared/Models/CloudFolder.cs diff --git a/DysonNetwork.Drive/AppDatabase.cs b/DysonNetwork.Drive/AppDatabase.cs index da5176d..93c19f7 100644 --- a/DysonNetwork.Drive/AppDatabase.cs +++ b/DysonNetwork.Drive/AppDatabase.cs @@ -26,6 +26,7 @@ public class AppDatabase( public DbSet Files { get; set; } = null!; public DbSet FileReferences { get; set; } = null!; public DbSet FileIndexes { get; set; } + public DbSet Folders { get; set; } = null!; public DbSet Tasks { get; set; } = null!; public DbSet UploadTasks { get; set; } = null!; // Backward compatibility diff --git a/DysonNetwork.Drive/Index/FileIndexController.cs b/DysonNetwork.Drive/Index/FileIndexController.cs index 74febab..0e6c5ee 100644 --- a/DysonNetwork.Drive/Index/FileIndexController.cs +++ b/DysonNetwork.Drive/Index/FileIndexController.cs @@ -1,6 +1,4 @@ using System.ComponentModel.DataAnnotations; -using DysonNetwork.Drive.Storage; -using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Proto; @@ -15,6 +13,7 @@ namespace DysonNetwork.Drive.Index; [Authorize] public class FileIndexController( FileIndexService fileIndexService, + FolderService folderService, AppDatabase db, ILogger logger ) : ControllerBase @@ -35,13 +34,10 @@ public class FileIndexController( try { var fileIndexes = await fileIndexService.GetByPathAsync(accountId, path); - - // Get all file indexes for this account to extract child folders - var allFileIndexes = await fileIndexService.GetByAccountIdAsync(accountId); - - // Extract unique child folder paths - var childFolders = ExtractChildFolders(allFileIndexes, path); - + + // Get child folders using the folder system + var childFolders = await GetChildFoldersAsync(accountId, path); + return Ok(new { Path = path, @@ -63,38 +59,108 @@ public class FileIndexController( } /// - /// Extracts unique child folder paths from all file indexes for a given parent path + /// Gets child folders for a given parent path using the folder system /// - /// All file indexes for the account + /// The account ID /// The parent path to find children for - /// List of unique child folder names - private List ExtractChildFolders(List allFileIndexes, string parentPath) + /// List of child folder objects + private async Task> GetChildFoldersAsync(Guid accountId, string parentPath) { var normalizedParentPath = FileIndexService.NormalizePath(parentPath); - var childFolders = new HashSet(); - foreach (var index in allFileIndexes) + // Try to find a folder that corresponds to this path + var parentFolder = await FindFolderByPathAsync(accountId, normalizedParentPath); + + if (parentFolder != null) { - var normalizedIndexPath = FileIndexService.NormalizePath(index.Path); - - // Check if this path is a direct child of the parent path - if (normalizedIndexPath.StartsWith(normalizedParentPath) && - normalizedIndexPath != normalizedParentPath) + // Use folder-based approach + return await folderService.GetChildFoldersAsync(parentFolder.Id, accountId); + } + else + { + // Fall back to path-based approach - find folders that start with this path + var allFolders = await folderService.GetByAccountIdAsync(accountId); + var childFolders = new List(); + + foreach (var folder in allFolders) { - // Remove the parent path prefix to get the relative path - var relativePath = normalizedIndexPath.Substring(normalizedParentPath.Length); - - // Extract the first folder name (direct child) - var firstSlashIndex = relativePath.IndexOf('/'); - if (firstSlashIndex > 0) + // For path-based folders, we need to check if they belong under this path + // This is a simplified approach - in a full implementation, folders would have path information + if (folder.ParentFolderId == null && normalizedParentPath == "/") { - var folderName = relativePath.Substring(0, firstSlashIndex); - childFolders.Add(folderName); + // Root level folders + childFolders.Add(folder); } + // For nested folders, we'd need path information in the folder model } + + return childFolders.OrderBy(f => f.Name).ToList(); + } + } + + /// + /// Attempts to find a folder by its path + /// + /// The account ID + /// The path to search for + /// The folder if found, null otherwise + private async Task FindFolderByPathAsync(Guid accountId, string path) + { + // This is a simplified implementation + // In a full implementation, folders would have path information stored + var allFolders = await folderService.GetByAccountIdAsync(accountId); + + // For now, just return null to use path-based approach + // TODO: Implement proper path-to-folder mapping + return null; + } + + /// + /// Gets or creates a folder hierarchy based on a file path + /// + /// The file path (e.g., "/folder/sub/file.txt") + /// The account ID + /// The folder where the file should be placed + private async Task GetOrCreateFolderByPathAsync(string filePath, Guid accountId) + { + // Extract folder path from file path (remove filename) + var lastSlashIndex = filePath.LastIndexOf('/'); + var folderPath = lastSlashIndex == 0 ? "/" : filePath[..(lastSlashIndex + 1)]; + + // Ensure root folder exists + var rootFolder = await folderService.EnsureRootFolderAsync(accountId); + + // If it's the root folder, return it + if (folderPath == "/") + return rootFolder; + + // Split the folder path into segments + var pathSegments = folderPath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + + var currentParent = rootFolder; + var currentPath = "/"; + + // Create folder hierarchy + foreach (var segment in pathSegments) + { + currentPath += segment + "/"; + + // Check if folder already exists + var existingFolder = await db.Folders + .FirstOrDefaultAsync(f => f.AccountId == accountId && f.Path == currentPath); + + if (existingFolder != null) + { + currentParent = existingFolder; + continue; + } + + // Create new folder + var newFolder = await folderService.CreateAsync(segment, accountId, currentParent.Id); + currentParent = newFolder; } - return childFolders.OrderBy(f => f).ToList(); + return currentParent; } /// @@ -116,7 +182,7 @@ public class FileIndexController( return Ok(new { Files = fileIndexes, - TotalCount = fileIndexes.Count() + TotalCount = fileIndexes.Count }); } catch (Exception ex) @@ -359,9 +425,11 @@ public class FileIndexController( if (file.AccountId != accountId) return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 }; - // Check if index already exists for this file and path + // Check if index already exists for this file and path - since Path is now optional, we need to check by folder + // For now, we'll check if any index exists for this file in the same folder that would result from the path + var targetFolder = await GetOrCreateFolderByPathAsync(FileIndexService.NormalizePath(request.Path), accountId); var existingIndex = await db.FileIndexes - .FirstOrDefaultAsync(fi => fi.FileId == request.FileId && fi.Path == request.Path && fi.AccountId == accountId); + .FirstOrDefaultAsync(fi => fi.FileId == request.FileId && fi.FolderId == targetFolder.Id && fi.AccountId == accountId); if (existingIndex != null) return new ObjectResult(ApiError.Validation(new Dictionary @@ -375,7 +443,7 @@ public class FileIndexController( { IndexId = fileIndex.Id, fileIndex.FileId, - fileIndex.Path, + Path = fileIndex.Path, Message = "File index created successfully" }); } @@ -392,6 +460,56 @@ public class FileIndexController( } } + /// + /// Gets unindexed files for the current user (files that exist but don't have file indexes) + /// + /// Pagination offset + /// Number of files to take + /// List of unindexed files + [HttpGet("unindexed")] + public async Task GetUnindexedFiles([FromQuery] int offset = 0, [FromQuery] int take = 20) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; + + var accountId = Guid.Parse(currentUser.Id); + + try + { + // Get files that belong to the user but don't have any file indexes + var unindexedFiles = await db.Files + .Where(f => f.AccountId == accountId) + .Where(f => !db.FileIndexes.Any(fi => fi.FileId == f.Id && fi.AccountId == accountId)) + .OrderByDescending(f => f.CreatedAt) + .Skip(offset) + .Take(take) + .ToListAsync(); + + var totalCount = await db.Files + .Where(f => f.AccountId == accountId) + .Where(f => !db.FileIndexes.Any(fi => fi.FileId == f.Id && fi.AccountId == accountId)) + .CountAsync(); + + return Ok(new + { + Files = unindexedFiles, + TotalCount = totalCount, + Offset = offset, + Take = take + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get unindexed files for account {AccountId}", accountId); + return new ObjectResult(new ApiError + { + Code = "GET_UNINDEXED_FAILED", + Message = "Failed to get unindexed files", + Status = 500 + }) { StatusCode = 500 }; + } + } + /// /// Searches for files by name or metadata /// @@ -410,14 +528,29 @@ public class FileIndexController( { // Build the query with all conditions at once var searchTerm = query.ToLower(); - var fileIndexes = await db.FileIndexes + var baseQuery = db.FileIndexes .Where(fi => fi.AccountId == accountId) - .Include(fi => fi.File) - .Where(fi => - (string.IsNullOrEmpty(path) || fi.Path == FileIndexService.NormalizePath(path)) && - (fi.File.Name.ToLower().Contains(searchTerm) || - (fi.File.Description != null && fi.File.Description.ToLower().Contains(searchTerm)) || - (fi.File.MimeType != null && fi.File.MimeType.ToLower().Contains(searchTerm)))) + .Include(fi => fi.File); + + IQueryable queryable; + + // If a path is specified, find the folder and filter by folder ID + if (!string.IsNullOrEmpty(path)) + { + var normalizedPath = FileIndexService.NormalizePath(path); + var targetFolder = await GetOrCreateFolderByPathAsync(normalizedPath, accountId); + queryable = baseQuery.Where(fi => fi.FolderId == targetFolder.Id); + } + else + { + queryable = baseQuery; + } + + var fileIndexes = await queryable + .Where(fi => + fi.File.Name.ToLower().Contains(searchTerm) || + (fi.File.Description != null && fi.File.Description.ToLower().Contains(searchTerm)) || + (fi.File.MimeType != null && fi.File.MimeType.ToLower().Contains(searchTerm))) .ToListAsync(); return Ok(new @@ -439,6 +572,249 @@ public class FileIndexController( }) { StatusCode = 500 }; } } + + /// + /// Creates a new folder + /// + /// The folder creation request + /// The created folder + [HttpPost("folders")] + public async Task CreateFolder([FromBody] CreateFolderRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; + + var accountId = Guid.Parse(currentUser.Id); + + try + { + var folder = await folderService.CreateAsync(request.Name, accountId, request.ParentFolderId); + return Ok(folder); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (InvalidOperationException ex) + { + return Conflict(ex.Message); + } + } + + /// + /// Gets a folder by ID with its contents + /// + /// The folder ID + /// The folder with child folders and files + [HttpGet("folders/{folderId:guid}")] + public async Task GetFolderById(Guid folderId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; + + var accountId = Guid.Parse(currentUser.Id); + + var folder = await folderService.GetByIdAsync(folderId, accountId); + + if (folder == null) + { + return NotFound($"Folder with ID '{folderId}' not found"); + } + + return Ok(folder); + } + + /// + /// Gets all folders for the current account + /// + /// List of folders + [HttpGet("folders")] + public async Task GetAllFolders() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; + + var accountId = Guid.Parse(currentUser.Id); + + var folders = await folderService.GetByAccountIdAsync(accountId); + return Ok(folders); + } + + /// + /// Gets child folders of a parent folder + /// + /// The parent folder ID + /// List of child folders + [HttpGet("folders/children/{parentFolderId:guid}")] + public async Task GetChildFolders(Guid parentFolderId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; + + var accountId = Guid.Parse(currentUser.Id); + + var folders = await folderService.GetChildFoldersAsync(parentFolderId, accountId); + return Ok(folders); + } + + /// + /// Updates a folder's name + /// + /// The folder ID + /// The update request + /// The updated folder + [HttpPut("folders/{folderId:guid}")] + public async Task UpdateFolder(Guid folderId, [FromBody] UpdateFolderRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; + + var accountId = Guid.Parse(currentUser.Id); + + try + { + var folder = await folderService.UpdateAsync(folderId, request.Name, accountId); + + if (folder == null) + { + return NotFound($"Folder with ID '{folderId}' not found"); + } + + return Ok(folder); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (InvalidOperationException ex) + { + return Conflict(ex.Message); + } + } + + /// + /// Deletes a folder and all its contents + /// + /// The folder ID + /// Success status + [HttpDelete("folders/{folderId:guid}")] + public async Task DeleteFolder(Guid folderId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; + + var accountId = Guid.Parse(currentUser.Id); + + var deleted = await folderService.DeleteAsync(folderId, accountId); + + if (!deleted) + { + return NotFound($"Folder with ID '{folderId}' not found"); + } + + return NoContent(); + } + + /// + /// Moves a folder to a new parent folder + /// + /// The folder ID + /// The move request + /// The moved folder + [HttpPost("folders/{folderId:guid}/move")] + public async Task MoveFolder(Guid folderId, [FromBody] MoveFolderRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; + + var accountId = Guid.Parse(currentUser.Id); + + try + { + var folder = await folderService.MoveAsync(folderId, request.NewParentFolderId, accountId); + + if (folder == null) + { + return NotFound($"Folder with ID '{folderId}' not found"); + } + + return Ok(folder); + } + catch (InvalidOperationException ex) + { + return Conflict(ex.Message); + } + } + + /// + /// Searches for folders by name + /// + /// The search term + /// List of matching folders + [HttpGet("folders/search")] + public async Task SearchFolders([FromQuery] string searchTerm) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; + + var accountId = Guid.Parse(currentUser.Id); + + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return BadRequest("Search term cannot be empty"); + } + + var folders = await folderService.SearchAsync(accountId, searchTerm); + return Ok(folders); + } + + /// + /// Gets files in a specific folder + /// + /// The folder ID + /// List of files in the folder + [HttpGet("folders/{folderId:guid}/files")] + public async Task GetFilesInFolder(Guid folderId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; + + var accountId = Guid.Parse(currentUser.Id); + + var files = await fileIndexService.GetByFolderAsync(accountId, folderId); + return Ok(files); + } + + /// + /// Moves a file to a different folder + /// + /// The file index ID + /// The move request + /// The updated file index + [HttpPost("files/{fileIndexId:guid}/move-to-folder")] + public async Task MoveFileToFolder(Guid fileIndexId, [FromBody] MoveFileToFolderRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; + + var accountId = Guid.Parse(currentUser.Id); + + try + { + var fileIndex = await fileIndexService.MoveAsync(fileIndexId, request.NewFolderId, accountId); + + if (fileIndex == null) + { + return NotFound($"File index with ID '{fileIndexId}' not found"); + } + + return Ok(fileIndex); + } + catch (InvalidOperationException ex) + { + return Conflict(ex.Message); + } + } } public class MoveFileRequest @@ -451,3 +827,52 @@ public class CreateFileIndexRequest [MaxLength(32)] public string FileId { get; set; } = null!; public string Path { get; set; } = null!; } + +/// +/// Request model for creating a folder +/// +public class CreateFolderRequest +{ + /// + /// The name of the folder + /// + public string Name { get; set; } = null!; + + /// + /// Optional parent folder ID (null for root folder) + /// + public Guid? ParentFolderId { get; set; } +} + +/// +/// Request model for updating a folder +/// +public class UpdateFolderRequest +{ + /// + /// The new name for the folder + /// + public string Name { get; set; } = null!; +} + +/// +/// Request model for moving a folder +/// +public class MoveFolderRequest +{ + /// + /// The new parent folder ID (null for root) + /// + public Guid? NewParentFolderId { get; set; } +} + +/// +/// Request model for moving a file to a folder +/// +public class MoveFileToFolderRequest +{ + /// + /// The new folder ID + /// + public Guid NewFolderId { get; set; } +} diff --git a/DysonNetwork.Drive/Index/FileIndexService.cs b/DysonNetwork.Drive/Index/FileIndexService.cs index 174d82d..97ca17d 100644 --- a/DysonNetwork.Drive/Index/FileIndexService.cs +++ b/DysonNetwork.Drive/Index/FileIndexService.cs @@ -3,37 +3,111 @@ using Microsoft.EntityFrameworkCore; namespace DysonNetwork.Drive.Index; -public class FileIndexService(AppDatabase db) +public class FileIndexService(AppDatabase db, FolderService folderService) { /// - /// Creates a new file index entry + /// Normalizes a path to ensure consistent formatting /// - /// The parent folder path with a trailing slash + /// The path to normalize + /// The normalized path + public static string NormalizePath(string path) + { + if (string.IsNullOrEmpty(path)) + return "/"; + + // Ensure path starts with / + if (!path.StartsWith('/')) + path = "/" + path; + + // Remove trailing slash unless it's the root + if (path.Length > 1 && path.EndsWith('/')) + path = path.TrimEnd('/'); + + // Normalize double slashes + while (path.Contains("//")) + path = path.Replace("//", "/"); + + return path; + } + + /// + /// Gets or creates a folder hierarchy based on a file path + /// + /// The file path (e.g., "/folder/sub/file.txt") + /// The account ID + /// The folder where the file should be placed + private async Task GetOrCreateFolderByPathAsync(string filePath, Guid accountId) + { + // Extract folder path from file path (remove filename) + var lastSlashIndex = filePath.LastIndexOf('/'); + var folderPath = lastSlashIndex == 0 ? "/" : filePath[..(lastSlashIndex + 1)]; + + // Ensure root folder exists + var rootFolder = await folderService.EnsureRootFolderAsync(accountId); + + // If it's the root folder, return it + if (folderPath == "/") + return rootFolder; + + // Split the folder path into segments + var pathSegments = folderPath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + + var currentParent = rootFolder; + var currentPath = "/"; + + // Create folder hierarchy + foreach (var segment in pathSegments) + { + currentPath += segment + "/"; + + // Check if folder already exists + var existingFolder = await db.Folders + .FirstOrDefaultAsync(f => f.AccountId == accountId && f.Path == currentPath); + + if (existingFolder != null) + { + currentParent = existingFolder; + continue; + } + + // Create new folder + var newFolder = await folderService.CreateAsync(segment, accountId, currentParent.Id); + currentParent = newFolder; + } + + return currentParent; + } + /// + /// Creates a new file index entry at a specific path (creates folder hierarchy if needed) + /// + /// The path where the file should be indexed /// The file ID /// The account ID /// The created file index public async Task CreateAsync(string path, string fileId, Guid accountId) { - // Ensure a path has a trailing slash and is query-safe var normalizedPath = NormalizePath(path); - // Check if a file with the same name already exists in the same path for this account + // Get the file to extract the file name + var file = await db.Files + .FirstOrDefaultAsync(f => f.Id == fileId) ?? throw new InvalidOperationException($"File with ID '{fileId}' not found"); + + // Get or create the folder hierarchy based on the path + var folder = await GetOrCreateFolderByPathAsync(normalizedPath, accountId); + + // Check if a file with the same name already exists in the same folder for this account var existingFileIndex = await db.FileIndexes - .FirstOrDefaultAsync(fi => fi.AccountId == accountId && fi.Path == normalizedPath && fi.FileId == fileId); + .FirstOrDefaultAsync(fi => fi.AccountId == accountId && + fi.FolderId == folder.Id && + fi.File.Name == file.Name); if (existingFileIndex != null) { throw new InvalidOperationException( - $"A file with ID '{fileId}' already exists in path '{normalizedPath}' for account '{accountId}'"); + $"A file with name '{file.Name}' already exists in folder '{folder.Name}' for account '{accountId}'"); } - var fileIndex = new SnCloudFileIndex - { - Path = normalizedPath, - FileId = fileId, - AccountId = accountId - }; - + var fileIndex = SnCloudFileIndex.Create(folder, file, accountId); db.FileIndexes.Add(fileIndex); await db.SaveChangesAsync(); @@ -41,26 +115,94 @@ public class FileIndexService(AppDatabase db) } /// - /// Updates an existing file index entry by removing the old one and creating a new one + /// Creates a new file index entry in a specific folder /// - /// The file index ID - /// The new parent folder path with trailing slash - /// The updated file index - public async Task UpdateAsync(Guid id, string newPath) + /// The folder ID where the file should be placed + /// The file ID + /// The account ID + /// The created file index + public async Task CreateInFolderAsync(Guid folderId, string fileId, Guid accountId) { - var fileIndex = await db.FileIndexes.FindAsync(id); + // Verify the folder exists and belongs to the account + var folder = await db.Folders + .FirstOrDefaultAsync(f => f.Id == folderId && f.AccountId == accountId); + + if (folder == null) + { + throw new InvalidOperationException($"Folder with ID '{folderId}' not found or access denied"); + } + + // Get the file to extract the file name + var file = await db.Files + .FirstOrDefaultAsync(f => f.Id == fileId); + + if (file == null) + { + throw new InvalidOperationException($"File with ID '{fileId}' not found"); + } + + // Check if a file with the same name already exists in the same folder for this account + var existingFileIndex = await db.FileIndexes + .FirstOrDefaultAsync(fi => fi.AccountId == accountId && + fi.FolderId == folderId && + fi.File.Name == file.Name); + + if (existingFileIndex != null) + { + throw new InvalidOperationException( + $"A file with name '{file.Name}' already exists in folder '{folder.Name}' for account '{accountId}'"); + } + + var fileIndex = SnCloudFileIndex.Create(folder, file, accountId); + db.FileIndexes.Add(fileIndex); + await db.SaveChangesAsync(); + + return fileIndex; + } + + /// + /// Moves a file to a different folder + /// + /// The file index ID + /// The new folder ID + /// The account ID + /// The updated file index + public async Task MoveAsync(Guid fileIndexId, Guid newFolderId, Guid accountId) + { + var fileIndex = await db.FileIndexes + .Include(fi => fi.File) + .FirstOrDefaultAsync(fi => fi.Id == fileIndexId && fi.AccountId == accountId); + if (fileIndex == null) return null; + // Verify the new folder exists and belongs to the account + var newFolder = await db.Folders + .FirstOrDefaultAsync(f => f.Id == newFolderId && f.AccountId == accountId); + + if (newFolder == null) + { + throw new InvalidOperationException($"Target folder with ID '{newFolderId}' not found or access denied"); + } + + // Check if a file with the same name already exists in the target folder + var existingFileIndex = await db.FileIndexes + .FirstOrDefaultAsync(fi => fi.AccountId == accountId && + fi.FolderId == newFolderId && + fi.File.Name == fileIndex.File.Name && + fi.Id != fileIndexId); + + if (existingFileIndex != null) + { + throw new InvalidOperationException( + $"A file with name '{fileIndex.File.Name}' already exists in folder '{newFolder.Name}'"); + } + // Since properties are init-only, we need to remove the old index and create a new one db.FileIndexes.Remove(fileIndex); - var newFileIndex = new SnCloudFileIndex - { - Path = NormalizePath(newPath), - FileId = fileIndex.FileId, - AccountId = fileIndex.AccountId - }; + var newFileIndex = SnCloudFileIndex.Create(newFolder, fileIndex.File, accountId); + newFileIndex.Id = fileIndexId; // Keep the same ID db.FileIndexes.Add(newFileIndex); await db.SaveChangesAsync(); @@ -106,17 +248,15 @@ public class FileIndexService(AppDatabase db) } /// - /// Removes file index entries by account ID and path + /// Removes file index entries by account ID and folder /// /// The account ID - /// The parent folder path + /// The folder ID /// The number of indexes removed - public async Task RemoveByPathAsync(Guid accountId, string path) + public async Task RemoveByFolderAsync(Guid accountId, Guid folderId) { - var normalizedPath = NormalizePath(path); - var indexes = await db.FileIndexes - .Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath) + .Where(fi => fi.AccountId == accountId && fi.FolderId == folderId) .ToListAsync(); if (!indexes.Any()) @@ -129,23 +269,21 @@ public class FileIndexService(AppDatabase db) } /// - /// Gets file indexes by account ID and path + /// Gets file indexes by account ID and folder /// /// The account ID - /// The parent folder path + /// The folder ID /// List of file indexes - public async Task> GetByPathAsync(Guid accountId, string path) + public async Task> GetByFolderAsync(Guid accountId, Guid folderId) { - var normalizedPath = NormalizePath(path); - return await db.FileIndexes - .Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath) + .Where(fi => fi.AccountId == accountId && fi.FolderId == folderId) .Include(fi => fi.File) .ToListAsync(); } /// - /// Gets file indexes by file ID + /// Gets file indexes by file ID with folder information /// /// The file ID /// List of file indexes @@ -154,6 +292,7 @@ public class FileIndexService(AppDatabase db) return await db.FileIndexes .Where(fi => fi.FileId == fileId) .Include(fi => fi.File) + .Include(fi => fi.Folder) .ToListAsync(); } @@ -167,31 +306,107 @@ public class FileIndexService(AppDatabase db) return await db.FileIndexes .Where(fi => fi.AccountId == accountId) .Include(fi => fi.File) + .Include(fi => fi.Folder) .ToListAsync(); } /// - /// Normalizes the path to ensure it has a trailing slash and is query-safe + /// Gets file indexes by path for an account (finds folder by path and gets files in that folder) /// - /// The original path - /// The normalized path - public static string NormalizePath(string path) + /// The account ID + /// The path to search for + /// List of file indexes at the specified path + public async Task> GetByPathAsync(Guid accountId, string path) { - if (string.IsNullOrEmpty(path)) - return "/"; + var normalizedPath = NormalizePath(path); - // Ensure the path starts with a slash - if (!path.StartsWith('/')) - path = "/" + path; + // Find the folder that corresponds to this path + var folder = await db.Folders + .FirstOrDefaultAsync(f => f.AccountId == accountId && f.Path == normalizedPath + (normalizedPath == "/" ? "" : "/")); - // Ensure the path ends with a slash (unless it's just the root) - if (path != "/" && !path.EndsWith('/')) - path += "/"; + if (folder == null) + return new List(); - // Make path query-safe by removing problematic characters - // This is a basic implementation - you might want to add more robust validation - path = path.Replace("%", "").Replace("'", "").Replace("\"", ""); - - return path; + return await db.FileIndexes + .Where(fi => fi.AccountId == accountId && fi.FolderId == folder.Id) + .Include(fi => fi.File) + .Include(fi => fi.Folder) + .ToListAsync(); } -} \ No newline at end of file + + /// + /// Updates the path of a file index + /// + /// The file index ID + /// The new path + /// The updated file index, or null if not found + public async Task UpdateAsync(Guid fileIndexId, string newPath) + { + var fileIndex = await db.FileIndexes + .Include(fi => fi.File) + .Include(fi => fi.Folder) + .FirstOrDefaultAsync(fi => fi.Id == fileIndexId); + + if (fileIndex == null) + return null; + + var normalizedPath = NormalizePath(newPath); + + // Get or create the folder hierarchy based on the new path + var newFolder = await GetOrCreateFolderByPathAsync(normalizedPath, fileIndex.AccountId); + + // Check if a file with the same name already exists in the new folder + var existingFileIndex = await db.FileIndexes + .FirstOrDefaultAsync(fi => fi.AccountId == fileIndex.AccountId && + fi.FolderId == newFolder.Id && + fi.File.Name == fileIndex.File.Name && + fi.Id != fileIndexId); + + if (existingFileIndex != null) + { + throw new InvalidOperationException( + $"A file with name '{fileIndex.File.Name}' already exists in folder '{newFolder.Name}'"); + } + + // Since properties are init-only, we need to remove the old index and create a new one + db.FileIndexes.Remove(fileIndex); + + var updatedFileIndex = SnCloudFileIndex.Create(newFolder, fileIndex.File, fileIndex.AccountId); + updatedFileIndex.Id = fileIndexId; // Keep the same ID + + db.FileIndexes.Add(updatedFileIndex); + await db.SaveChangesAsync(); + + return updatedFileIndex; + } + + /// + /// Removes all file index entries at a specific path for an account (finds folder by path and removes files from that folder) + /// + /// The account ID + /// The path to clear + /// The number of indexes removed + public async Task RemoveByPathAsync(Guid accountId, string path) + { + var normalizedPath = NormalizePath(path); + + // Find the folder that corresponds to this path + var folder = await db.Folders + .FirstOrDefaultAsync(f => f.AccountId == accountId && f.Path == normalizedPath + (normalizedPath == "/" ? "" : "/")); + + if (folder == null) + return 0; + + var indexes = await db.FileIndexes + .Where(fi => fi.AccountId == accountId && fi.FolderId == folder.Id) + .ToListAsync(); + + if (!indexes.Any()) + return 0; + + db.FileIndexes.RemoveRange(indexes); + await db.SaveChangesAsync(); + + return indexes.Count; + } +} diff --git a/DysonNetwork.Drive/Index/FolderService.cs b/DysonNetwork.Drive/Index/FolderService.cs new file mode 100644 index 0000000..77b67fd --- /dev/null +++ b/DysonNetwork.Drive/Index/FolderService.cs @@ -0,0 +1,313 @@ +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Drive.Index; + +public class FolderService(AppDatabase db) +{ + /// + /// Creates a new folder + /// + /// The folder name + /// The account ID + /// Optional parent folder ID + /// The created folder + public async Task CreateAsync(string name, Guid accountId, Guid? parentFolderId = null) + { + // Validate folder name + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Folder name cannot be empty", nameof(name)); + + // Check if parent folder exists and belongs to the same account + SnCloudFolder? parentFolder = null; + if (parentFolderId.HasValue) + { + parentFolder = await db.Folders + .FirstOrDefaultAsync(f => f.Id == parentFolderId && f.AccountId == accountId); + + if (parentFolder == null) + throw new InvalidOperationException($"Parent folder with ID '{parentFolderId}' not found or access denied"); + } + + // Check if folder with same name already exists in the same location + var existingFolder = await db.Folders + .FirstOrDefaultAsync(f => f.AccountId == accountId && + f.ParentFolderId == parentFolderId && + f.Name == name); + + if (existingFolder != null) + { + throw new InvalidOperationException( + $"A folder with name '{name}' already exists in the specified location"); + } + + var folder = SnCloudFolder.Create(name, accountId, parentFolder); + db.Folders.Add(folder); + await db.SaveChangesAsync(); + + return folder; + } + + /// + /// Creates the root folder for an account (if it doesn't exist) + /// + /// The account ID + /// The root folder + public async Task EnsureRootFolderAsync(Guid accountId) + { + var rootFolder = await db.Folders + .FirstOrDefaultAsync(f => f.AccountId == accountId && f.ParentFolderId == null); + + if (rootFolder == null) + { + rootFolder = SnCloudFolder.CreateRoot(accountId); + db.Folders.Add(rootFolder); + await db.SaveChangesAsync(); + } + + return rootFolder; + } + + /// + /// Gets a folder by ID with its contents + /// + /// The folder ID + /// The account ID (for authorization) + /// The folder with child folders and files + public async Task GetByIdAsync(Guid folderId, Guid accountId) + { + return await db.Folders + .Include(f => f.ChildFolders.OrderBy(cf => cf.Name)) + .Include(f => f.Files.OrderBy(fi => fi.File.Name)) + .ThenInclude(fi => fi.File) + .FirstOrDefaultAsync(f => f.Id == folderId && f.AccountId == accountId); + } + + /// + /// Gets all folders for an account + /// + /// The account ID + /// List of folders + public async Task> GetByAccountIdAsync(Guid accountId) + { + return await db.Folders + .Where(f => f.AccountId == accountId) + .Include(f => f.ParentFolder) + .OrderBy(f => f.Path) + .ToListAsync(); + } + + /// + /// Gets child folders of a parent folder + /// + /// The parent folder ID + /// The account ID + /// List of child folders + public async Task> GetChildFoldersAsync(Guid parentFolderId, Guid accountId) + { + return await db.Folders + .Where(f => f.ParentFolderId == parentFolderId && f.AccountId == accountId) + .OrderBy(f => f.Name) + .ToListAsync(); + } + + /// + /// Updates a folder's name and path + /// + /// The folder ID + /// The new folder name + /// The account ID + /// The updated folder + public async Task UpdateAsync(Guid folderId, string newName, Guid accountId) + { + var folder = await db.Folders + .Include(f => f.ParentFolder) + .FirstOrDefaultAsync(f => f.Id == folderId && f.AccountId == accountId); + + if (folder == null) + return null; + + // Check if folder with same name already exists in the same location + var existingFolder = await db.Folders + .FirstOrDefaultAsync(f => f.AccountId == accountId && + f.ParentFolderId == folder.ParentFolderId && + f.Name == newName && f.Id != folderId); + + if (existingFolder != null) + { + throw new InvalidOperationException( + $"A folder with name '{newName}' already exists in the specified location"); + } + + // Update folder name and path + var oldPath = folder.Path; + folder = SnCloudFolder.Create(newName, accountId, folder.ParentFolder); + folder.Id = folderId; // Keep the same ID + + // Update all child folders' paths recursively + await UpdateChildFolderPathsAsync(folderId, oldPath, folder.Path); + + db.Folders.Update(folder); + await db.SaveChangesAsync(); + + return folder; + } + + /// + /// Recursively updates child folder paths when a parent folder is renamed + /// + private async Task UpdateChildFolderPathsAsync(Guid parentFolderId, string oldParentPath, string newParentPath) + { + var childFolders = await db.Folders + .Where(f => f.ParentFolderId == parentFolderId) + .ToListAsync(); + + foreach (var childFolder in childFolders) + { + var newPath = childFolder.Path.Replace(oldParentPath, newParentPath); + childFolder.Path = newPath; + + // Recursively update grandchildren + await UpdateChildFolderPathsAsync(childFolder.Id, oldParentPath, newParentPath); + } + + await db.SaveChangesAsync(); + } + + /// + /// Deletes a folder and all its contents + /// + /// The folder ID + /// The account ID + /// True if the folder was deleted, false otherwise + public async Task DeleteAsync(Guid folderId, Guid accountId) + { + var folder = await db.Folders + .Include(f => f.ChildFolders) + .Include(f => f.Files) + .FirstOrDefaultAsync(f => f.Id == folderId && f.AccountId == accountId); + + if (folder == null) + return false; + + // Recursively delete child folders + foreach (var childFolder in folder.ChildFolders.ToList()) + { + await DeleteAsync(childFolder.Id, accountId); + } + + // Remove file indexes + db.FileIndexes.RemoveRange(folder.Files); + + // Remove the folder itself + db.Folders.Remove(folder); + await db.SaveChangesAsync(); + + return true; + } + + /// + /// Moves a folder to a new parent folder + /// + /// The folder ID + /// The new parent folder ID + /// The account ID + /// The moved folder + public async Task MoveAsync(Guid folderId, Guid? newParentFolderId, Guid accountId) + { + var folder = await db.Folders + .FirstOrDefaultAsync(f => f.Id == folderId && f.AccountId == accountId); + + if (folder == null) + return null; + + // Check if new parent exists and belongs to the same account + SnCloudFolder? newParentFolder = null; + if (newParentFolderId.HasValue) + { + newParentFolder = await db.Folders + .FirstOrDefaultAsync(f => f.Id == newParentFolderId && f.AccountId == accountId); + + if (newParentFolder == null) + throw new InvalidOperationException($"Target folder with ID '{newParentFolderId}' not found or access denied"); + } + + // Check for circular reference + if (newParentFolderId.HasValue && await IsCircularReferenceAsync(folderId, newParentFolderId.Value)) + { + throw new InvalidOperationException("Cannot move folder to its own descendant"); + } + + // Check if folder with same name already exists in the target location + var existingFolder = await db.Folders + .FirstOrDefaultAsync(f => f.AccountId == accountId && + f.ParentFolderId == newParentFolderId && + f.Name == folder.Name); + + if (existingFolder != null) + { + throw new InvalidOperationException( + $"A folder with name '{folder.Name}' already exists in the target location"); + } + + var oldPath = folder.Path; + var newPath = newParentFolder != null + ? $"{newParentFolder.Path.TrimEnd('/')}/{folder.Name}/" + : $"/{folder.Name}/"; + + // Update folder parent and path + folder.ParentFolderId = newParentFolderId; + folder.Path = newPath; + + // Update all child folders' paths recursively + await UpdateChildFolderPathsAsync(folderId, oldPath, newPath); + + db.Folders.Update(folder); + await db.SaveChangesAsync(); + + return folder; + } + + /// + /// Checks if moving a folder would create a circular reference + /// + private async Task IsCircularReferenceAsync(Guid folderId, Guid potentialParentId) + { + if (folderId == potentialParentId) + return true; + + var currentFolderId = potentialParentId; + while (currentFolderId != Guid.Empty) + { + var currentFolder = await db.Folders + .Where(f => f.Id == currentFolderId) + .Select(f => new { f.Id, f.ParentFolderId }) + .FirstOrDefaultAsync(); + + if (currentFolder == null) + break; + + if (currentFolder.Id == folderId) + return true; + + currentFolderId = currentFolder.ParentFolderId ?? Guid.Empty; + } + + return false; + } + + /// + /// Searches for folders by name + /// + /// The account ID + /// The search term + /// List of matching folders + public async Task> SearchAsync(Guid accountId, string searchTerm) + { + return await db.Folders + .Where(f => f.AccountId == accountId && f.Name.Contains(searchTerm)) + .Include(f => f.ParentFolder) + .OrderBy(f => f.Name) + .ToListAsync(); + } +} diff --git a/DysonNetwork.Drive/Index/README.md b/DysonNetwork.Drive/Index/README.md index 2a72155..d739c80 100644 --- a/DysonNetwork.Drive/Index/README.md +++ b/DysonNetwork.Drive/Index/README.md @@ -12,9 +12,11 @@ And all the arguments will be transformed into snake case via the gateway. ### Core Components 1. **SnCloudFileIndex Model** - Represents the file-to-path mapping -2. **FileIndexService** - Business logic for file index operations -3. **FileIndexController** - REST API endpoints for file management -4. **FileUploadController Integration** - Automatic index creation during upload +2. **SnCloudFolder Model** - Represents hierarchical folder structure +3. **FileIndexService** - Business logic for file index operations +4. **FolderService** - Business logic for folder operations +5. **FileIndexController** - REST API endpoints for file and folder management +6. **FileUploadController Integration** - Automatic index creation during upload ### Database Schema @@ -200,6 +202,138 @@ Search for files by name or metadata. } ``` +### Folder Management + +The system provides comprehensive folder management capabilities alongside file indexing. + +#### Create Folder +**POST** `/api/index/folders` + +Create a new folder. + +**Request Body:** +```json +{ + "name": "Documents", + "parentFolderId": null // null for root folder +} +``` + +**Response:** +```json +{ + "id": "guid", + "name": "Documents", + "parentFolderId": null, + "accountId": "guid", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" +} +``` + +#### Get Folder by ID +**GET** `/api/index/folders/{folderId}` + +Get a folder with its contents. + +**Path Parameters:** +- `folderId` - The folder ID + +**Response:** +```json +{ + "id": "guid", + "name": "Documents", + "parentFolderId": null, + "accountId": "guid", + "childFolders": [ + { + "id": "guid", + "name": "Reports", + "parentFolderId": "guid", + "accountId": "guid" + } + ], + "files": [ + // File index objects + ], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" +} +``` + +#### Get All Folders +**GET** `/api/index/folders` + +Get all folders for the current user. + +**Response:** +```json +[ + { + "id": "guid", + "name": "Documents", + "parentFolderId": null, + "accountId": "guid", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" + } +] +``` + +#### Update Folder +**PUT** `/api/index/folders/{folderId}` + +Update a folder's name. + +**Path Parameters:** +- `folderId` - The folder ID + +**Request Body:** +```json +{ + "name": "Updated Documents" +} +``` + +#### Delete Folder +**DELETE** `/api/index/folders/{folderId}` + +Delete a folder and all its contents. + +**Path Parameters:** +- `folderId` - The folder ID + +#### Move File to Folder +**POST** `/api/index/files/{fileIndexId}/move-to-folder` + +Move a file to a different folder. + +**Path Parameters:** +- `fileIndexId` - The file index ID + +**Request Body:** +```json +{ + "newFolderId": "guid" +} +``` + +#### Get Files in Folder +**GET** `/api/index/folders/{folderId}/files` + +Get all files in a specific folder. + +**Path Parameters:** +- `folderId` - The folder ID + +**Response:** +```json +[ + // File index objects +] +``` + ## Path Normalization The system automatically normalizes paths to ensure consistency: @@ -237,32 +371,75 @@ The system will automatically create a file index when the upload completes succ ```csharp public class FileIndexService { - // Create a new file index - Task CreateAsync(string path, Guid fileId, Guid accountId); - + // Create a new file index at path + Task CreateAsync(string path, string fileId, Guid accountId); + + // Create a new file index in folder + Task CreateInFolderAsync(Guid folderId, string fileId, Guid accountId); + // Get files by path Task> GetByPathAsync(Guid accountId, string path); - + + // Get files by folder + Task> GetByFolderAsync(Guid accountId, Guid folderId); + // Get all files for account Task> GetByAccountIdAsync(Guid accountId); - + // Get indexes for specific file - Task> GetByFileIdAsync(Guid fileId); - + Task> GetByFileIdAsync(string fileId); + // Move file to new path Task UpdateAsync(Guid indexId, string newPath); - + + // Move file to different folder + Task MoveAsync(Guid fileIndexId, Guid newFolderId, Guid accountId); + // Remove file index Task RemoveAsync(Guid indexId); - + // Remove all indexes in path Task RemoveByPathAsync(Guid accountId, string path); - + + // Remove all indexes in folder + Task RemoveByFolderAsync(Guid accountId, Guid folderId); + // Normalize path format public static string NormalizePath(string path); } ``` +### FolderService + +```csharp +public class FolderService +{ + // Create a new folder + Task CreateAsync(string name, Guid accountId, Guid? parentFolderId = null); + + // Get folder by ID with contents + Task GetByIdAsync(Guid folderId, Guid accountId); + + // Get all folders for account + Task> GetByAccountIdAsync(Guid accountId); + + // Get child folders + Task> GetChildFoldersAsync(Guid parentFolderId, Guid accountId); + + // Update folder name + Task UpdateAsync(Guid folderId, string name, Guid accountId); + + // Move folder to new parent + Task MoveAsync(Guid folderId, Guid? newParentFolderId, Guid accountId); + + // Delete folder and contents + Task DeleteAsync(Guid folderId, Guid accountId); + + // Search folders by name + Task> SearchAsync(Guid accountId, string searchTerm); +} +``` + ## Error Handling The API returns appropriate HTTP status codes and error messages: diff --git a/DysonNetwork.Drive/Migrations/20251113165508_AddFileFolders.Designer.cs b/DysonNetwork.Drive/Migrations/20251113165508_AddFileFolders.Designer.cs new file mode 100644 index 0000000..1df5c42 --- /dev/null +++ b/DysonNetwork.Drive/Migrations/20251113165508_AddFileFolders.Designer.cs @@ -0,0 +1,722 @@ +// +using System; +using System.Collections.Generic; +using DysonNetwork.Drive; +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DysonNetwork.Drive.Migrations +{ + [DbContext(typeof(AppDatabase))] + [Migration("20251113165508_AddFileFolders")] + partial class AddFileFolders + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Quota") + .HasColumnType("bigint") + .HasColumnName("quota"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_quota_records"); + + b.ToTable("quota_records", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Drive.Storage.Model.PersistentTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("description"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("character varying(21)") + .HasColumnName("discriminator"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("error_message"); + + b.Property("EstimatedDurationSeconds") + .HasColumnType("bigint") + .HasColumnName("estimated_duration_seconds"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("LastActivity") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_activity"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property>("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("Progress") + .HasColumnType("double precision") + .HasColumnName("progress"); + + b.Property>("Results") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("results"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TaskId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("task_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_tasks"); + + b.ToTable("tasks", (string)null); + + b.HasDiscriminator().HasValue("PersistentTask"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.CloudFileReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("FileId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("file_id"); + + b.Property("ResourceId") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("resource_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Usage") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("usage"); + + b.HasKey("Id") + .HasName("pk_file_references"); + + b.HasIndex("FileId") + .HasDatabaseName("ix_file_references_file_id"); + + b.ToTable("file_references", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.FilePool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("BillingConfig") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("billing_config"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("description"); + + b.Property("IsHidden") + .HasColumnType("boolean") + .HasColumnName("is_hidden"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("PolicyConfig") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("policy_config"); + + b.Property("StorageConfig") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("storage_config"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_pools"); + + b.ToTable("pools", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("BundleId") + .HasColumnType("uuid") + .HasColumnName("bundle_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property>("FileMeta") + .HasColumnType("jsonb") + .HasColumnName("file_meta"); + + b.Property("HasCompression") + .HasColumnType("boolean") + .HasColumnName("has_compression"); + + b.Property("HasThumbnail") + .HasColumnType("boolean") + .HasColumnName("has_thumbnail"); + + b.Property("Hash") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("hash"); + + b.Property("IsEncrypted") + .HasColumnType("boolean") + .HasColumnName("is_encrypted"); + + b.Property("IsMarkedRecycle") + .HasColumnType("boolean") + .HasColumnName("is_marked_recycle"); + + b.Property("MimeType") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("mime_type"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property>("SensitiveMarks") + .HasColumnType("jsonb") + .HasColumnName("sensitive_marks"); + + b.Property("Size") + .HasColumnType("bigint") + .HasColumnName("size"); + + b.Property("StorageId") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("storage_id"); + + b.Property("StorageUrl") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("storage_url"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at"); + + b.Property>("UserMeta") + .HasColumnType("jsonb") + .HasColumnName("user_meta"); + + b.HasKey("Id") + .HasName("pk_files"); + + b.HasIndex("BundleId") + .HasDatabaseName("ix_files_bundle_id"); + + b.HasIndex("PoolId") + .HasDatabaseName("ix_files_pool_id"); + + b.ToTable("files", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("FileId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("file_id"); + + b.Property("FolderId") + .HasColumnType("uuid") + .HasColumnName("folder_id"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("path"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_file_indexes"); + + b.HasIndex("FileId") + .HasDatabaseName("ix_file_indexes_file_id"); + + b.HasIndex("FolderId", "AccountId") + .HasDatabaseName("ix_file_indexes_folder_id_account_id"); + + b.ToTable("file_indexes", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property>("FolderMeta") + .HasColumnType("jsonb") + .HasColumnName("folder_meta"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("ParentFolderId") + .HasColumnType("uuid") + .HasColumnName("parent_folder_id"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("path"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_folders"); + + b.HasIndex("ParentFolderId") + .HasDatabaseName("ix_folders_parent_folder_id"); + + b.HasIndex("Path", "AccountId") + .HasDatabaseName("ix_folders_path_account_id"); + + b.ToTable("folders", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("description"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("Passcode") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("passcode"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_bundles"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_bundles_slug"); + + b.ToTable("bundles", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Drive.Storage.Model.PersistentUploadTask", b => + { + b.HasBaseType("DysonNetwork.Drive.Storage.Model.PersistentTask"); + + b.Property("BundleId") + .HasColumnType("uuid") + .HasColumnName("bundle_id"); + + b.Property("ChunkSize") + .HasColumnType("bigint") + .HasColumnName("chunk_size"); + + b.Property("ChunksCount") + .HasColumnType("integer") + .HasColumnName("chunks_count"); + + b.Property("ChunksUploaded") + .HasColumnType("integer") + .HasColumnName("chunks_uploaded"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("content_type"); + + b.Property("EncryptPassword") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("encrypt_password"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("file_name"); + + b.Property("FileSize") + .HasColumnType("bigint") + .HasColumnName("file_size"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hash"); + + b.Property("Path") + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.PrimitiveCollection>("UploadedChunks") + .IsRequired() + .HasColumnType("integer[]") + .HasColumnName("uploaded_chunks"); + + b.HasDiscriminator().HasValue("PersistentUploadTask"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.CloudFileReference", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File") + .WithMany("References") + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_file_references_files_file_id"); + + b.Navigation("File"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnFileBundle", "Bundle") + .WithMany("Files") + .HasForeignKey("BundleId") + .HasConstraintName("fk_files_bundles_bundle_id"); + + b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool") + .WithMany() + .HasForeignKey("PoolId") + .HasConstraintName("fk_files_pools_pool_id"); + + b.Navigation("Bundle"); + + b.Navigation("Pool"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File") + .WithMany("FileIndexes") + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_file_indexes_files_file_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnCloudFolder", "Folder") + .WithMany("Files") + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_file_indexes_folders_folder_id"); + + b.Navigation("File"); + + b.Navigation("Folder"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFolder", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnCloudFolder", "ParentFolder") + .WithMany("ChildFolders") + .HasForeignKey("ParentFolderId") + .HasConstraintName("fk_folders_folders_parent_folder_id"); + + b.Navigation("ParentFolder"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b => + { + b.Navigation("FileIndexes"); + + b.Navigation("References"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFolder", b => + { + b.Navigation("ChildFolders"); + + b.Navigation("Files"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b => + { + b.Navigation("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Drive/Migrations/20251113165508_AddFileFolders.cs b/DysonNetwork.Drive/Migrations/20251113165508_AddFileFolders.cs new file mode 100644 index 0000000..30c3b78 --- /dev/null +++ b/DysonNetwork.Drive/Migrations/20251113165508_AddFileFolders.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace DysonNetwork.Drive.Migrations +{ + /// + public partial class AddFileFolders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_file_indexes_path_account_id", + table: "file_indexes"); + + migrationBuilder.AlterColumn( + name: "path", + table: "file_indexes", + type: "character varying(2048)", + maxLength: 2048, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(8192)", + oldMaxLength: 8192); + + migrationBuilder.AddColumn( + name: "folder_id", + table: "file_indexes", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateTable( + name: "folders", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + path = table.Column(type: "character varying(8192)", maxLength: 8192, nullable: false), + parent_folder_id = table.Column(type: "uuid", nullable: true), + account_id = table.Column(type: "uuid", nullable: false), + description = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: true), + folder_meta = table.Column>(type: "jsonb", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_folders", x => x.id); + table.ForeignKey( + name: "fk_folders_folders_parent_folder_id", + column: x => x.parent_folder_id, + principalTable: "folders", + principalColumn: "id"); + }); + + migrationBuilder.CreateIndex( + name: "ix_file_indexes_folder_id_account_id", + table: "file_indexes", + columns: new[] { "folder_id", "account_id" }); + + migrationBuilder.CreateIndex( + name: "ix_folders_parent_folder_id", + table: "folders", + column: "parent_folder_id"); + + migrationBuilder.CreateIndex( + name: "ix_folders_path_account_id", + table: "folders", + columns: new[] { "path", "account_id" }); + + migrationBuilder.AddForeignKey( + name: "fk_file_indexes_folders_folder_id", + table: "file_indexes", + column: "folder_id", + principalTable: "folders", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_file_indexes_folders_folder_id", + table: "file_indexes"); + + migrationBuilder.DropTable( + name: "folders"); + + migrationBuilder.DropIndex( + name: "ix_file_indexes_folder_id_account_id", + table: "file_indexes"); + + migrationBuilder.DropColumn( + name: "folder_id", + table: "file_indexes"); + + migrationBuilder.AlterColumn( + name: "path", + table: "file_indexes", + type: "character varying(8192)", + maxLength: 8192, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(2048)", + oldMaxLength: 2048); + + migrationBuilder.CreateIndex( + name: "ix_file_indexes_path_account_id", + table: "file_indexes", + columns: new[] { "path", "account_id" }); + } + } +} diff --git a/DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs index 824af79..d68945a 100644 --- a/DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs @@ -428,10 +428,14 @@ namespace DysonNetwork.Drive.Migrations .HasColumnType("character varying(32)") .HasColumnName("file_id"); + b.Property("FolderId") + .HasColumnType("uuid") + .HasColumnName("folder_id"); + b.Property("Path") .IsRequired() - .HasMaxLength(8192) - .HasColumnType("character varying(8192)") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") .HasColumnName("path"); b.Property("UpdatedAt") @@ -444,12 +448,72 @@ namespace DysonNetwork.Drive.Migrations b.HasIndex("FileId") .HasDatabaseName("ix_file_indexes_file_id"); - b.HasIndex("Path", "AccountId") - .HasDatabaseName("ix_file_indexes_path_account_id"); + b.HasIndex("FolderId", "AccountId") + .HasDatabaseName("ix_file_indexes_folder_id_account_id"); b.ToTable("file_indexes", (string)null); }); + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property>("FolderMeta") + .HasColumnType("jsonb") + .HasColumnName("folder_meta"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("ParentFolderId") + .HasColumnType("uuid") + .HasColumnName("parent_folder_id"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("path"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_folders"); + + b.HasIndex("ParentFolderId") + .HasDatabaseName("ix_folders_parent_folder_id"); + + b.HasIndex("Path", "AccountId") + .HasDatabaseName("ix_folders_path_account_id"); + + b.ToTable("folders", (string)null); + }); + modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b => { b.Property("Id") @@ -609,7 +673,26 @@ namespace DysonNetwork.Drive.Migrations .IsRequired() .HasConstraintName("fk_file_indexes_files_file_id"); + b.HasOne("DysonNetwork.Shared.Models.SnCloudFolder", "Folder") + .WithMany("Files") + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_file_indexes_folders_folder_id"); + b.Navigation("File"); + + b.Navigation("Folder"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFolder", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnCloudFolder", "ParentFolder") + .WithMany("ChildFolders") + .HasForeignKey("ParentFolderId") + .HasConstraintName("fk_folders_folders_parent_folder_id"); + + b.Navigation("ParentFolder"); }); modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b => @@ -619,6 +702,13 @@ namespace DysonNetwork.Drive.Migrations b.Navigation("References"); }); + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFolder", b => + { + b.Navigation("ChildFolders"); + + b.Navigation("Files"); + }); + modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b => { b.Navigation("Files"); diff --git a/DysonNetwork.Drive/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Drive/Startup/ServiceCollectionExtensions.cs index 3fbd280..cc1026b 100644 --- a/DysonNetwork.Drive/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Drive/Startup/ServiceCollectionExtensions.cs @@ -57,6 +57,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/DysonNetwork.Shared/Models/CloudFileIndex.cs b/DysonNetwork.Shared/Models/CloudFileIndex.cs index 9e64e09..e882c2e 100644 --- a/DysonNetwork.Shared/Models/CloudFileIndex.cs +++ b/DysonNetwork.Shared/Models/CloudFileIndex.cs @@ -1,30 +1,70 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; namespace DysonNetwork.Shared.Models; -[Index(nameof(Path), nameof(AccountId))] +[Index(nameof(FolderId), nameof(AccountId))] +[Index(nameof(FileId))] public class SnCloudFileIndex : ModelBase { - public Guid Id { get; init; } = Guid.NewGuid(); + public Guid Id { get; set; } = Guid.NewGuid(); /// - /// The path of the file, - /// only store the parent folder. - /// With the trailing slash. - /// - /// Like /hello/here/ not /hello/here/text.txt or /hello/here - /// Or the user home folder files, store as / - /// - /// Besides, the folder name should be all query-safe, not contains % or - /// other special characters that will mess up the pgsql query + /// Reference to the folder containing this file /// - [MaxLength(8192)] - public string Path { get; init; } = null!; + public Guid FolderId { get; init; } + + /// + /// Navigation property to the folder + /// + [JsonIgnore] + public SnCloudFolder Folder { get; init; } = null!; [MaxLength(32)] public string FileId { get; init; } = null!; public SnCloudFile File { get; init; } = null!; public Guid AccountId { get; init; } [NotMapped] public SnAccount? Account { get; init; } + + /// + /// Cached full path of the file (stored in database for performance) + /// + [MaxLength(2048)] + public string Path { get; set; } = null!; + + /// + /// Creates a new file index with the specified folder and file + /// + public static SnCloudFileIndex Create(SnCloudFolder folder, SnCloudFile file, Guid accountId) + { + // Build the full path by traversing the folder hierarchy + var pathSegments = new List(); + var currentFolder = folder; + + while (currentFolder != null) + { + if (!string.IsNullOrEmpty(currentFolder.Name)) + { + pathSegments.Insert(0, currentFolder.Name); + } + currentFolder = currentFolder.ParentFolder; + } + + // Add the file name + if (!string.IsNullOrEmpty(file.Name)) + { + pathSegments.Add(file.Name); + } + + var fullPath = "/" + string.Join("/", pathSegments); + + return new SnCloudFileIndex + { + FolderId = folder.Id, + FileId = file.Id, + AccountId = accountId, + Path = fullPath + }; + } } diff --git a/DysonNetwork.Shared/Models/CloudFolder.cs b/DysonNetwork.Shared/Models/CloudFolder.cs new file mode 100644 index 0000000..609f0f2 --- /dev/null +++ b/DysonNetwork.Shared/Models/CloudFolder.cs @@ -0,0 +1,103 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Shared.Models; + +[Index(nameof(Path), nameof(AccountId))] +[Index(nameof(ParentFolderId))] +public class SnCloudFolder : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// The name of the folder + /// + [MaxLength(256)] + public string Name { get; init; } = null!; + + /// + /// The full path of the folder (for querying purposes) + /// With trailing slash, e.g., /documents/work/ + /// + [MaxLength(8192)] + public string Path { get; set; } = null!; + + /// + /// Reference to the parent folder (null for root folders) + /// + public Guid? ParentFolderId { get; set; } + + /// + /// Navigation property to the parent folder + /// + [ForeignKey(nameof(ParentFolderId))] + public SnCloudFolder? ParentFolder { get; init; } + + /// + /// Navigation property to child folders + /// + [InverseProperty(nameof(ParentFolder))] + public List ChildFolders { get; init; } = []; + + /// + /// Navigation property to files in this folder + /// + [InverseProperty(nameof(SnCloudFileIndex.Folder))] + public List Files { get; init; } = []; + + /// + /// The account that owns this folder + /// + public Guid AccountId { get; init; } + + /// + /// Navigation property to the account + /// + [NotMapped] + public SnAccount? Account { get; init; } + + /// + /// Optional description for the folder + /// + [MaxLength(4096)] + public string? Description { get; init; } + + /// + /// Custom metadata for the folder + /// + [Column(TypeName = "jsonb")] + public Dictionary? FolderMeta { get; init; } + + /// + /// Creates a new folder with proper path normalization + /// + public static SnCloudFolder Create(string name, Guid accountId, SnCloudFolder? parentFolder = null) + { + var normalizedPath = parentFolder != null + ? $"{parentFolder.Path.TrimEnd('/')}/{name}/" + : $"/{name}/"; + + return new SnCloudFolder + { + Name = name, + Path = normalizedPath, + ParentFolderId = parentFolder?.Id, + AccountId = accountId + }; + } + + /// + /// Creates the root folder for an account + /// + public static SnCloudFolder CreateRoot(Guid accountId) + { + return new SnCloudFolder + { + Name = "Root", + Path = "/", + ParentFolderId = null, + AccountId = accountId + }; + } +}