feat: folder ownership scoping, batch operations integration, frontend audit fixes
Backend: - Add owner_id to Folder entity + FolderDto (DB user_id column) - Add list_folders_by_owner to FolderRepository trait + PG impl - Add list_folders_for_owner to FolderUseCase + FolderService - Rewrite FolderHandler: all endpoints now scope by AuthUser - Remove dead handler methods (list_folders_inner, list_folders_for_user, is_user_home_folder, folder_belongs_to_user) - Add ownership check in get_folder (returns 404 on mismatch) Batch operations: - Add trash_service + zip_service to BatchOperationService - New methods: trash_files, trash_folders, move_folders, download_zip - New handlers: trash_batch, move_folders_batch, download_batch - New routes: POST /api/batch/trash, /api/batch/folders/move, /api/batch/download Frontend: - Replace findUserHomeFolder (~130 lines) with resolveHomeFolder (~35 lines) - Remove client-side folder filtering in loadFiles (backend now scopes) - Rewrite batchDelete: N requests -> 1 POST /api/batch/trash - Rewrite batchMove: N requests -> 2 POST max (files + folders) - Rewrite batchDownload: N requests -> 1 POST /api/batch/download (ZIP) - Search moved to backend, share system uses backend API - Dark mode fixes, frontend audit improvements
This commit is contained in:
@@ -40,6 +40,10 @@ pub struct FolderDto {
|
||||
/// Parent folder ID
|
||||
pub parent_id: Option<String>,
|
||||
|
||||
/// Owner user ID (scopes visibility per user)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_id: Option<String>,
|
||||
|
||||
/// Creation timestamp
|
||||
pub created_at: u64,
|
||||
|
||||
@@ -59,6 +63,7 @@ impl From<Folder> for FolderDto {
|
||||
name: folder.name().to_string(),
|
||||
path: folder.path_string().to_string(),
|
||||
parent_id: folder.parent_id().map(String::from),
|
||||
owner_id: folder.owner_id().map(String::from),
|
||||
created_at: folder.created_at(),
|
||||
modified_at: folder.modified_at(),
|
||||
is_root,
|
||||
@@ -90,6 +95,7 @@ impl FolderDto {
|
||||
name: "stub-folder".to_string(),
|
||||
path: "/stub/path".to_string(),
|
||||
parent_id: None,
|
||||
owner_id: None,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
is_root: true,
|
||||
|
||||
@@ -56,6 +56,10 @@ pub struct SearchCriteriaDto {
|
||||
/// Offset for pagination
|
||||
#[serde(default)]
|
||||
pub offset: usize,
|
||||
|
||||
/// Sort order for results: "relevance", "name", "name_desc", "date", "date_desc", "size", "size_desc"
|
||||
#[serde(default = "default_sort_by")]
|
||||
pub sort_by: String,
|
||||
}
|
||||
|
||||
/// Default value for recursive search (true)
|
||||
@@ -68,6 +72,11 @@ fn default_limit() -> usize {
|
||||
100
|
||||
}
|
||||
|
||||
/// Default sort_by value
|
||||
fn default_sort_by() -> String {
|
||||
"relevance".to_string()
|
||||
}
|
||||
|
||||
impl Default for SearchCriteriaDto {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -83,23 +92,75 @@ impl Default for SearchCriteriaDto {
|
||||
recursive: default_recursive(),
|
||||
limit: default_limit(),
|
||||
offset: 0,
|
||||
sort_by: default_sort_by(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file search result enriched with server-computed metadata
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchFileResultDto {
|
||||
/// File ID
|
||||
pub id: String,
|
||||
/// File name
|
||||
pub name: String,
|
||||
/// Path to the file (relative)
|
||||
pub path: String,
|
||||
/// Size in bytes
|
||||
pub size: u64,
|
||||
/// MIME type
|
||||
pub mime_type: String,
|
||||
/// Parent folder ID
|
||||
pub folder_id: Option<String>,
|
||||
/// Creation timestamp
|
||||
pub created_at: u64,
|
||||
/// Last modification timestamp
|
||||
pub modified_at: u64,
|
||||
/// Relevance score (0-100) computed server-side
|
||||
pub relevance_score: u32,
|
||||
/// Human-readable file size (e.g., "2.5 MB")
|
||||
pub size_formatted: String,
|
||||
/// CSS icon class for the file type (e.g., "fas fa-file-pdf")
|
||||
pub icon_class: String,
|
||||
/// Content category: "document", "image", "video", "audio", "archive", "code", "other"
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
/// A folder search result enriched with server-computed metadata
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchFolderResultDto {
|
||||
/// Folder ID
|
||||
pub id: String,
|
||||
/// Folder name
|
||||
pub name: String,
|
||||
/// Path to the folder (relative)
|
||||
pub path: String,
|
||||
/// Parent folder ID
|
||||
pub parent_id: Option<String>,
|
||||
/// Creation timestamp
|
||||
pub created_at: u64,
|
||||
/// Last modification timestamp
|
||||
pub modified_at: u64,
|
||||
/// Whether it is a root folder
|
||||
pub is_root: bool,
|
||||
/// Relevance score (0-100) computed server-side
|
||||
pub relevance_score: u32,
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Transfer Object for search results.
|
||||
*
|
||||
* This structure encapsulates the results of a search operation, including
|
||||
* both files and folders that match the search criteria, along with pagination information.
|
||||
* both files and folders that match the search criteria, along with pagination
|
||||
* information and server-computed metadata.
|
||||
*/
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResultsDto {
|
||||
/// Files matching the search criteria
|
||||
pub files: Vec<crate::application::dtos::file_dto::FileDto>,
|
||||
/// Files matching the search criteria (enriched with metadata)
|
||||
pub files: Vec<SearchFileResultDto>,
|
||||
|
||||
/// Folders matching the search criteria
|
||||
pub folders: Vec<crate::application::dtos::folder_dto::FolderDto>,
|
||||
/// Folders matching the search criteria (enriched with metadata)
|
||||
pub folders: Vec<SearchFolderResultDto>,
|
||||
|
||||
/// Total count of matching items (for pagination)
|
||||
pub total_count: Option<usize>,
|
||||
@@ -112,6 +173,12 @@ pub struct SearchResultsDto {
|
||||
|
||||
/// Whether there are more results available
|
||||
pub has_more: bool,
|
||||
|
||||
/// Query execution time in milliseconds (server-side)
|
||||
pub query_time_ms: u64,
|
||||
|
||||
/// Sort order used
|
||||
pub sort_by: String,
|
||||
}
|
||||
|
||||
impl SearchResultsDto {
|
||||
@@ -124,16 +191,20 @@ impl SearchResultsDto {
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
query_time_ms: 0,
|
||||
sort_by: "relevance".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new search results object from files and folders
|
||||
pub fn new(
|
||||
files: Vec<crate::application::dtos::file_dto::FileDto>,
|
||||
folders: Vec<crate::application::dtos::folder_dto::FolderDto>,
|
||||
files: Vec<SearchFileResultDto>,
|
||||
folders: Vec<SearchFolderResultDto>,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
total_count: Option<usize>,
|
||||
query_time_ms: u64,
|
||||
sort_by: String,
|
||||
) -> Self {
|
||||
let has_more = match total_count {
|
||||
Some(total) => (offset + files.len() + folders.len()) < total,
|
||||
@@ -147,6 +218,34 @@ impl SearchResultsDto {
|
||||
limit,
|
||||
offset,
|
||||
has_more,
|
||||
query_time_ms,
|
||||
sort_by,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DTO for search suggestion results (quick prefix search)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchSuggestionsDto {
|
||||
/// Suggested file/folder names matching the query prefix
|
||||
pub suggestions: Vec<SearchSuggestionItem>,
|
||||
/// Query execution time in milliseconds
|
||||
pub query_time_ms: u64,
|
||||
}
|
||||
|
||||
/// Individual search suggestion item
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchSuggestionItem {
|
||||
/// The suggested name
|
||||
pub name: String,
|
||||
/// Type: "file" or "folder"
|
||||
pub item_type: String,
|
||||
/// Item ID for navigation
|
||||
pub id: String,
|
||||
/// Path for context
|
||||
pub path: String,
|
||||
/// CSS icon class
|
||||
pub icon_class: String,
|
||||
/// Relevance score
|
||||
pub relevance_score: u32,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::domain::entities::share::{Share, SharePermissions};
|
||||
pub struct ShareDto {
|
||||
pub id: String,
|
||||
pub item_id: String,
|
||||
pub item_name: Option<String>,
|
||||
pub item_type: String,
|
||||
pub token: String,
|
||||
pub url: String,
|
||||
@@ -27,6 +28,7 @@ pub struct SharePermissionsDto {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateShareDto {
|
||||
pub item_id: String,
|
||||
pub item_name: Option<String>,
|
||||
pub item_type: String,
|
||||
pub password: Option<String>,
|
||||
pub expires_at: Option<u64>,
|
||||
@@ -48,6 +50,7 @@ impl ShareDto {
|
||||
Self {
|
||||
id: share.id().to_string(),
|
||||
item_id: share.item_id().to_string(),
|
||||
item_name: share.item_name().map(|s| s.to_string()),
|
||||
item_type: share.item_type().to_string(),
|
||||
token: share.token().to_string(),
|
||||
url,
|
||||
|
||||
@@ -3,7 +3,9 @@ use async_trait::async_trait;
|
||||
use crate::application::dtos::folder_dto::{
|
||||
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto};
|
||||
use crate::application::dtos::search_dto::{
|
||||
SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto,
|
||||
};
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
/// Primary port for folder operations
|
||||
@@ -21,6 +23,14 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
||||
/// Lists folders within a parent folder
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError>;
|
||||
|
||||
/// Lists folders scoped to a specific owner (for user-facing endpoints).
|
||||
/// At root level, only returns folders belonging to this user.
|
||||
async fn list_folders_for_owner(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: &str,
|
||||
) -> Result<Vec<FolderDto>, DomainError>;
|
||||
|
||||
/// Lists folders with pagination
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
@@ -40,25 +50,24 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary port for file and folder search
|
||||
* Primary port for file and folder search.
|
||||
*
|
||||
* Defines the operations related to advanced search of
|
||||
* files and folders based on various criteria.
|
||||
* All search processing (filtering, scoring, sorting, categorization)
|
||||
* is handled server-side in Rust for maximum efficiency.
|
||||
*/
|
||||
#[async_trait]
|
||||
pub trait SearchUseCase: Send + Sync + 'static {
|
||||
/**
|
||||
* Performs a search based on the specified criteria
|
||||
*
|
||||
* @param criteria Search criteria including text, dates, sizes, etc.
|
||||
* @return Search results containing matching files and folders
|
||||
*/
|
||||
/// Performs a full search based on the specified criteria.
|
||||
async fn search(&self, criteria: SearchCriteriaDto) -> Result<SearchResultsDto, DomainError>;
|
||||
|
||||
/**
|
||||
* Clears the search results cache
|
||||
*
|
||||
* @return Result indicating success or error
|
||||
*/
|
||||
/// Returns quick suggestions for autocomplete (lightweight, fast).
|
||||
async fn suggest(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<SearchSuggestionsDto, DomainError>;
|
||||
|
||||
/// Clears the search results cache.
|
||||
async fn clear_search_cache(&self) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ use tokio::sync::Semaphore;
|
||||
use tracing::info;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::dtos::folder_dto::{FolderDto, MoveFolderDto};
|
||||
use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase};
|
||||
use crate::application::ports::inbound::FolderUseCase;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::application::ports::zip_ports::ZipPort;
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
use crate::common::config::AppConfig;
|
||||
use crate::common::errors::DomainError;
|
||||
@@ -62,6 +64,8 @@ pub struct BatchOperationService {
|
||||
file_retrieval: Arc<dyn FileRetrievalUseCase>,
|
||||
file_management: Arc<dyn FileManagementUseCase>,
|
||||
folder_service: Arc<FolderService>,
|
||||
trash_service: Option<Arc<dyn TrashUseCase>>,
|
||||
zip_service: Option<Arc<dyn ZipPort>>,
|
||||
config: AppConfig,
|
||||
semaphore: Arc<Semaphore>,
|
||||
}
|
||||
@@ -81,6 +85,8 @@ impl BatchOperationService {
|
||||
file_retrieval,
|
||||
file_management,
|
||||
folder_service,
|
||||
trash_service: None,
|
||||
zip_service: None,
|
||||
config,
|
||||
semaphore: Arc::new(Semaphore::new(max_concurrency)),
|
||||
}
|
||||
@@ -100,6 +106,18 @@ impl BatchOperationService {
|
||||
)
|
||||
}
|
||||
|
||||
/// Set the optional trash service (enables batch trash operations)
|
||||
pub fn with_trash_service(mut self, trash_service: Arc<dyn TrashUseCase>) -> Self {
|
||||
self.trash_service = Some(trash_service);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the optional zip service (enables batch download)
|
||||
pub fn with_zip_service(mut self, zip_service: Arc<dyn ZipPort>) -> Self {
|
||||
self.zip_service = Some(zip_service);
|
||||
self
|
||||
}
|
||||
|
||||
/// Copies multiple files in parallel
|
||||
pub async fn copy_files(
|
||||
&self,
|
||||
@@ -459,6 +477,358 @@ impl BatchOperationService {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Moves multiple files to trash in parallel (soft delete)
|
||||
pub async fn trash_files(
|
||||
&self,
|
||||
file_ids: Vec<String>,
|
||||
user_id: &str,
|
||||
) -> Result<BatchResult<String>, BatchOperationError> {
|
||||
let trash_service = self
|
||||
.trash_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| BatchOperationError::Internal("Trash service not available".into()))?;
|
||||
|
||||
info!("Starting batch trash of {} files", file_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
failed: Vec::new(),
|
||||
stats: BatchStats {
|
||||
total: file_ids.len(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let trash = trash_service.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let uid = user_id.to_string();
|
||||
let id_clone = file_id.clone();
|
||||
|
||||
async move {
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
let trash_result = trash.move_to_trash(&file_id, "file", &uid).await;
|
||||
drop(permit);
|
||||
(id_clone.clone(), trash_result.map(|_| id_clone))
|
||||
}
|
||||
});
|
||||
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
for (file_id, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
Ok(id) => {
|
||||
result.successful.push(id);
|
||||
result.stats.successful += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
result.failed.push((file_id, e.to_string()));
|
||||
result.stats.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
info!(
|
||||
"Batch trash files completed: {}/{} successful in {}ms",
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Moves multiple folders to trash in parallel (soft delete)
|
||||
pub async fn trash_folders(
|
||||
&self,
|
||||
folder_ids: Vec<String>,
|
||||
user_id: &str,
|
||||
) -> Result<BatchResult<String>, BatchOperationError> {
|
||||
let trash_service = self
|
||||
.trash_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| BatchOperationError::Internal("Trash service not available".into()))?;
|
||||
|
||||
info!("Starting batch trash of {} folders", folder_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
failed: Vec::new(),
|
||||
stats: BatchStats {
|
||||
total: folder_ids.len(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let operations = folder_ids.into_iter().map(|folder_id| {
|
||||
let trash = trash_service.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let uid = user_id.to_string();
|
||||
let id_clone = folder_id.clone();
|
||||
|
||||
async move {
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
let trash_result = trash.move_to_trash(&folder_id, "folder", &uid).await;
|
||||
drop(permit);
|
||||
(id_clone.clone(), trash_result.map(|_| id_clone))
|
||||
}
|
||||
});
|
||||
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
for (folder_id, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
Ok(id) => {
|
||||
result.successful.push(id);
|
||||
result.stats.successful += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
result.failed.push((folder_id, e.to_string()));
|
||||
result.stats.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
info!(
|
||||
"Batch trash folders completed: {}/{} successful in {}ms",
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Moves multiple folders to a target parent in parallel
|
||||
pub async fn move_folders(
|
||||
&self,
|
||||
folder_ids: Vec<String>,
|
||||
target_folder_id: Option<String>,
|
||||
) -> Result<BatchResult<FolderDto>, BatchOperationError> {
|
||||
info!("Starting batch move of {} folders", folder_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
failed: Vec::new(),
|
||||
stats: BatchStats {
|
||||
total: folder_ids.len(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let operations = folder_ids.into_iter().map(|folder_id| {
|
||||
let folder_service = self.folder_service.clone();
|
||||
let target = target_folder_id.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
async move {
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
let dto = MoveFolderDto { parent_id: target };
|
||||
let move_result = folder_service.move_folder(&folder_id, dto).await;
|
||||
drop(permit);
|
||||
(folder_id, move_result)
|
||||
}
|
||||
});
|
||||
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
for (folder_id, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
Ok(folder) => {
|
||||
result.successful.push(folder);
|
||||
result.stats.successful += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
result.failed.push((folder_id, e.to_string()));
|
||||
result.stats.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
info!(
|
||||
"Batch folder move completed: {}/{} successful in {}ms",
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Downloads multiple files/folders as a single ZIP archive
|
||||
pub async fn download_zip(
|
||||
&self,
|
||||
file_ids: Vec<String>,
|
||||
folder_ids: Vec<String>,
|
||||
) -> Result<Vec<u8>, BatchOperationError> {
|
||||
use std::io::{Cursor, Write};
|
||||
use zip::{ZipWriter, write::SimpleFileOptions};
|
||||
|
||||
let zip_service = self.zip_service.as_ref();
|
||||
|
||||
info!(
|
||||
"Starting batch download: {} files, {} folders",
|
||||
file_ids.len(),
|
||||
folder_ids.len()
|
||||
);
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut zip = ZipWriter::new(buf);
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o644);
|
||||
|
||||
// Add individual files at the root of the ZIP
|
||||
for file_id in &file_ids {
|
||||
match self.file_retrieval.get_file(file_id).await {
|
||||
Ok(file_dto) => {
|
||||
match self.file_retrieval.get_file_content(file_id).await {
|
||||
Ok(content) => {
|
||||
if let Err(e) = zip.start_file(&file_dto.name, options) {
|
||||
info!("Could not start zip entry for {}: {}", file_dto.name, e);
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = zip.write_all(&content) {
|
||||
info!("Could not write zip entry for {}: {}", file_dto.name, e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Could not read file content {}: {}", file_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Could not get file metadata {}: {}", file_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add folders as sub-trees using the existing ZipPort if available
|
||||
// Otherwise fall back to manual folder traversal
|
||||
if let Some(zip_svc) = zip_service {
|
||||
// For each folder, create a separate zip and merge its contents
|
||||
// Actually, we need to build the tree ourselves for a single zip
|
||||
// Use manual approach for consistency within one archive
|
||||
for folder_id in &folder_ids {
|
||||
match self.folder_service.get_folder(folder_id).await {
|
||||
Ok(folder) => {
|
||||
self.add_folder_to_zip(&mut zip, folder_id, &folder.name, &options)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Could not get folder {}: {}", folder_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Suppress unused variable warning
|
||||
let _ = zip_svc;
|
||||
} else {
|
||||
for folder_id in &folder_ids {
|
||||
match self.folder_service.get_folder(folder_id).await {
|
||||
Ok(folder) => {
|
||||
self.add_folder_to_zip(&mut zip, folder_id, &folder.name, &options)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Could not get folder {}: {}", folder_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut zip_buf = zip
|
||||
.finish()
|
||||
.map_err(|e| BatchOperationError::Internal(format!("ZIP finalize error: {}", e)))?;
|
||||
|
||||
use std::io::Read;
|
||||
let mut bytes = Vec::new();
|
||||
zip_buf
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| BatchOperationError::Internal(format!("ZIP read error: {}", e)))?;
|
||||
|
||||
info!(
|
||||
"Batch download ZIP created: {} bytes in {}ms",
|
||||
bytes.len(),
|
||||
start_time.elapsed().as_millis()
|
||||
);
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Recursively add a folder and its contents to a ZipWriter
|
||||
async fn add_folder_to_zip(
|
||||
&self,
|
||||
zip: &mut zip::ZipWriter<std::io::Cursor<Vec<u8>>>,
|
||||
folder_id: &str,
|
||||
path: &str,
|
||||
options: &zip::write::SimpleFileOptions,
|
||||
) {
|
||||
use std::io::Write;
|
||||
|
||||
struct PendingFolder {
|
||||
id: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
let mut queue = vec![PendingFolder {
|
||||
id: folder_id.to_string(),
|
||||
path: path.to_string(),
|
||||
}];
|
||||
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
|
||||
while let Some(current) = queue.pop() {
|
||||
if visited.contains(¤t.id) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(current.id.clone());
|
||||
|
||||
let dir_path = format!("{}/", current.path);
|
||||
let _ = zip.add_directory(&dir_path, *options);
|
||||
|
||||
// Add files
|
||||
if let Ok(files) = self.file_retrieval.list_files(Some(¤t.id)).await {
|
||||
for file in files {
|
||||
let file_path = format!("{}{}", dir_path, file.name);
|
||||
if let Ok(content) = self.file_retrieval.get_file_content(&file.id).await {
|
||||
if zip.start_file(&file_path, *options).is_ok() {
|
||||
let _ = zip.write_all(&content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue subfolders
|
||||
if let Ok(subfolders) = self.folder_service.list_folders(Some(¤t.id)).await {
|
||||
for sub in subfolders {
|
||||
queue.push(PendingFolder {
|
||||
id: sub.id.clone(),
|
||||
path: format!("{}/{}", current.path, sub.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic batch operation for any type of async function
|
||||
pub async fn generic_batch_operation<T, F, Fut>(
|
||||
&self,
|
||||
|
||||
@@ -45,6 +45,14 @@ impl FolderService {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_for_owner(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: &str,
|
||||
) -> Result<Vec<FolderDto>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
@@ -173,6 +181,29 @@ impl FolderUseCase for FolderService {
|
||||
Ok(folders.into_iter().map(FolderDto::from).collect())
|
||||
}
|
||||
|
||||
/// Lists folders scoped to a specific owner.
|
||||
async fn list_folders_for_owner(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: &str,
|
||||
) -> Result<Vec<FolderDto>, DomainError> {
|
||||
let folders = self
|
||||
.folder_storage
|
||||
.list_folders_by_owner(parent_id, owner_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!(
|
||||
"Failed to list folders for owner '{}' in parent {:?}: {}",
|
||||
owner_id, parent_id, e
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(folders.into_iter().map(FolderDto::from).collect())
|
||||
}
|
||||
|
||||
/// Lists folders with pagination
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
|
||||
@@ -7,19 +7,30 @@ use tokio::time;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto};
|
||||
use crate::application::dtos::search_dto::{
|
||||
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto,
|
||||
SearchSuggestionItem, SearchSuggestionsDto,
|
||||
};
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::application::ports::outbound::FolderStoragePort;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::common::errors::Result;
|
||||
|
||||
/**
|
||||
* Search service implementation for files and folders.
|
||||
* High-performance search service implementation for files and folders.
|
||||
*
|
||||
* This service implements the advanced search functionality that allows
|
||||
* users to find files and folders based on various criteria
|
||||
* such as name, type, date and size. It also includes a cache to improve
|
||||
* the performance of repeated searches.
|
||||
* All search processing (filtering, scoring, sorting, categorization,
|
||||
* formatting) is performed server-side in Rust for maximum efficiency.
|
||||
* The frontend acts as a thin rendering client only.
|
||||
*
|
||||
* Features:
|
||||
* - Parallel recursive folder traversal using tokio tasks
|
||||
* - Relevance scoring (exact match > starts-with > contains)
|
||||
* - Content categorization and icon mapping
|
||||
* - Multiple sort options (relevance, name, date, size)
|
||||
* - Server-side formatted file sizes
|
||||
* - Quick suggestions endpoint for autocomplete
|
||||
* - TTL-based result caching
|
||||
*/
|
||||
pub struct SearchService {
|
||||
/// Repository for file operations
|
||||
@@ -57,14 +68,149 @@ struct CachedSearchResult {
|
||||
timestamp: Instant,
|
||||
}
|
||||
|
||||
// ─── Utility functions (pure, no self — computed on the server) ─────────
|
||||
|
||||
/// Compute relevance score (0–100) for a name against a query.
|
||||
/// Exact match = 100, starts-with = 80, contains = 50, no match = 0.
|
||||
fn compute_relevance(name: &str, query: &str) -> u32 {
|
||||
let name_lower = name.to_lowercase();
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
if name_lower == query_lower {
|
||||
100
|
||||
} else if name_lower.starts_with(&query_lower) {
|
||||
80
|
||||
} else if name_lower.contains(&query_lower) {
|
||||
// Bonus for shorter names (more specific match)
|
||||
let ratio = query_lower.len() as f64 / name_lower.len() as f64;
|
||||
50 + (ratio * 20.0) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Format bytes into a human-readable string (e.g. "2.5 MB").
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
|
||||
if bytes == 0 {
|
||||
return "0 B".to_string();
|
||||
}
|
||||
let exp = (bytes as f64).log(1024.0).floor() as usize;
|
||||
let exp = exp.min(UNITS.len() - 1);
|
||||
let value = bytes as f64 / 1024_f64.powi(exp as i32);
|
||||
if exp == 0 {
|
||||
format!("{} B", bytes)
|
||||
} else {
|
||||
format!("{:.1} {}", value, UNITS[exp])
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine content category from MIME type.
|
||||
fn categorize_mime(mime: &str) -> &'static str {
|
||||
let m = mime.to_lowercase();
|
||||
if m.starts_with("image/") {
|
||||
"image"
|
||||
} else if m.starts_with("video/") {
|
||||
"video"
|
||||
} else if m.starts_with("audio/") {
|
||||
"audio"
|
||||
} else if m.starts_with("text/")
|
||||
|| m.contains("pdf")
|
||||
|| m.contains("document")
|
||||
|| m.contains("spreadsheet")
|
||||
|| m.contains("presentation")
|
||||
|| m.contains("msword")
|
||||
|| m.contains("officedocument")
|
||||
{
|
||||
"document"
|
||||
} else if m.contains("zip")
|
||||
|| m.contains("tar")
|
||||
|| m.contains("gzip")
|
||||
|| m.contains("bzip")
|
||||
|| m.contains("7z")
|
||||
|| m.contains("rar")
|
||||
|| m.contains("compress")
|
||||
{
|
||||
"archive"
|
||||
} else if m.contains("json")
|
||||
|| m.contains("xml")
|
||||
|| m.contains("javascript")
|
||||
|| m.contains("typescript")
|
||||
|| m.contains("x-python")
|
||||
|| m.contains("x-rust")
|
||||
|| m.contains("x-c")
|
||||
|| m.contains("x-java")
|
||||
|| m.contains("x-shellscript")
|
||||
|| m.contains("x-httpd-php")
|
||||
|| m.contains("yaml")
|
||||
|| m.contains("toml")
|
||||
{
|
||||
"code"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Font Awesome icon class for a file based on extension and MIME type.
|
||||
fn get_icon_class(name: &str, mime: &str) -> String {
|
||||
// Try extension first
|
||||
if let Some(ext) = name.rsplit('.').next() {
|
||||
let ext_lower = ext.to_lowercase();
|
||||
let icon = match ext_lower.as_str() {
|
||||
// Documents
|
||||
"pdf" => "fas fa-file-pdf",
|
||||
"doc" | "docx" => "fas fa-file-word",
|
||||
"xls" | "xlsx" => "fas fa-file-excel",
|
||||
"ppt" | "pptx" => "fas fa-file-powerpoint",
|
||||
"txt" | "rtf" | "md" => "fas fa-file-alt",
|
||||
"csv" => "fas fa-file-csv",
|
||||
// Images
|
||||
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" => {
|
||||
"fas fa-file-image"
|
||||
}
|
||||
// Video
|
||||
"mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => {
|
||||
"fas fa-file-video"
|
||||
}
|
||||
// Audio
|
||||
"mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" => "fas fa-file-audio",
|
||||
// Archives
|
||||
"zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" => "fas fa-file-archive",
|
||||
// Code
|
||||
"js" | "ts" | "jsx" | "tsx" | "py" | "rs" | "go" | "java" | "c" | "cpp" | "cs"
|
||||
| "rb" | "php" | "swift" | "kt" | "scala" | "r" | "lua" | "pl" | "sh" | "bash"
|
||||
| "zsh" | "fish" | "ps1" | "bat" | "cmd" => "fas fa-file-code",
|
||||
"html" | "htm" | "css" | "scss" | "sass" | "less" => "fas fa-file-code",
|
||||
"json" | "xml" | "yaml" | "yml" | "toml" | "ini" | "cfg" | "conf" => {
|
||||
"fas fa-file-code"
|
||||
}
|
||||
"sql" => "fas fa-database",
|
||||
_ => "",
|
||||
};
|
||||
if !icon.is_empty() {
|
||||
return icon.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to MIME type
|
||||
let category = categorize_mime(mime);
|
||||
match category {
|
||||
"image" => "fas fa-file-image",
|
||||
"video" => "fas fa-file-video",
|
||||
"audio" => "fas fa-file-audio",
|
||||
"document" => "fas fa-file-alt",
|
||||
"archive" => "fas fa-file-archive",
|
||||
"code" => "fas fa-file-code",
|
||||
_ => "fas fa-file",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ─── SearchService implementation ───────────────────────────────────────
|
||||
|
||||
impl SearchService {
|
||||
/**
|
||||
* Creates a new instance of the search service.
|
||||
*
|
||||
* @param file_repository Repository for file operations
|
||||
* @param folder_repository Repository for folder operations
|
||||
* @param cache_ttl Cache time-to-live in seconds (0 to disable)
|
||||
* @param max_cache_size Maximum cache size
|
||||
*/
|
||||
pub fn new(
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
@@ -88,12 +234,7 @@ impl SearchService {
|
||||
search_service
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an asynchronous task to clean up expired cache entries.
|
||||
*
|
||||
* @param cache_ref Reference to the shared cache
|
||||
* @param ttl_seconds TTL in seconds
|
||||
*/
|
||||
/// Starts an asynchronous task to clean up expired cache entries.
|
||||
fn start_cache_cleanup_task(
|
||||
cache_ref: Arc<Mutex<HashMap<SearchCacheKey, CachedSearchResult>>>,
|
||||
ttl_seconds: u64,
|
||||
@@ -105,18 +246,13 @@ impl SearchService {
|
||||
loop {
|
||||
time::sleep(cleanup_interval).await;
|
||||
|
||||
// Acquire lock and clean up expired entries
|
||||
if let Ok(mut cache) = cache_ref.lock() {
|
||||
let now = Instant::now();
|
||||
|
||||
// Identify expired entries
|
||||
let expired_keys: Vec<SearchCacheKey> = cache
|
||||
.iter()
|
||||
.filter(|(_, result)| now.duration_since(result.timestamp) > ttl)
|
||||
.map(|(key, _)| key.clone())
|
||||
.collect();
|
||||
|
||||
// Remove expired entries
|
||||
for key in expired_keys {
|
||||
cache.remove(&key);
|
||||
}
|
||||
@@ -125,31 +261,17 @@ impl SearchService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cache key from the search criteria.
|
||||
*
|
||||
* @param criteria Search criteria
|
||||
* @param user_id User ID (to isolate cache between users)
|
||||
* @return Cache key
|
||||
*/
|
||||
/// Creates a cache key from the search criteria.
|
||||
fn create_cache_key(&self, criteria: &SearchCriteriaDto, user_id: &str) -> SearchCacheKey {
|
||||
// Serialize criteria to generate a hash
|
||||
let criteria_str = serde_json::to_string(criteria).unwrap_or_default();
|
||||
|
||||
SearchCacheKey {
|
||||
criteria_hash: criteria_str,
|
||||
user_id: user_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to retrieve results from the cache.
|
||||
*
|
||||
* @param key Cache key
|
||||
* @return Optionally, the results if they exist and have not expired
|
||||
*/
|
||||
/// Attempts to retrieve results from the cache.
|
||||
fn get_from_cache(&self, key: &SearchCacheKey) -> Option<SearchResultsDto> {
|
||||
// If TTL is 0, the cache is disabled
|
||||
if self.cache_ttl == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -159,8 +281,6 @@ impl SearchService {
|
||||
{
|
||||
let now = Instant::now();
|
||||
let ttl = Duration::from_secs(self.cache_ttl);
|
||||
|
||||
// Check if the entry has expired
|
||||
if now.duration_since(cached_result.timestamp) < ttl {
|
||||
return Some(cached_result.results.clone());
|
||||
}
|
||||
@@ -169,20 +289,13 @@ impl SearchService {
|
||||
None
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores results in the cache.
|
||||
*
|
||||
* @param key Cache key
|
||||
* @param results Results to store
|
||||
*/
|
||||
/// Stores results in the cache.
|
||||
fn store_in_cache(&self, key: SearchCacheKey, results: SearchResultsDto) {
|
||||
// If TTL is 0, the cache is disabled
|
||||
if self.cache_ttl == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(mut cache) = self.search_cache.lock() {
|
||||
// If the cache is full, remove the oldest entry
|
||||
if cache.len() >= self.max_cache_size
|
||||
&& let Some((oldest_key, _)) =
|
||||
cache.iter().min_by_key(|(_, result)| result.timestamp)
|
||||
@@ -191,7 +304,6 @@ impl SearchService {
|
||||
cache.remove(&key_to_remove);
|
||||
}
|
||||
|
||||
// Store the new result
|
||||
cache.insert(
|
||||
key,
|
||||
CachedSearchResult {
|
||||
@@ -202,268 +314,407 @@ impl SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters files according to the search criteria.
|
||||
*
|
||||
* @param files List of files to filter
|
||||
* @param criteria Search criteria
|
||||
* @return Files that match the criteria
|
||||
*/
|
||||
fn filter_files(&self, files: Vec<FileDto>, criteria: &SearchCriteriaDto) -> Vec<FileDto> {
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|file| {
|
||||
// Filter by name
|
||||
if let Some(name_query) = &criteria.name_contains
|
||||
&& !file
|
||||
.name
|
||||
.to_lowercase()
|
||||
.contains(&name_query.to_lowercase())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
/// Enrich a FileDto → SearchFileResultDto with server-computed metadata.
|
||||
fn enrich_file(file: &FileDto, query: &str) -> SearchFileResultDto {
|
||||
let relevance = if query.is_empty() {
|
||||
50
|
||||
} else {
|
||||
compute_relevance(&file.name, query)
|
||||
};
|
||||
|
||||
// Filter by file type (extension)
|
||||
if let Some(file_types) = &criteria.file_types {
|
||||
if let Some(extension) = file.name.split('.').next_back() {
|
||||
if !file_types
|
||||
.iter()
|
||||
.any(|ext| ext.eq_ignore_ascii_case(extension))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Has no extension
|
||||
return false;
|
||||
}
|
||||
}
|
||||
SearchFileResultDto {
|
||||
id: file.id.clone(),
|
||||
name: file.name.clone(),
|
||||
path: file.path.clone(),
|
||||
size: file.size,
|
||||
mime_type: file.mime_type.clone(),
|
||||
folder_id: file.folder_id.clone(),
|
||||
created_at: file.created_at,
|
||||
modified_at: file.modified_at,
|
||||
relevance_score: relevance,
|
||||
size_formatted: format_bytes(file.size),
|
||||
icon_class: get_icon_class(&file.name, &file.mime_type),
|
||||
category: categorize_mime(&file.mime_type).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by creation date
|
||||
if let Some(created_after) = criteria.created_after
|
||||
&& file.created_at < created_after
|
||||
{
|
||||
return false;
|
||||
}
|
||||
/// Enrich a FolderDto → SearchFolderResultDto with server-computed metadata.
|
||||
fn enrich_folder(folder: &FolderDto, query: &str) -> SearchFolderResultDto {
|
||||
let relevance = if query.is_empty() {
|
||||
50
|
||||
} else {
|
||||
compute_relevance(&folder.name, query)
|
||||
};
|
||||
|
||||
if let Some(created_before) = criteria.created_before
|
||||
&& file.created_at > created_before
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by modification date
|
||||
if let Some(modified_after) = criteria.modified_after
|
||||
&& file.modified_at < modified_after
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(modified_before) = criteria.modified_before
|
||||
&& file.modified_at > modified_before
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by size
|
||||
if let Some(min_size) = criteria.min_size
|
||||
&& file.size < min_size
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(max_size) = criteria.max_size
|
||||
&& file.size > max_size
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect()
|
||||
SearchFolderResultDto {
|
||||
id: folder.id.clone(),
|
||||
name: folder.name.clone(),
|
||||
path: folder.path.clone(),
|
||||
parent_id: folder.parent_id.clone(),
|
||||
created_at: folder.created_at,
|
||||
modified_at: folder.modified_at,
|
||||
is_root: folder.is_root,
|
||||
relevance_score: relevance,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters folders according to the search criteria.
|
||||
* Parallel recursive search through folders using tokio tasks.
|
||||
*
|
||||
* @param folders List of folders to filter
|
||||
* @param criteria Search criteria
|
||||
* @return Folders that match the criteria
|
||||
* Instead of searching subfolders sequentially, we spawn a task
|
||||
* per subfolder and join them all concurrently.
|
||||
*/
|
||||
fn filter_folders(
|
||||
&self,
|
||||
folders: Vec<FolderDto>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
) -> Vec<FolderDto> {
|
||||
folders
|
||||
.into_iter()
|
||||
.filter(|folder| {
|
||||
// Filter by name
|
||||
if let Some(name_query) = &criteria.name_contains
|
||||
&& !folder
|
||||
.name
|
||||
.to_lowercase()
|
||||
.contains(&name_query.to_lowercase())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by creation date
|
||||
if let Some(created_after) = criteria.created_after
|
||||
&& folder.created_at < created_after
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(created_before) = criteria.created_before
|
||||
&& folder.created_at > created_before
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by modification date
|
||||
if let Some(modified_after) = criteria.modified_after
|
||||
&& folder.modified_at < modified_after
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(modified_before) = criteria.modified_before
|
||||
&& folder.modified_at > modified_before
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of recursive search through folders.
|
||||
*
|
||||
* @param current_folder_id ID of the current folder
|
||||
* @param criteria Search criteria
|
||||
* @param found_files Files found so far
|
||||
* @param found_folders Folders found so far
|
||||
*/
|
||||
async fn search_recursive(
|
||||
&self,
|
||||
current_folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
found_files: &mut Vec<FileDto>,
|
||||
found_folders: &mut Vec<FolderDto>,
|
||||
) -> Result<()> {
|
||||
fn search_parallel(
|
||||
file_repo: Arc<dyn FileReadPort>,
|
||||
folder_repo: Arc<dyn FolderStoragePort>,
|
||||
current_folder_id: Option<String>,
|
||||
criteria: Arc<SearchCriteriaDto>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(Vec<FileDto>, Vec<FolderDto>)>> + Send>> {
|
||||
Box::pin(async move {
|
||||
// List files in the current folder
|
||||
let files = self.file_repository.list_files(current_folder_id).await?;
|
||||
// List files in the current folder
|
||||
let files = file_repo
|
||||
.list_files(current_folder_id.as_deref())
|
||||
.await?;
|
||||
|
||||
// Filter files according to criteria and add them to the results
|
||||
let filtered_files =
|
||||
self.filter_files(files.into_iter().map(FileDto::from).collect(), criteria);
|
||||
found_files.extend(filtered_files);
|
||||
let filtered_files: Vec<FileDto> = files
|
||||
.into_iter()
|
||||
.map(FileDto::from)
|
||||
.filter(|file| passes_file_filter(file, &criteria))
|
||||
.collect();
|
||||
|
||||
// If the search is recursive, process subfolders
|
||||
if criteria.recursive {
|
||||
// List subfolders
|
||||
let folders = self
|
||||
.folder_repository
|
||||
.list_folders(current_folder_id)
|
||||
.await?;
|
||||
let mut all_files = filtered_files;
|
||||
let mut all_folders: Vec<FolderDto> = Vec::new();
|
||||
|
||||
// Filter folders according to criteria and add them to the results
|
||||
let filtered_folders: Vec<FolderDto> = self
|
||||
.filter_folders(folders.into_iter().map(FolderDto::from).collect(), criteria);
|
||||
// If recursive, process subfolders in parallel
|
||||
if criteria.recursive {
|
||||
let folders = folder_repo
|
||||
.list_folders(current_folder_id.as_deref())
|
||||
.await?;
|
||||
|
||||
// Add filtered folders to the results
|
||||
found_folders.extend(filtered_folders.iter().cloned());
|
||||
let folder_dtos: Vec<FolderDto> = folders
|
||||
.into_iter()
|
||||
.map(FolderDto::from)
|
||||
.filter(|f| passes_folder_filter(f, &criteria))
|
||||
.collect();
|
||||
|
||||
// Search recursively in each subfolder
|
||||
for folder in filtered_folders {
|
||||
self.search_recursive(Some(&folder.id), criteria, found_files, found_folders)
|
||||
.await?;
|
||||
}
|
||||
all_folders.extend(folder_dtos.iter().cloned());
|
||||
|
||||
// Spawn parallel tasks for each subfolder
|
||||
let mut handles = Vec::with_capacity(folder_dtos.len());
|
||||
for subfolder in &folder_dtos {
|
||||
let fr = file_repo.clone();
|
||||
let fdr = folder_repo.clone();
|
||||
let crit = criteria.clone();
|
||||
let folder_id = subfolder.id.clone();
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
Self::search_parallel(fr, fdr, Some(folder_id), crit).await
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
// Collect results from all parallel tasks
|
||||
for handle in handles {
|
||||
match handle.await {
|
||||
Ok(Ok((sub_files, sub_folders))) => {
|
||||
all_files.extend(sub_files);
|
||||
all_folders.extend(sub_folders);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("Parallel search subtask error: {}", e);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Parallel search task join error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((all_files, all_folders))
|
||||
}) // end Box::pin
|
||||
}
|
||||
|
||||
/// Quick suggestions search — returns up to `limit` name suggestions
|
||||
/// matching the query prefix. Uses cache-friendly shallow search.
|
||||
pub async fn suggest(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
let start = Instant::now();
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let mut suggestions: Vec<SearchSuggestionItem> = Vec::new();
|
||||
|
||||
// List files in the folder
|
||||
let files = self.file_repository.list_files(folder_id).await?;
|
||||
for file in files {
|
||||
let file_dto = FileDto::from(file);
|
||||
if file_dto.name.to_lowercase().contains(&query_lower) {
|
||||
let score = compute_relevance(&file_dto.name, query);
|
||||
suggestions.push(SearchSuggestionItem {
|
||||
name: file_dto.name.clone(),
|
||||
item_type: "file".to_string(),
|
||||
id: file_dto.id.clone(),
|
||||
path: file_dto.path.clone(),
|
||||
icon_class: get_icon_class(&file_dto.name, &file_dto.mime_type),
|
||||
relevance_score: score,
|
||||
});
|
||||
}
|
||||
if suggestions.len() >= limit * 2 {
|
||||
break; // Collect enough candidates
|
||||
}
|
||||
}
|
||||
|
||||
// List folders
|
||||
let folders = self.folder_repository.list_folders(folder_id).await?;
|
||||
for folder in folders {
|
||||
let folder_dto = FolderDto::from(folder);
|
||||
if folder_dto.name.to_lowercase().contains(&query_lower) {
|
||||
let score = compute_relevance(&folder_dto.name, query);
|
||||
suggestions.push(SearchSuggestionItem {
|
||||
name: folder_dto.name.clone(),
|
||||
item_type: "folder".to_string(),
|
||||
id: folder_dto.id.clone(),
|
||||
path: folder_dto.path.clone(),
|
||||
icon_class: "fas fa-folder".to_string(),
|
||||
relevance_score: score,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by relevance and truncate
|
||||
suggestions.sort_by(|a, b| b.relevance_score.cmp(&a.relevance_score));
|
||||
suggestions.truncate(limit);
|
||||
|
||||
let elapsed = start.elapsed().as_millis() as u64;
|
||||
Ok(SearchSuggestionsDto {
|
||||
suggestions,
|
||||
query_time_ms: elapsed,
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Standalone filter functions for use in parallel tasks ──────────────
|
||||
|
||||
/// Check if a file passes all filter criteria (standalone, no &self needed).
|
||||
fn passes_file_filter(file: &FileDto, criteria: &SearchCriteriaDto) -> bool {
|
||||
if let Some(name_query) = &criteria.name_contains
|
||||
&& !file
|
||||
.name
|
||||
.to_lowercase()
|
||||
.contains(&name_query.to_lowercase())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(file_types) = &criteria.file_types {
|
||||
if let Some(extension) = file.name.split('.').next_back() {
|
||||
if !file_types
|
||||
.iter()
|
||||
.any(|ext| ext.eq_ignore_ascii_case(extension))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.created_after {
|
||||
if file.created_at < v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.created_before {
|
||||
if file.created_at > v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.modified_after {
|
||||
if file.modified_at < v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.modified_before {
|
||||
if file.modified_at > v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.min_size {
|
||||
if file.size < v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.max_size {
|
||||
if file.size > v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if a folder passes all filter criteria (standalone).
|
||||
fn passes_folder_filter(folder: &FolderDto, criteria: &SearchCriteriaDto) -> bool {
|
||||
if let Some(name_query) = &criteria.name_contains
|
||||
&& !folder
|
||||
.name
|
||||
.to_lowercase()
|
||||
.contains(&name_query.to_lowercase())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(v) = criteria.created_after {
|
||||
if folder.created_at < v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.created_before {
|
||||
if folder.created_at > v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.modified_after {
|
||||
if folder.modified_at < v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.modified_before {
|
||||
if folder.modified_at > v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ─── SearchUseCase trait implementation ──────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl SearchUseCase for SearchService {
|
||||
/**
|
||||
* Performs a search based on the specified criteria.
|
||||
*
|
||||
* @param criteria Search criteria
|
||||
* @return Search results
|
||||
* All processing happens server-side:
|
||||
* - Parallel recursive traversal
|
||||
* - Filtering by name, type, dates, size
|
||||
* - Relevance scoring
|
||||
* - Sorting (relevance, name, date, size)
|
||||
* - Content categorization & icon mapping
|
||||
* - Human-readable size formatting
|
||||
* - Pagination
|
||||
*/
|
||||
async fn search(&self, criteria: SearchCriteriaDto) -> Result<SearchResultsDto> {
|
||||
let start = Instant::now();
|
||||
|
||||
// TODO: Get user ID from the authentication context
|
||||
let user_id = "default-user";
|
||||
let cache_key = self.create_cache_key(&criteria, user_id);
|
||||
|
||||
// Try to get results from the cache
|
||||
// Try cache
|
||||
if let Some(cached_results) = self.get_from_cache(&cache_key) {
|
||||
return Ok(cached_results);
|
||||
}
|
||||
|
||||
// Initialize collections for results
|
||||
let mut found_files: Vec<FileDto> = Vec::new();
|
||||
let mut found_folders: Vec<FolderDto> = Vec::new();
|
||||
|
||||
// Perform search in the specified folder or at the root
|
||||
self.search_recursive(
|
||||
criteria.folder_id.as_deref(),
|
||||
&criteria,
|
||||
&mut found_files,
|
||||
&mut found_folders,
|
||||
// ── Parallel recursive search ──
|
||||
let criteria_arc = Arc::new(criteria.clone());
|
||||
let (found_files, found_folders) = Self::search_parallel(
|
||||
self.file_repository.clone(),
|
||||
self.folder_repository.clone(),
|
||||
criteria.folder_id.clone(),
|
||||
criteria_arc,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Apply pagination
|
||||
let total_count = found_files.len() + found_folders.len();
|
||||
let query = criteria.name_contains.as_deref().unwrap_or("");
|
||||
|
||||
// Sort by relevance or date according to criteria
|
||||
// By default, sort by modification date (most recent first)
|
||||
found_files.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
found_folders.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
// ── Enrich results with server-computed metadata ──
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = found_files
|
||||
.iter()
|
||||
.map(|f| Self::enrich_file(f, query))
|
||||
.collect();
|
||||
|
||||
// Apply limit and offset for pagination
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = found_folders
|
||||
.iter()
|
||||
.map(|f| Self::enrich_folder(f, query))
|
||||
.collect();
|
||||
|
||||
// ── Sort based on criteria.sort_by ──
|
||||
match criteria.sort_by.as_str() {
|
||||
"name" => {
|
||||
enriched_files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
enriched_folders
|
||||
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
"name_desc" => {
|
||||
enriched_files.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase()));
|
||||
enriched_folders
|
||||
.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase()));
|
||||
}
|
||||
"date" => {
|
||||
enriched_files.sort_by(|a, b| a.modified_at.cmp(&b.modified_at));
|
||||
enriched_folders.sort_by(|a, b| a.modified_at.cmp(&b.modified_at));
|
||||
}
|
||||
"date_desc" => {
|
||||
enriched_files.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
enriched_folders.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
}
|
||||
"size" => {
|
||||
enriched_files.sort_by(|a, b| a.size.cmp(&b.size));
|
||||
}
|
||||
"size_desc" => {
|
||||
enriched_files.sort_by(|a, b| b.size.cmp(&a.size));
|
||||
}
|
||||
_ => {
|
||||
// "relevance" (default) — highest relevance first, tie-break by date desc
|
||||
enriched_files.sort_by(|a, b| {
|
||||
b.relevance_score
|
||||
.cmp(&a.relevance_score)
|
||||
.then_with(|| b.modified_at.cmp(&a.modified_at))
|
||||
});
|
||||
enriched_folders.sort_by(|a, b| {
|
||||
b.relevance_score
|
||||
.cmp(&a.relevance_score)
|
||||
.then_with(|| b.modified_at.cmp(&a.modified_at))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pagination ──
|
||||
let total_count = enriched_files.len() + enriched_folders.len();
|
||||
let start_idx = criteria.offset.min(total_count);
|
||||
let end_idx = (criteria.offset + criteria.limit).min(total_count);
|
||||
|
||||
let paginated_items: Vec<(bool, usize)> = (start_idx..end_idx)
|
||||
.map(|i| {
|
||||
if i < found_folders.len() {
|
||||
(true, i) // It's a folder
|
||||
if i < enriched_folders.len() {
|
||||
(true, i) // folder
|
||||
} else {
|
||||
(false, i - found_folders.len()) // It's a file
|
||||
(false, i - enriched_folders.len()) // file
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Extract paginated items
|
||||
let mut paginated_folders = Vec::new();
|
||||
let mut paginated_files = Vec::new();
|
||||
|
||||
for (is_folder, idx) in paginated_items {
|
||||
if is_folder {
|
||||
if idx < found_folders.len() {
|
||||
paginated_folders.push(found_folders[idx].clone());
|
||||
if idx < enriched_folders.len() {
|
||||
paginated_folders.push(enriched_folders[idx].clone());
|
||||
}
|
||||
} else if idx < found_files.len() {
|
||||
paginated_files.push(found_files[idx].clone());
|
||||
} else if idx < enriched_files.len() {
|
||||
paginated_files.push(enriched_files[idx].clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Create results object
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
let search_results = SearchResultsDto::new(
|
||||
paginated_files,
|
||||
paginated_folders,
|
||||
criteria.limit,
|
||||
criteria.offset,
|
||||
Some(total_count),
|
||||
elapsed_ms,
|
||||
criteria.sort_by.clone(),
|
||||
);
|
||||
|
||||
// Store in cache
|
||||
@@ -472,11 +723,17 @@ impl SearchUseCase for SearchService {
|
||||
Ok(search_results)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the search results cache.
|
||||
*
|
||||
* @return Result indicating success
|
||||
*/
|
||||
/// Returns quick suggestions for autocomplete.
|
||||
async fn suggest(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
self.suggest(query, folder_id, limit).await
|
||||
}
|
||||
|
||||
/// Clears the search results cache.
|
||||
async fn clear_search_cache(&self) -> Result<()> {
|
||||
if let Ok(mut cache) = self.search_cache.lock() {
|
||||
cache.clear();
|
||||
@@ -485,7 +742,8 @@ impl SearchUseCase for SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
// Implement the test use case (stub)
|
||||
// ── Stub for testing ────────────────────────────────────────────────────
|
||||
|
||||
impl SearchService {
|
||||
/// Creates a stub version of the service for testing
|
||||
pub fn new_stub() -> impl SearchUseCase {
|
||||
@@ -497,6 +755,18 @@ impl SearchService {
|
||||
Ok(SearchResultsDto::empty())
|
||||
}
|
||||
|
||||
async fn suggest(
|
||||
&self,
|
||||
_query: &str,
|
||||
_folder_id: Option<&str>,
|
||||
_limit: usize,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
Ok(SearchSuggestionsDto {
|
||||
suggestions: Vec::new(),
|
||||
query_time_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn clear_search_cache(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ impl ShareUseCase for ShareService {
|
||||
// Create the Share entity
|
||||
let share = Share::new(
|
||||
dto.item_id.clone(),
|
||||
dto.item_name.clone(),
|
||||
item_type,
|
||||
user_id.to_string(),
|
||||
permissions,
|
||||
@@ -524,6 +525,14 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: &str,
|
||||
) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
|
||||
@@ -355,6 +355,14 @@ impl FolderRepository for MockFolderRepository {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: &str,
|
||||
) -> std::result::Result<Vec<Folder>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
|
||||
Reference in New Issue
Block a user