Revert "♻️ Proper folder system to index"

This reverts commit 1647aa2f1e.
This commit is contained in:
2025-11-14 22:11:21 +08:00
parent bd2943345a
commit 73700e7cfd
11 changed files with 126 additions and 2333 deletions

View File

@@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
@@ -13,7 +15,6 @@ namespace DysonNetwork.Drive.Index;
[Authorize]
public class FileIndexController(
FileIndexService fileIndexService,
FolderService folderService,
AppDatabase db,
ILogger<FileIndexController> logger
) : ControllerBase
@@ -34,10 +35,13 @@ public class FileIndexController(
try
{
var fileIndexes = await fileIndexService.GetByPathAsync(accountId, path);
// Get child folders using the folder system
var childFolders = await GetChildFoldersAsync(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);
return Ok(new
{
Path = path,
@@ -59,108 +63,38 @@ public class FileIndexController(
}
/// <summary>
/// Gets child folders for a given parent path using the folder system
/// Extracts unique child folder paths from all file indexes for a given parent path
/// </summary>
/// <param name="accountId">The account ID</param>
/// <param name="allFileIndexes">All file indexes for the account</param>
/// <param name="parentPath">The parent path to find children for</param>
/// <returns>List of child folder objects</returns>
private async Task<List<SnCloudFolder>> GetChildFoldersAsync(Guid accountId, string parentPath)
/// <returns>List of unique child folder names</returns>
private List<string> ExtractChildFolders(List<SnCloudFileIndex> allFileIndexes, string parentPath)
{
var normalizedParentPath = FileIndexService.NormalizePath(parentPath);
var childFolders = new HashSet<string>();
// Try to find a folder that corresponds to this path
var parentFolder = await FindFolderByPathAsync(accountId, normalizedParentPath);
if (parentFolder != null)
foreach (var index in allFileIndexes)
{
// 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<SnCloudFolder>();
foreach (var folder in allFolders)
var normalizedIndexPath = FileIndexService.NormalizePath(index.Path);
// Check if this path is a direct child of the parent path
if (normalizedIndexPath.StartsWith(normalizedParentPath) &&
normalizedIndexPath != normalizedParentPath)
{
// 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 == "/")
// 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)
{
// Root level folders
childFolders.Add(folder);
var folderName = relativePath.Substring(0, firstSlashIndex);
childFolders.Add(folderName);
}
// For nested folders, we'd need path information in the folder model
}
return childFolders.OrderBy(f => f.Name).ToList();
}
}
/// <summary>
/// Attempts to find a folder by its path
/// </summary>
/// <param name="accountId">The account ID</param>
/// <param name="path">The path to search for</param>
/// <returns>The folder if found, null otherwise</returns>
private async Task<SnCloudFolder?> 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;
}
/// <summary>
/// Gets or creates a folder hierarchy based on a file path
/// </summary>
/// <param name="filePath">The file path (e.g., "/folder/sub/file.txt")</param>
/// <param name="accountId">The account ID</param>
/// <returns>The folder where the file should be placed</returns>
private async Task<SnCloudFolder> 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;
return childFolders.OrderBy(f => f).ToList();
}
/// <summary>
@@ -182,7 +116,7 @@ public class FileIndexController(
return Ok(new
{
Files = fileIndexes,
TotalCount = fileIndexes.Count
TotalCount = fileIndexes.Count()
});
}
catch (Exception ex)
@@ -425,11 +359,9 @@ 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 - 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);
// Check if index already exists for this file and path
var existingIndex = await db.FileIndexes
.FirstOrDefaultAsync(fi => fi.FileId == request.FileId && fi.FolderId == targetFolder.Id && fi.AccountId == accountId);
.FirstOrDefaultAsync(fi => fi.FileId == request.FileId && fi.Path == request.Path && fi.AccountId == accountId);
if (existingIndex != null)
return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]>
@@ -443,7 +375,7 @@ public class FileIndexController(
{
IndexId = fileIndex.Id,
fileIndex.FileId,
Path = fileIndex.Path,
fileIndex.Path,
Message = "File index created successfully"
});
}
@@ -460,56 +392,6 @@ public class FileIndexController(
}
}
/// <summary>
/// Gets unindexed files for the current user (files that exist but don't have file indexes)
/// </summary>
/// <param name="offset">Pagination offset</param>
/// <param name="take">Number of files to take</param>
/// <returns>List of unindexed files</returns>
[HttpGet("unindexed")]
public async Task<IActionResult> 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 };
}
}
/// <summary>
/// Searches for files by name or metadata
/// </summary>
@@ -528,29 +410,14 @@ public class FileIndexController(
{
// Build the query with all conditions at once
var searchTerm = query.ToLower();
var baseQuery = db.FileIndexes
var fileIndexes = await db.FileIndexes
.Where(fi => fi.AccountId == accountId)
.Include(fi => fi.File);
IQueryable<SnCloudFileIndex> 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)))
.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))))
.ToListAsync();
return Ok(new
@@ -572,249 +439,6 @@ public class FileIndexController(
}) { StatusCode = 500 };
}
}
/// <summary>
/// Creates a new folder
/// </summary>
/// <param name="request">The folder creation request</param>
/// <returns>The created folder</returns>
[HttpPost("folders")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Gets a folder by ID with its contents
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <returns>The folder with child folders and files</returns>
[HttpGet("folders/{folderId:guid}")]
public async Task<IActionResult> 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);
}
/// <summary>
/// Gets all folders for the current account
/// </summary>
/// <returns>List of folders</returns>
[HttpGet("folders")]
public async Task<IActionResult> 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);
}
/// <summary>
/// Gets child folders of a parent folder
/// </summary>
/// <param name="parentFolderId">The parent folder ID</param>
/// <returns>List of child folders</returns>
[HttpGet("folders/children/{parentFolderId:guid}")]
public async Task<IActionResult> 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);
}
/// <summary>
/// Updates a folder's name
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <param name="request">The update request</param>
/// <returns>The updated folder</returns>
[HttpPut("folders/{folderId:guid}")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Deletes a folder and all its contents
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <returns>Success status</returns>
[HttpDelete("folders/{folderId:guid}")]
public async Task<IActionResult> 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();
}
/// <summary>
/// Moves a folder to a new parent folder
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <param name="request">The move request</param>
/// <returns>The moved folder</returns>
[HttpPost("folders/{folderId:guid}/move")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Searches for folders by name
/// </summary>
/// <param name="searchTerm">The search term</param>
/// <returns>List of matching folders</returns>
[HttpGet("folders/search")]
public async Task<IActionResult> 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);
}
/// <summary>
/// Gets files in a specific folder
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <returns>List of files in the folder</returns>
[HttpGet("folders/{folderId:guid}/files")]
public async Task<IActionResult> 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);
}
/// <summary>
/// Moves a file to a different folder
/// </summary>
/// <param name="fileIndexId">The file index ID</param>
/// <param name="request">The move request</param>
/// <returns>The updated file index</returns>
[HttpPost("files/{fileIndexId:guid}/move-to-folder")]
public async Task<IActionResult> 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
@@ -827,52 +451,3 @@ public class CreateFileIndexRequest
[MaxLength(32)] public string FileId { get; set; } = null!;
public string Path { get; set; } = null!;
}
/// <summary>
/// Request model for creating a folder
/// </summary>
public class CreateFolderRequest
{
/// <summary>
/// The name of the folder
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// Optional parent folder ID (null for root folder)
/// </summary>
public Guid? ParentFolderId { get; set; }
}
/// <summary>
/// Request model for updating a folder
/// </summary>
public class UpdateFolderRequest
{
/// <summary>
/// The new name for the folder
/// </summary>
public string Name { get; set; } = null!;
}
/// <summary>
/// Request model for moving a folder
/// </summary>
public class MoveFolderRequest
{
/// <summary>
/// The new parent folder ID (null for root)
/// </summary>
public Guid? NewParentFolderId { get; set; }
}
/// <summary>
/// Request model for moving a file to a folder
/// </summary>
public class MoveFileToFolderRequest
{
/// <summary>
/// The new folder ID
/// </summary>
public Guid NewFolderId { get; set; }
}

View File

@@ -3,111 +3,37 @@ using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Drive.Index;
public class FileIndexService(AppDatabase db, FolderService folderService)
public class FileIndexService(AppDatabase db)
{
/// <summary>
/// Normalizes a path to ensure consistent formatting
/// Creates a new file index entry
/// </summary>
/// <param name="path">The path to normalize</param>
/// <returns>The normalized path</returns>
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;
}
/// <summary>
/// Gets or creates a folder hierarchy based on a file path
/// </summary>
/// <param name="filePath">The file path (e.g., "/folder/sub/file.txt")</param>
/// <param name="accountId">The account ID</param>
/// <returns>The folder where the file should be placed</returns>
private async Task<SnCloudFolder> 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;
}
/// <summary>
/// Creates a new file index entry at a specific path (creates folder hierarchy if needed)
/// </summary>
/// <param name="path">The path where the file should be indexed</param>
/// <param name="path">The parent folder path with a trailing slash</param>
/// <param name="fileId">The file ID</param>
/// <param name="accountId">The account ID</param>
/// <returns>The created file index</returns>
public async Task<SnCloudFileIndex> CreateAsync(string path, string fileId, Guid accountId)
{
// Ensure a path has a trailing slash and is query-safe
var normalizedPath = NormalizePath(path);
// 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
// Check if a file with the same name already exists in the same path for this account
var existingFileIndex = await db.FileIndexes
.FirstOrDefaultAsync(fi => fi.AccountId == accountId &&
fi.FolderId == folder.Id &&
fi.File.Name == file.Name);
.FirstOrDefaultAsync(fi => fi.AccountId == accountId && fi.Path == normalizedPath && fi.FileId == fileId);
if (existingFileIndex != null)
{
throw new InvalidOperationException(
$"A file with name '{file.Name}' already exists in folder '{folder.Name}' for account '{accountId}'");
$"A file with ID '{fileId}' already exists in path '{normalizedPath}' for account '{accountId}'");
}
var fileIndex = SnCloudFileIndex.Create(folder, file, accountId);
var fileIndex = new SnCloudFileIndex
{
Path = normalizedPath,
FileId = fileId,
AccountId = accountId
};
db.FileIndexes.Add(fileIndex);
await db.SaveChangesAsync();
@@ -115,94 +41,26 @@ public class FileIndexService(AppDatabase db, FolderService folderService)
}
/// <summary>
/// Creates a new file index entry in a specific folder
/// Updates an existing file index entry by removing the old one and creating a new one
/// </summary>
/// <param name="folderId">The folder ID where the file should be placed</param>
/// <param name="fileId">The file ID</param>
/// <param name="accountId">The account ID</param>
/// <returns>The created file index</returns>
public async Task<SnCloudFileIndex> CreateInFolderAsync(Guid folderId, string fileId, Guid accountId)
{
// 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;
}
/// <summary>
/// Moves a file to a different folder
/// </summary>
/// <param name="fileIndexId">The file index ID</param>
/// <param name="newFolderId">The new folder ID</param>
/// <param name="accountId">The account ID</param>
/// <param name="id">The file index ID</param>
/// <param name="newPath">The new parent folder path with trailing slash</param>
/// <returns>The updated file index</returns>
public async Task<SnCloudFileIndex?> MoveAsync(Guid fileIndexId, Guid newFolderId, Guid accountId)
public async Task<SnCloudFileIndex?> UpdateAsync(Guid id, string newPath)
{
var fileIndex = await db.FileIndexes
.Include(fi => fi.File)
.FirstOrDefaultAsync(fi => fi.Id == fileIndexId && fi.AccountId == accountId);
var fileIndex = await db.FileIndexes.FindAsync(id);
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 = SnCloudFileIndex.Create(newFolder, fileIndex.File, accountId);
newFileIndex.Id = fileIndexId; // Keep the same ID
var newFileIndex = new SnCloudFileIndex
{
Path = NormalizePath(newPath),
FileId = fileIndex.FileId,
AccountId = fileIndex.AccountId
};
db.FileIndexes.Add(newFileIndex);
await db.SaveChangesAsync();
@@ -248,15 +106,17 @@ public class FileIndexService(AppDatabase db, FolderService folderService)
}
/// <summary>
/// Removes file index entries by account ID and folder
/// Removes file index entries by account ID and path
/// </summary>
/// <param name="accountId">The account ID</param>
/// <param name="folderId">The folder ID</param>
/// <param name="path">The parent folder path</param>
/// <returns>The number of indexes removed</returns>
public async Task<int> RemoveByFolderAsync(Guid accountId, Guid folderId)
public async Task<int> RemoveByPathAsync(Guid accountId, string path)
{
var normalizedPath = NormalizePath(path);
var indexes = await db.FileIndexes
.Where(fi => fi.AccountId == accountId && fi.FolderId == folderId)
.Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath)
.ToListAsync();
if (!indexes.Any())
@@ -269,21 +129,23 @@ public class FileIndexService(AppDatabase db, FolderService folderService)
}
/// <summary>
/// Gets file indexes by account ID and folder
/// Gets file indexes by account ID and path
/// </summary>
/// <param name="accountId">The account ID</param>
/// <param name="folderId">The folder ID</param>
/// <param name="path">The parent folder path</param>
/// <returns>List of file indexes</returns>
public async Task<List<SnCloudFileIndex>> GetByFolderAsync(Guid accountId, Guid folderId)
public async Task<List<SnCloudFileIndex>> GetByPathAsync(Guid accountId, string path)
{
var normalizedPath = NormalizePath(path);
return await db.FileIndexes
.Where(fi => fi.AccountId == accountId && fi.FolderId == folderId)
.Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath)
.Include(fi => fi.File)
.ToListAsync();
}
/// <summary>
/// Gets file indexes by file ID with folder information
/// Gets file indexes by file ID
/// </summary>
/// <param name="fileId">The file ID</param>
/// <returns>List of file indexes</returns>
@@ -292,7 +154,6 @@ public class FileIndexService(AppDatabase db, FolderService folderService)
return await db.FileIndexes
.Where(fi => fi.FileId == fileId)
.Include(fi => fi.File)
.Include(fi => fi.Folder)
.ToListAsync();
}
@@ -306,107 +167,31 @@ public class FileIndexService(AppDatabase db, FolderService folderService)
return await db.FileIndexes
.Where(fi => fi.AccountId == accountId)
.Include(fi => fi.File)
.Include(fi => fi.Folder)
.ToListAsync();
}
/// <summary>
/// Gets file indexes by path for an account (finds folder by path and gets files in that folder)
/// Normalizes the path to ensure it has a trailing slash and is query-safe
/// </summary>
/// <param name="accountId">The account ID</param>
/// <param name="path">The path to search for</param>
/// <returns>List of file indexes at the specified path</returns>
public async Task<List<SnCloudFileIndex>> GetByPathAsync(Guid accountId, string path)
/// <param name="path">The original path</param>
/// <returns>The normalized path</returns>
public static string NormalizePath(string path)
{
var normalizedPath = NormalizePath(path);
if (string.IsNullOrEmpty(path))
return "/";
// 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 starts with a slash
if (!path.StartsWith('/'))
path = "/" + path;
if (folder == null)
return new List<SnCloudFileIndex>();
// Ensure the path ends with a slash (unless it's just the root)
if (path != "/" && !path.EndsWith('/'))
path += "/";
return await db.FileIndexes
.Where(fi => fi.AccountId == accountId && fi.FolderId == folder.Id)
.Include(fi => fi.File)
.Include(fi => fi.Folder)
.ToListAsync();
// 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;
}
/// <summary>
/// Updates the path of a file index
/// </summary>
/// <param name="fileIndexId">The file index ID</param>
/// <param name="newPath">The new path</param>
/// <returns>The updated file index, or null if not found</returns>
public async Task<SnCloudFileIndex?> 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;
}
/// <summary>
/// Removes all file index entries at a specific path for an account (finds folder by path and removes files from that folder)
/// </summary>
/// <param name="accountId">The account ID</param>
/// <param name="path">The path to clear</param>
/// <returns>The number of indexes removed</returns>
public async Task<int> 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;
}
}
}

View File

@@ -1,313 +0,0 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Drive.Index;
public class FolderService(AppDatabase db)
{
/// <summary>
/// Creates a new folder
/// </summary>
/// <param name="name">The folder name</param>
/// <param name="accountId">The account ID</param>
/// <param name="parentFolderId">Optional parent folder ID</param>
/// <returns>The created folder</returns>
public async Task<SnCloudFolder> 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;
}
/// <summary>
/// Creates the root folder for an account (if it doesn't exist)
/// </summary>
/// <param name="accountId">The account ID</param>
/// <returns>The root folder</returns>
public async Task<SnCloudFolder> 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;
}
/// <summary>
/// Gets a folder by ID with its contents
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <param name="accountId">The account ID (for authorization)</param>
/// <returns>The folder with child folders and files</returns>
public async Task<SnCloudFolder?> 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);
}
/// <summary>
/// Gets all folders for an account
/// </summary>
/// <param name="accountId">The account ID</param>
/// <returns>List of folders</returns>
public async Task<List<SnCloudFolder>> GetByAccountIdAsync(Guid accountId)
{
return await db.Folders
.Where(f => f.AccountId == accountId)
.Include(f => f.ParentFolder)
.OrderBy(f => f.Path)
.ToListAsync();
}
/// <summary>
/// Gets child folders of a parent folder
/// </summary>
/// <param name="parentFolderId">The parent folder ID</param>
/// <param name="accountId">The account ID</param>
/// <returns>List of child folders</returns>
public async Task<List<SnCloudFolder>> GetChildFoldersAsync(Guid parentFolderId, Guid accountId)
{
return await db.Folders
.Where(f => f.ParentFolderId == parentFolderId && f.AccountId == accountId)
.OrderBy(f => f.Name)
.ToListAsync();
}
/// <summary>
/// Updates a folder's name and path
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <param name="newName">The new folder name</param>
/// <param name="accountId">The account ID</param>
/// <returns>The updated folder</returns>
public async Task<SnCloudFolder?> 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;
}
/// <summary>
/// Recursively updates child folder paths when a parent folder is renamed
/// </summary>
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();
}
/// <summary>
/// Deletes a folder and all its contents
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <param name="accountId">The account ID</param>
/// <returns>True if the folder was deleted, false otherwise</returns>
public async Task<bool> 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;
}
/// <summary>
/// Moves a folder to a new parent folder
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <param name="newParentFolderId">The new parent folder ID</param>
/// <param name="accountId">The account ID</param>
/// <returns>The moved folder</returns>
public async Task<SnCloudFolder?> 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;
}
/// <summary>
/// Checks if moving a folder would create a circular reference
/// </summary>
private async Task<bool> 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;
}
/// <summary>
/// Searches for folders by name
/// </summary>
/// <param name="accountId">The account ID</param>
/// <param name="searchTerm">The search term</param>
/// <returns>List of matching folders</returns>
public async Task<List<SnCloudFolder>> 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();
}
}

View File

@@ -12,11 +12,9 @@ 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. **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
2. **FileIndexService** - Business logic for file index operations
3. **FileIndexController** - REST API endpoints for file management
4. **FileUploadController Integration** - Automatic index creation during upload
### Database Schema
@@ -202,138 +200,6 @@ 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:
@@ -371,75 +237,32 @@ The system will automatically create a file index when the upload completes succ
```csharp
public class FileIndexService
{
// Create a new file index at path
Task<SnCloudFileIndex> CreateAsync(string path, string fileId, Guid accountId);
// Create a new file index in folder
Task<SnCloudFileIndex> CreateInFolderAsync(Guid folderId, string fileId, Guid accountId);
// Create a new file index
Task<SnCloudFileIndex> CreateAsync(string path, Guid fileId, Guid accountId);
// Get files by path
Task<List<SnCloudFileIndex>> GetByPathAsync(Guid accountId, string path);
// Get files by folder
Task<List<SnCloudFileIndex>> GetByFolderAsync(Guid accountId, Guid folderId);
// Get all files for account
Task<List<SnCloudFileIndex>> GetByAccountIdAsync(Guid accountId);
// Get indexes for specific file
Task<List<SnCloudFileIndex>> GetByFileIdAsync(string fileId);
Task<List<SnCloudFileIndex>> GetByFileIdAsync(Guid fileId);
// Move file to new path
Task<SnCloudFileIndex?> UpdateAsync(Guid indexId, string newPath);
// Move file to different folder
Task<SnCloudFileIndex?> MoveAsync(Guid fileIndexId, Guid newFolderId, Guid accountId);
// Remove file index
Task<bool> RemoveAsync(Guid indexId);
// Remove all indexes in path
Task<int> RemoveByPathAsync(Guid accountId, string path);
// Remove all indexes in folder
Task<int> RemoveByFolderAsync(Guid accountId, Guid folderId);
// Normalize path format
public static string NormalizePath(string path);
}
```
### FolderService
```csharp
public class FolderService
{
// Create a new folder
Task<SnCloudFolder> CreateAsync(string name, Guid accountId, Guid? parentFolderId = null);
// Get folder by ID with contents
Task<SnCloudFolder?> GetByIdAsync(Guid folderId, Guid accountId);
// Get all folders for account
Task<List<SnCloudFolder>> GetByAccountIdAsync(Guid accountId);
// Get child folders
Task<List<SnCloudFolder>> GetChildFoldersAsync(Guid parentFolderId, Guid accountId);
// Update folder name
Task<SnCloudFolder?> UpdateAsync(Guid folderId, string name, Guid accountId);
// Move folder to new parent
Task<SnCloudFolder?> MoveAsync(Guid folderId, Guid? newParentFolderId, Guid accountId);
// Delete folder and contents
Task<bool> DeleteAsync(Guid folderId, Guid accountId);
// Search folders by name
Task<List<SnCloudFolder>> SearchAsync(Guid accountId, string searchTerm);
}
```
## Error Handling
The API returns appropriate HTTP status codes and error messages: