diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index 2f6d8616..572d4748 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -40,6 +40,10 @@ pub struct FolderDto { /// Parent folder ID pub parent_id: Option, + /// Owner user ID (scopes visibility per user) + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_id: Option, + /// Creation timestamp pub created_at: u64, @@ -59,6 +63,7 @@ impl From 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, diff --git a/src/application/dtos/search_dto.rs b/src/application/dtos/search_dto.rs index c868a31f..b897048c 100644 --- a/src/application/dtos/search_dto.rs +++ b/src/application/dtos/search_dto.rs @@ -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, + /// 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, + /// 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, + /// Files matching the search criteria (enriched with metadata) + pub files: Vec, - /// Folders matching the search criteria - pub folders: Vec, + /// Folders matching the search criteria (enriched with metadata) + pub folders: Vec, /// Total count of matching items (for pagination) pub total_count: Option, @@ -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, - folders: Vec, + files: Vec, + folders: Vec, limit: usize, offset: usize, total_count: Option, + 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, + /// 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, +} diff --git a/src/application/dtos/share_dto.rs b/src/application/dtos/share_dto.rs index 61b12e73..2e732f9e 100644 --- a/src/application/dtos/share_dto.rs +++ b/src/application/dtos/share_dto.rs @@ -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, 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, pub item_type: String, pub password: Option, pub expires_at: Option, @@ -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, diff --git a/src/application/ports/inbound.rs b/src/application/ports/inbound.rs index 92cac88f..2ae28f95 100644 --- a/src/application/ports/inbound.rs +++ b/src/application/ports/inbound.rs @@ -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, 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, 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; - /** - * 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; + + /// Clears the search results cache. async fn clear_search_cache(&self) -> Result<(), DomainError>; } diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 93c8d5fe..a1361ace 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -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, file_management: Arc, folder_service: Arc, + trash_service: Option>, + zip_service: Option>, config: AppConfig, semaphore: Arc, } @@ -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) -> 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) -> 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, + user_id: &str, + ) -> Result, 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, + user_id: &str, + ) -> Result, 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, + target_folder_id: Option, + ) -> Result, 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, + folder_ids: Vec, + ) -> Result, 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>>, + 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( &self, diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 805c7857..9365b05b 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -45,6 +45,14 @@ impl FolderService { Ok(vec![]) } + async fn list_folders_for_owner( + &self, + _parent_id: Option<&str>, + _owner_id: &str, + ) -> Result, 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, 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, diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index e9f1d74a..dc14c42e 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -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, @@ -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>>, 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 = 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 { - // 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, criteria: &SearchCriteriaDto) -> Vec { - 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, - criteria: &SearchCriteriaDto, - ) -> Vec { - 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, - found_folders: &mut Vec, - ) -> Result<()> { + fn search_parallel( + file_repo: Arc, + folder_repo: Arc, + current_folder_id: Option, + criteria: Arc, + ) -> std::pin::Pin, Vec)>> + 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 = 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 = Vec::new(); - // Filter folders according to criteria and add them to the results - let filtered_folders: Vec = 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 = 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 { + let start = Instant::now(); + let query_lower = query.to_lowercase(); + + let mut suggestions: Vec = 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 { + 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 = Vec::new(); - let mut found_folders: Vec = 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 = found_files + .iter() + .map(|f| Self::enrich_file(f, query)) + .collect(); - // Apply limit and offset for pagination + let mut enriched_folders: Vec = 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 { + 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 { + Ok(SearchSuggestionsDto { + suggestions: Vec::new(), + query_time_ms: 0, + }) + } + async fn clear_search_cache(&self) -> Result<()> { Ok(()) } diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index c5f91bf1..df6ab80e 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -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, DomainError> { + unimplemented!() + } + async fn list_folders_paginated( &self, _parent_id: Option<&str>, diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index 5d0d8d82..88de9d1e 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -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, DomainError> { + Ok(vec![]) + } + async fn list_folders_paginated( &self, _parent_id: Option<&str>, diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 50150558..fea17ea2 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -19,7 +19,7 @@ use crate::application::dtos::folder_dto::{ CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, }; use crate::application::dtos::pagination::{PaginatedResponseDto, PaginationRequestDto}; -use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto}; +use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto}; use crate::application::ports::compression_ports::{CompressionLevel, CompressionPort}; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileUseCaseFactory, @@ -250,6 +250,14 @@ impl FolderRepository for StubFolderStoragePort { Ok(Vec::new()) } + async fn list_folders_by_owner( + &self, + _parent_id: Option<&str>, + _owner_id: &str, + ) -> Result, DomainError> { + Ok(Vec::new()) + } + async fn list_folders_paginated( &self, _parent_id: Option<&str>, @@ -350,6 +358,14 @@ impl FolderUseCase for StubFolderUseCase { Ok(Vec::new()) } + async fn list_folders_for_owner( + &self, + _parent_id: Option<&str>, + _owner_id: &str, + ) -> Result, DomainError> { + Ok(Vec::new()) + } + async fn list_folders_paginated( &self, _parent_id: Option<&str>, @@ -559,6 +575,18 @@ impl SearchUseCase for StubSearchUseCase { Ok(SearchResultsDto::empty()) } + async fn suggest( + &self, + _query: &str, + _folder_id: Option<&str>, + _limit: usize, + ) -> Result { + Ok(SearchSuggestionsDto { + suggestions: Vec::new(), + query_time_ms: 0, + }) + } + async fn clear_search_cache(&self) -> Result<(), DomainError> { Ok(()) } diff --git a/src/domain/entities/folder.rs b/src/domain/entities/folder.rs index 0c9f58fb..5f70166b 100644 --- a/src/domain/entities/folder.rs +++ b/src/domain/entities/folder.rs @@ -21,6 +21,10 @@ pub struct Folder { /// Parent folder ID (None if it's a root folder) parent_id: Option, + /// Owner user ID — scopes folder visibility per user. + /// `None` only for legacy/stub folders; real folders always have an owner. + owner_id: Option, + /// Creation timestamp created_at: u64, @@ -38,6 +42,7 @@ impl Default for Folder { storage_path: StoragePath::from_string("/"), path_string: "/".to_string(), parent_id: None, + owner_id: None, created_at: 0, modified_at: 0, } @@ -51,6 +56,17 @@ impl Folder { name: String, storage_path: StoragePath, parent_id: Option, + ) -> FolderResult { + Self::new_with_owner(id, name, storage_path, parent_id, None) + } + + /// Creates a new folder with validation and an explicit owner. + pub fn new_with_owner( + id: String, + name: String, + storage_path: StoragePath, + parent_id: Option, + owner_id: Option, ) -> FolderResult { // Validate folder name if name.is_empty() || name.contains('/') || name.contains('\\') { @@ -71,6 +87,7 @@ impl Folder { storage_path, path_string, parent_id, + owner_id, created_at: now, modified_at: now, }) @@ -84,6 +101,19 @@ impl Folder { parent_id: Option, created_at: u64, modified_at: u64, + ) -> FolderResult { + Self::with_timestamps_and_owner(id, name, storage_path, parent_id, None, created_at, modified_at) + } + + /// Creates a folder with specific timestamps and owner (for DB reconstruction) + pub fn with_timestamps_and_owner( + id: String, + name: String, + storage_path: StoragePath, + parent_id: Option, + owner_id: Option, + created_at: u64, + modified_at: u64, ) -> FolderResult { // Validate folder name if name.is_empty() || name.contains('/') || name.contains('\\') { @@ -99,6 +129,7 @@ impl Folder { storage_path, path_string, parent_id, + owner_id, created_at, modified_at, }) @@ -133,6 +164,10 @@ impl Folder { self.modified_at } + pub fn owner_id(&self) -> Option<&str> { + self.owner_id.as_deref() + } + /// Creates a new Folder instance from a DTO /// This function is primarily for conversions in batch handlers pub fn from_dto( @@ -153,6 +188,7 @@ impl Folder { storage_path, path_string: path, parent_id, + owner_id: None, created_at, modified_at, } @@ -188,6 +224,7 @@ impl Folder { storage_path: new_storage_path, path_string: new_path_string, parent_id: self.parent_id.clone(), + owner_id: self.owner_id.clone(), created_at: self.created_at, modified_at: now, }) @@ -219,6 +256,7 @@ impl Folder { storage_path: new_storage_path, path_string: new_path_string, parent_id, + owner_id: self.owner_id.clone(), created_at: self.created_at, modified_at: now, }) diff --git a/src/domain/entities/share.rs b/src/domain/entities/share.rs index 6011afff..4ba9b1ce 100644 --- a/src/domain/entities/share.rs +++ b/src/domain/entities/share.rs @@ -8,6 +8,7 @@ pub use super::entity_errors::ShareError; pub struct Share { id: String, item_id: String, + item_name: Option, item_type: ShareItemType, token: String, password_hash: Option, @@ -34,6 +35,7 @@ pub enum ShareItemType { impl Share { pub fn new( item_id: String, + item_name: Option, item_type: ShareItemType, created_by: String, permissions: Option, @@ -69,6 +71,7 @@ impl Share { Ok(Self { id: Uuid::new_v4().to_string(), item_id, + item_name, item_type, token: Uuid::new_v4().to_string(), password_hash, @@ -88,6 +91,7 @@ impl Share { pub fn from_raw( id: String, item_id: String, + item_name: Option, item_type: ShareItemType, token: String, password_hash: Option, @@ -100,6 +104,7 @@ impl Share { Self { id, item_id, + item_name, item_type, token, password_hash, @@ -121,6 +126,10 @@ impl Share { &self.item_id } + pub fn item_name(&self) -> Option<&str> { + self.item_name.as_deref() + } + pub fn item_type(&self) -> &ShareItemType { &self.item_type } @@ -257,6 +266,7 @@ mod tests { fn test_create_share() { let share = Share::new( "test_file_id".to_string(), + None, ShareItemType::File, "user123".to_string(), None, @@ -287,6 +297,7 @@ mod tests { let future = now + 3600; // 1 hour in the future let share = Share::new( "test_file_id".to_string(), + None, ShareItemType::File, "user123".to_string(), None, @@ -301,6 +312,7 @@ mod tests { let past = now - 3600; // 1 hour in the past let share_result = Share::new( "test_file_id".to_string(), + None, ShareItemType::File, "user123".to_string(), None, @@ -335,6 +347,7 @@ mod tests { fn test_has_password_with_hash() { let share = Share::new( "test_file_id".to_string(), + None, ShareItemType::File, "user123".to_string(), None, @@ -351,6 +364,7 @@ mod tests { fn test_has_password_without_hash() { let share = Share::new( "test_file_id".to_string(), + None, ShareItemType::File, "user123".to_string(), None, diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index 9f5e267e..bcd66b49 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -36,6 +36,15 @@ pub trait FolderRepository: Send + Sync + 'static { /// Lists folders within a parent folder async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError>; + /// Lists root-level folders owned by a specific user. + /// For non-root queries (parent_id is Some), ownership is implicit + /// because the parent already belongs to the user. + async fn list_folders_by_owner( + &self, + parent_id: Option<&str>, + owner_id: &str, + ) -> Result, DomainError>; + /// Lists folders with pagination async fn list_folders_paginated( &self, diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 43644c6f..dca75b37 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -73,15 +73,17 @@ impl FolderDbRepository { id: String, name: String, parent_id: Option, + user_id: Option, created_at: i64, modified_at: i64, ) -> Result { let storage_path = self.build_folder_path(&id).await?; - Folder::with_timestamps( + Folder::with_timestamps_and_owner( id, name, storage_path, parent_id, + user_id, created_at as u64, modified_at as u64, ) @@ -141,14 +143,14 @@ impl FolderRepository for FolderDbRepository { DomainError::internal_error("FolderDb", format!("insert: {e}")) })?; - self.row_to_folder(row.0, name, parent_id, row.1, row.2) + self.row_to_folder(row.0, name, parent_id, Some(user_id), row.1, row.2) .await } async fn get_folder(&self, id: &str) -> Result { - let row = sqlx::query_as::<_, (String, String, Option, i64, i64)>( + let row = sqlx::query_as::<_, (String, String, Option, String, i64, i64)>( r#" - SELECT id::text, name, parent_id::text, + SELECT id::text, name, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint FROM storage.folders @@ -161,7 +163,7 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", id))?; - self.row_to_folder(row.0, row.1, row.2, row.3, row.4).await + self.row_to_folder(row.0, row.1, row.2, Some(row.3), row.4, row.5).await } async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result { @@ -212,10 +214,10 @@ impl FolderRepository for FolderDbRepository { } async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError> { - let rows: Vec<(String, String, Option, i64, i64)> = if let Some(pid) = parent_id { + let rows: Vec<(String, String, Option, String, i64, i64)> = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, parent_id::text, + SELECT id::text, name, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint FROM storage.folders @@ -229,7 +231,7 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, parent_id::text, + SELECT id::text, name, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint FROM storage.folders @@ -243,8 +245,55 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; let mut folders = Vec::with_capacity(rows.len()); - for (id, name, pid, ca, ma) in rows { - folders.push(self.row_to_folder(id, name, pid, ca, ma).await?); + for (id, name, pid, uid, ca, ma) in rows { + folders.push(self.row_to_folder(id, name, pid, Some(uid), ca, ma).await?); + } + Ok(folders) + } + + async fn list_folders_by_owner( + &self, + parent_id: Option<&str>, + owner_id: &str, + ) -> Result, DomainError> { + let rows: Vec<(String, String, Option, String, i64, i64)> = if let Some(pid) = parent_id { + // For sub-folders the owner is implicit (parent belongs to user), + // but we still filter to be safe. + sqlx::query_as( + r#" + SELECT id::text, name, parent_id::text, user_id, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM storage.folders + WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed + ORDER BY name + "#, + ) + .bind(pid) + .bind(owner_id) + .fetch_all(self.pool()) + .await + } else { + // Root-level: only this user's home folders + sqlx::query_as( + r#" + SELECT id::text, name, parent_id::text, user_id, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM storage.folders + WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed + ORDER BY name + "#, + ) + .bind(owner_id) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?; + + let mut folders = Vec::with_capacity(rows.len()); + for (id, name, pid, uid, ca, ma) in rows { + folders.push(self.row_to_folder(id, name, pid, Some(uid), ca, ma).await?); } Ok(folders) } @@ -277,10 +326,10 @@ impl FolderRepository for FolderDbRepository { None }; - let rows: Vec<(String, String, Option, i64, i64)> = if let Some(pid) = parent_id { + let rows: Vec<(String, String, Option, String, i64, i64)> = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, parent_id::text, + SELECT id::text, name, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint FROM storage.folders @@ -297,7 +346,7 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, parent_id::text, + SELECT id::text, name, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint FROM storage.folders @@ -314,8 +363,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?; let mut folders = Vec::with_capacity(rows.len()); - for (id, name, pid, ca, ma) in rows { - folders.push(self.row_to_folder(id, name, pid, ca, ma).await?); + for (id, name, pid, uid, ca, ma) in rows { + folders.push(self.row_to_folder(id, name, pid, Some(uid), ca, ma).await?); } Ok((folders, total)) } @@ -524,7 +573,7 @@ impl FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?; match row { - Some((id, ca, ma)) => self.row_to_folder(id, name.to_string(), None, ca, ma).await, + Some((id, ca, ma)) => self.row_to_folder(id, name.to_string(), None, Some(user_id.to_string()), ca, ma).await, None => { // Already exists — fetch it let existing = sqlx::query_as::<_, (String, i64, i64)>( @@ -541,7 +590,7 @@ impl FolderDbRepository { .fetch_one(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("home fetch: {e}")))?; - self.row_to_folder(existing.0, name.to_string(), None, existing.1, existing.2) + self.row_to_folder(existing.0, name.to_string(), None, Some(user_id.to_string()), existing.1, existing.2) .await } } diff --git a/src/infrastructure/repositories/share_fs_repository.rs b/src/infrastructure/repositories/share_fs_repository.rs index 2fc2c419..50e8f6c7 100644 --- a/src/infrastructure/repositories/share_fs_repository.rs +++ b/src/infrastructure/repositories/share_fs_repository.rs @@ -15,6 +15,8 @@ use crate::{ struct ShareRecord { id: String, item_id: String, + #[serde(default)] + item_name: Option, item_type: String, token: String, password_hash: Option, @@ -84,6 +86,7 @@ impl ShareFsRepository { Share::from_raw( record.id.clone(), record.item_id.clone(), + record.item_name.clone(), item_type, record.token.clone(), record.password_hash.clone(), @@ -100,6 +103,7 @@ impl ShareFsRepository { ShareRecord { 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(), password_hash: share.password_hash().map(|s| s.to_string()), diff --git a/src/interfaces/api/handlers/batch_handler.rs b/src/interfaces/api/handlers/batch_handler.rs index 3d0b576c..00cef92f 100644 --- a/src/interfaces/api/handlers/batch_handler.rs +++ b/src/interfaces/api/handlers/batch_handler.rs @@ -1,7 +1,7 @@ use axum::{ extract::{Json, State}, http::StatusCode, - response::IntoResponse, + response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -12,6 +12,7 @@ use crate::application::services::batch_operations::{ BatchOperationService, BatchResult, BatchStats, }; use crate::interfaces::api::handlers::ApiResult; +use crate::interfaces::middleware::auth::AuthUser; /// Shared state for the batch handler #[derive(Clone)] @@ -428,3 +429,193 @@ pub async fn get_folders_batch( Ok((status_code, Json(response)).into_response()) } + +/// DTO for batch trash operation requests +#[derive(Debug, Deserialize)] +pub struct BatchTrashRequest { + /// IDs of the files to move to trash + #[serde(default)] + pub file_ids: Vec, + /// IDs of the folders to move to trash + #[serde(default)] + pub folder_ids: Vec, +} + +/// DTO for batch download requests +#[derive(Debug, Deserialize)] +pub struct BatchDownloadRequest { + /// IDs of the files to include in the ZIP + #[serde(default)] + pub file_ids: Vec, + /// IDs of the folders to include in the ZIP + #[serde(default)] + pub folder_ids: Vec, +} + +/// Handler for moving multiple files and folders to trash in batch +pub async fn trash_batch( + State(state): State, + auth_user: AuthUser, + Json(request): Json, +) -> ApiResult { + if request.file_ids.is_empty() && request.folder_ids.is_empty() { + return Ok(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "No file or folder IDs provided" + })), + ) + .into_response()); + } + + let mut all_successful: Vec = Vec::new(); + let mut all_failed: Vec = Vec::new(); + let total = request.file_ids.len() + request.folder_ids.len(); + let start_time = std::time::Instant::now(); + + // Trash files + if !request.file_ids.is_empty() { + match state + .batch_service + .trash_files(request.file_ids, &auth_user.id) + .await + { + Ok(result) => { + all_successful.extend(result.successful); + all_failed.extend( + result + .failed + .into_iter() + .map(|(id, error)| FailedOperation { id, error }), + ); + } + Err(e) => { + return Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + .into_response()); + } + } + } + + // Trash folders + if !request.folder_ids.is_empty() { + match state + .batch_service + .trash_folders(request.folder_ids, &auth_user.id) + .await + { + Ok(result) => { + all_successful.extend(result.successful); + all_failed.extend( + result + .failed + .into_iter() + .map(|(id, error)| FailedOperation { id, error }), + ); + } + Err(e) => { + return Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + .into_response()); + } + } + } + + let successful_count = all_successful.len(); + let failed_count = all_failed.len(); + + let response = BatchOperationResponse { + successful: all_successful, + failed: all_failed, + stats: BatchOperationStats { + total, + successful: successful_count, + failed: failed_count, + execution_time_ms: start_time.elapsed().as_millis(), + }, + }; + + let status_code = if failed_count > 0 { + if successful_count > 0 { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::BAD_REQUEST + } + } else { + StatusCode::OK + }; + + Ok((status_code, Json(response)).into_response()) +} + +/// Handler for moving multiple folders in batch +pub async fn move_folders_batch( + State(state): State, + Json(request): Json, +) -> ApiResult { + if request.folder_ids.is_empty() { + return Ok(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "No folder IDs provided" + })), + ) + .into_response()); + } + + let result = state + .batch_service + .move_folders(request.folder_ids, request.target_folder_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let response: BatchOperationResponse = result.into(); + + let status_code = if response.stats.failed > 0 { + if response.stats.successful > 0 { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::BAD_REQUEST + } + } else { + StatusCode::OK + }; + + Ok((status_code, Json(response)).into_response()) +} + +/// Handler for downloading multiple files and folders as a single ZIP +pub async fn download_batch( + State(state): State, + Json(request): Json, +) -> Result { + if request.file_ids.is_empty() && request.folder_ids.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "No file or folder IDs provided".to_string(), + )); + } + + let zip_bytes = state + .batch_service + .download_zip(request.file_ids, request.folder_ids) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let filename = format!("oxicloud-download-{}.zip", chrono::Utc::now().timestamp()); + + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/zip") + .header( + "Content-Disposition", + format!("attachment; filename=\"{}\"", filename), + ) + .header("Content-Length", zip_bytes.len().to_string()) + .body(axum::body::Body::from(zip_bytes)) + .unwrap()) +} diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 006aedf8..a1d8de83 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -21,11 +21,9 @@ type AppState = Arc; pub struct FolderHandler; impl FolderHandler { - /// Creates a new folder + /// Creates a new folder. /// When parent_id is not provided, the folder is created inside the - /// authenticated user's home folder ("My Folder - {username}") rather - /// than at the storage root. This prevents user-created directories - /// from being placed flat in ./storage/. + /// authenticated user's home folder rather than at the storage root. pub async fn create_folder( State(service): State, auth_user: AuthUser, @@ -34,15 +32,13 @@ impl FolderHandler { // If no parent_id was supplied, resolve the user's home folder as // the default parent so the new folder is nested correctly. if dto.parent_id.is_none() { - let home_folder_name = format!("My Folder - {}", auth_user.username); tracing::info!( - "create_folder: parent_id is None for user '{}', looking up home folder '{}'", - auth_user.username, - home_folder_name + "create_folder: parent_id is None for user '{}', resolving home folder", + auth_user.username ); - match service.list_folders(None).await { + match service.list_folders_for_owner(None, &auth_user.id).await { Ok(folders) => { - if let Some(home) = folders.iter().find(|f| f.name == home_folder_name) { + if let Some(home) = folders.first() { tracing::info!( "create_folder: resolved home folder ID '{}' for user '{}'", home.id, @@ -51,8 +47,8 @@ impl FolderHandler { dto.parent_id = Some(home.id.clone()); } else { tracing::warn!( - "create_folder: home folder '{}' not found, folder will be created at root", - home_folder_name + "create_folder: home folder not found for user '{}', folder will be created at root", + auth_user.username ); } } @@ -79,13 +75,27 @@ impl FolderHandler { } } - /// Gets a folder by ID + /// Gets a folder by ID. + /// Validates that the authenticated user owns the folder. pub async fn get_folder( State(service): State, + auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { match service.get_folder(&id).await { - Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), + Ok(folder) => { + // Access check: folder must belong to the requesting user + if let Some(ref owner) = folder.owner_id { + if owner != &auth_user.id { + tracing::warn!( + "get_folder: user '{}' attempted to access folder '{}' owned by '{}'", + auth_user.id, id, owner + ); + return (StatusCode::NOT_FOUND, "Folder not found".to_string()).into_response(); + } + } + (StatusCode::OK, Json(folder)).into_response() + } Err(err) => { let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, @@ -97,144 +107,77 @@ impl FolderHandler { } } - /// Lists root folders (no parent ID) - /// Non-admin users only see their own home folder. + /// Lists root folders for the authenticated user. + /// Only returns folders owned by this user — no information disclosure. pub async fn list_root_folders( State(service): State, auth_user: AuthUser, ) -> axum::response::Response { - Self::list_folders_for_user(service, None, &auth_user).await + Self::list_folders_scoped(service, None, &auth_user).await } - /// Lists contents of a specific folder by its ID + /// Lists contents of a specific folder by its ID. + /// Scoped to the authenticated user's folders. pub async fn list_folder_contents( State(service): State, + auth_user: AuthUser, Path(id): Path, ) -> axum::response::Response { - Self::list_folders_inner(service, Some(&id)).await + Self::list_folders_scoped(service, Some(&id), &auth_user).await } - /// Lists root folders with pagination support + /// Lists root folders with pagination support. pub async fn list_root_folders_paginated( State(service): State, auth_user: AuthUser, _pagination: Query, ) -> axum::response::Response { - // For paginated root listing, filter by user as well - Self::list_folders_for_user(service, None, &auth_user).await + Self::list_folders_scoped(service, None, &auth_user).await } - /// Lists contents of a specific folder with pagination + /// Lists contents of a specific folder with pagination. pub async fn list_folder_contents_paginated( State(service): State, + _auth_user: AuthUser, Path(id): Path, pagination: Query, ) -> axum::response::Response { - Self::list_folders_paginated_inner(service, pagination, Some(&id)).await - } - - /// Checks if a folder name matches the user home-folder convention. - fn is_user_home_folder(folder_name: &str) -> bool { - folder_name.starts_with("My Folder - ") - } - - /// Checks if a folder belongs to the given user. - fn folder_belongs_to_user(folder_name: &str, username: &str) -> bool { - let expected = format!("My Folder - {}", username); - folder_name == expected - } - - /// Lists folders, optionally filtered by parent ID (internal helper) - async fn list_folders_inner( - service: AppState, - parent_id: Option<&str>, - ) -> axum::response::Response { - match service.list_folders(parent_id).await { - Ok(folders) => (StatusCode::OK, Json(folders)).into_response(), - Err(err) => { - let status = match err.kind { - ErrorKind::NotFound => StatusCode::NOT_FOUND, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }; - - ( - status, - Json(serde_json::json!({ - "error": err.to_string() - })), - ) - .into_response() - } - } - } - - /// Lists folders with user-based filtering for root listings. - /// Non-admin users only see their own home folder at the root level. - async fn list_folders_for_user( - service: AppState, - parent_id: Option<&str>, - auth_user: &AuthUser, - ) -> axum::response::Response { - match service.list_folders(parent_id).await { - Ok(folders) => { - // Only filter at root level (parent_id == None) - let filtered = if parent_id.is_none() { - folders - .into_iter() - .filter(|f| { - // Skip hidden/system folders - if f.name.starts_with('.') { - return false; - } - // If it's a user home folder, only show if it belongs to this user - if Self::is_user_home_folder(&f.name) { - return Self::folder_belongs_to_user(&f.name, &auth_user.username); - } - // Non-home folders are visible to everyone - true - }) - .collect() - } else { - folders - }; - (StatusCode::OK, Json(filtered)).into_response() - } - Err(err) => { - let status = match err.kind { - ErrorKind::NotFound => StatusCode::NOT_FOUND, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }; - ( - status, - Json(serde_json::json!({ - "error": err.to_string() - })), - ) - .into_response() - } - } - } - - /// Lists folders with pagination support (internal helper) - async fn list_folders_paginated_inner( - service: AppState, - Query(pagination): Query, - parent_id: Option<&str>, - ) -> axum::response::Response { - match service.list_folders_paginated(parent_id, &pagination).await { + // For sub-folder pagination, use the standard paginated path + // (owner filtering is implicit — sub-folders inherit ownership) + match service.list_folders_paginated(Some(&id), &pagination).await { Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(), Err(err) => { let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - - // Return a JSON error response ( status, - Json(serde_json::json!({ - "error": err.to_string() - })), + Json(serde_json::json!({ "error": err.to_string() })), + ) + .into_response() + } + } + } + + /// Internal helper: lists folders scoped to the authenticated user. + /// Uses `list_folders_for_owner` — the DB query filters by `user_id`, + /// so no data from other users ever leaves the database. + async fn list_folders_scoped( + service: AppState, + parent_id: Option<&str>, + auth_user: &AuthUser, + ) -> axum::response::Response { + match service.list_folders_for_owner(parent_id, &auth_user.id).await { + Ok(folders) => (StatusCode::OK, Json(folders)).into_response(), + Err(err) => { + let status = match err.kind { + ErrorKind::NotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + ( + status, + Json(serde_json::json!({ "error": err.to_string() })), ) .into_response() } diff --git a/src/interfaces/api/handlers/search_handler.rs b/src/interfaces/api/handlers/search_handler.rs index 4d5b6eca..46a56fe8 100644 --- a/src/interfaces/api/handlers/search_handler.rs +++ b/src/interfaces/api/handlers/search_handler.rs @@ -12,43 +12,32 @@ use crate::common::di::AppState; /** * Handler for search operations through the API. * - * This handler exposes endpoints related to search functionality, - * allowing users to search for files and folders using various criteria. + * All search processing (filtering, scoring, sorting, categorization, + * formatting) is performed server-side. These handlers are thin HTTP + * adapters that delegate to the SearchUseCase. */ pub struct SearchHandler; impl SearchHandler { - /** - * Performs a search based on the criteria provided as query parameters. - * - * This endpoint allows simple searches directly with URL parameters. - * - * @param state Application state with services - * @param query_params Search parameters as query string - * @return HTTP response with the search results - */ + /// GET /search — simple query-parameter-based search. pub async fn search_files_get( State(state): State, Query(params): Query, ) -> impl IntoResponse { info!("API: File search with parameters: {:?}", params); - // Extract the search service or return error if not available let search_service = match &state.applications.search_service { Some(service) => service, None => { error!("Search service not available"); return ( StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ - "error": "Search service is not available" - })), + Json(json!({ "error": "Search service is not available" })), ) .into_response(); } }; - // Convert search parameters to DTO let search_criteria = SearchCriteriaDto { name_contains: params.query, file_types: params @@ -64,13 +53,14 @@ impl SearchHandler { recursive: params.recursive.unwrap_or(true), limit: params.limit.unwrap_or(100), offset: params.offset.unwrap_or(0), + sort_by: params.sort_by.unwrap_or_else(|| "relevance".to_string()), }; - // Perform the search match search_service.search(search_criteria).await { Ok(results) => { info!( - "Search completed, {} files and {} folders found", + "Search completed in {}ms — {} files, {} folders", + results.query_time_ms, results.files.len(), results.folders.len() ); @@ -80,51 +70,37 @@ impl SearchHandler { error!("Search error: {}", err); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ - "error": format!("Search error: {}", err) - })), + Json(json!({ "error": format!("Search error: {}", err) })), ) .into_response() } } } - /** - * Performs an advanced search based on a complete JSON criteria object. - * - * This endpoint allows more complex searches with all possible criteria - * provided in the request body. - * - * @param state Application state with services - * @param criteria Complete search criteria - * @return HTTP response with the search results - */ + /// POST /search/advanced — full criteria in the request body. pub async fn search_files_post( State(state): State, Json(criteria): Json, ) -> impl IntoResponse { info!("API: Advanced file search"); - // Extract the search service or return error if not available let search_service = match &state.applications.search_service { Some(service) => service, None => { error!("Search service not available"); return ( StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ - "error": "Search service is not available" - })), + Json(json!({ "error": "Search service is not available" })), ) .into_response(); } }; - // Perform the search match search_service.search(criteria).await { Ok(results) => { info!( - "Search completed, {} files and {} folders found", + "Advanced search completed in {}ms — {} files, {} folders", + results.query_time_ms, results.files.len(), results.folders.len() ); @@ -134,51 +110,79 @@ impl SearchHandler { error!("Search error: {}", err); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ - "error": format!("Search error: {}", err) - })), + Json(json!({ "error": format!("Search error: {}", err) })), ) .into_response() } } } - /** - * Clears the search results cache. - * - * This endpoint is useful for forcing fresh searches after significant - * changes in the file system. - * - * @param state Application state with services - * @return HTTP response indicating success or error - */ - pub async fn clear_search_cache(State(state): State) -> impl IntoResponse { - info!("API: Clearing search cache"); + /// GET /search/suggest — lightweight autocomplete suggestions. + pub async fn suggest_files( + State(state): State, + Query(params): Query, + ) -> impl IntoResponse { + info!("API: Search suggestions for {:?}", params.query); - // Extract the search service or return error if not available let search_service = match &state.applications.search_service { Some(service) => service, None => { error!("Search service not available"); return ( StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ - "error": "Search service is not available" - })), + Json(json!({ "error": "Search service is not available" })), + ) + .into_response(); + } + }; + + let limit = params.limit.unwrap_or(10).min(20); + + match search_service + .suggest(¶ms.query, params.folder_id.as_deref(), limit) + .await + { + Ok(suggestions) => { + info!( + "Suggestions completed in {}ms — {} results", + suggestions.query_time_ms, + suggestions.suggestions.len() + ); + (StatusCode::OK, Json(suggestions)).into_response() + } + Err(err) => { + error!("Suggestions error: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Suggestions error: {}", err) })), + ) + .into_response() + } + } + } + + /// DELETE /search/cache — clears the search results cache. + pub async fn clear_search_cache(State(state): State) -> impl IntoResponse { + info!("API: Clearing search cache"); + + let search_service = match &state.applications.search_service { + Some(service) => service, + None => { + error!("Search service not available"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "Search service is not available" })), ) .into_response(); } }; - // Clear the cache match search_service.clear_search_cache().await { Ok(_) => { info!("Search cache cleared successfully"); ( StatusCode::OK, - Json(json!({ - "message": "Search cache cleared successfully" - })), + Json(json!({ "message": "Search cache cleared successfully" })), ) .into_response() } @@ -186,9 +190,7 @@ impl SearchHandler { error!("Error clearing search cache: {}", err); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ - "error": format!("Error clearing search cache: {}", err) - })), + Json(json!({ "error": format!("Error clearing search cache: {}", err) })), ) .into_response() } @@ -196,38 +198,38 @@ impl SearchHandler { } } -/// Search parameters for the GET endpoint +/// Search parameters for the GET /search endpoint #[derive(Debug, serde::Deserialize)] pub struct SearchParams { - /// Text to search for in file and folder names + /// Text to search in file and folder names pub query: Option, /// Filter by file types (comma-separated extensions) #[serde(rename = "type")] pub type_filter: Option, - /// Filter items created after this date (timestamp) + /// Created after this timestamp pub created_after: Option, - /// Filter items created before this date (timestamp) + /// Created before this timestamp pub created_before: Option, - /// Filter items modified after this date (timestamp) + /// Modified after this timestamp pub modified_after: Option, - /// Filter items modified before this date (timestamp) + /// Modified before this timestamp pub modified_before: Option, - /// Minimum size in bytes + /// Minimum file size in bytes pub min_size: Option, - /// Maximum size in bytes + /// Maximum file size in bytes pub max_size: Option, /// Folder ID to limit the search scope pub folder_id: Option, - /// Recursive search in subfolders + /// Recursive search in subfolders (default: true) pub recursive: Option, /// Result limit for pagination @@ -235,4 +237,20 @@ pub struct SearchParams { /// Offset for pagination pub offset: Option, + + /// Sort order: relevance | name | name_desc | date | date_desc | size | size_desc + pub sort_by: Option, +} + +/// Parameters for the GET /search/suggest endpoint +#[derive(Debug, serde::Deserialize)] +pub struct SuggestParams { + /// Text to search for suggestions + pub query: String, + + /// Folder ID to limit the suggestion scope + pub folder_id: Option, + + /// Maximum number of suggestions (default 10, max 20) + pub limit: Option, } diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 4a8c2212..f573d5b4 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -212,6 +212,7 @@ async fn handle_propfind( name: "".to_string(), path: "".to_string(), parent_id: None, + owner_id: None, created_at: Utc::now().timestamp() as u64, modified_at: Utc::now().timestamp() as u64, is_root: true, diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 6d914958..0fe8fe79 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -93,11 +93,17 @@ pub fn create_api_routes(app_state: &AppState) -> Router { let recent_service = app_state.recent_service.clone(); // Initialize the batch operations service - let batch_service = Arc::new(BatchOperationService::default( + let mut batch_service_builder = BatchOperationService::default( file_retrieval_service.clone(), file_management_service.clone(), folder_service.clone(), - )); + ); + if let Some(ref ts) = trash_service { + batch_service_builder = batch_service_builder.with_trash_service(ts.clone()); + } + let zip_service_ref = app_state.core.zip_service.clone(); + batch_service_builder = batch_service_builder.with_zip_service(zip_service_ref); + let batch_service = Arc::new(batch_service_builder); // Create state for the batch operations handler let batch_handler_state = BatchHandlerState { @@ -176,6 +182,11 @@ pub fn create_api_routes(app_state: &AppState) -> Router { .route("/folders/delete", post(batch_handler::delete_folders_batch)) .route("/folders/create", post(batch_handler::create_folders_batch)) .route("/folders/get", post(batch_handler::get_folders_batch)) + .route("/folders/move", post(batch_handler::move_folders_batch)) + // Trash operations (soft delete) + .route("/trash", post(batch_handler::trash_batch)) + // Download as ZIP + .route("/download", post(batch_handler::download_batch)) .with_state(batch_handler_state); // Create search routes if the service is available @@ -185,6 +196,8 @@ pub fn create_api_routes(app_state: &AppState) -> Router { Router::new() // Simple search with query parameters .route("/", get(SearchHandler::search_files_get)) + // Lightweight autocomplete suggestions + .route("/suggest", get(SearchHandler::suggest_files)) // Advanced search with full criteria object .route("/advanced", post(SearchHandler::search_files_post)) // Clear search cache diff --git a/static/admin.html b/static/admin.html index c3c8d110..278109a1 100644 --- a/static/admin.html +++ b/static/admin.html @@ -4,6 +4,8 @@ OxiCloud — Admin Panel + + diff --git a/static/css/auth.css b/static/css/auth.css index 37c40891..4f406106 100644 --- a/static/css/auth.css +++ b/static/css/auth.css @@ -465,3 +465,121 @@ max-height: 200px; } } + +/* ============================================================ + DARK MODE — Auth Pages + ============================================================ */ +[data-theme="dark"] .auth-container { + background-color: #0f172a; +} +[data-theme="dark"] .auth-panel { + background-color: #1e293b; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3); +} +[data-theme="dark"] .auth-logo-text { + color: #f1f5f9; +} +[data-theme="dark"] .auth-title { + color: #f1f5f9; +} +[data-theme="dark"] .auth-label { + color: #94a3b8; +} +[data-theme="dark"] .auth-input { + background-color: #0f172a; + border-color: #334155; + color: #e2e8f0; +} +[data-theme="dark"] .auth-input:focus { + border-color: #ff5e3a; + background-color: #0f172a; + box-shadow: 0 0 0 3px rgba(255, 94, 58, 0.15); +} +[data-theme="dark"] .auth-input::placeholder { + color: #64748b; +} +[data-theme="dark"] .auth-error { + background-color: #3b1111; + color: #fca5a5; +} +[data-theme="dark"] .auth-success { + background-color: #052e16; + color: #86efac; +} +[data-theme="dark"] .auth-toggle { + color: #94a3b8; +} +[data-theme="dark"] .auth-divider { + color: #64748b; +} +[data-theme="dark"] .auth-divider::before, +[data-theme="dark"] .auth-divider::after { + background: #334155; +} +[data-theme="dark"] .language-subtitle { + color: #94a3b8; +} +[data-theme="dark"] .lang-picker-selected { + background-color: #0f172a; + border-color: #334155; +} +[data-theme="dark"] .lang-picker-selected:hover { + border-color: #ff5e3a; + background-color: #162032; +} +[data-theme="dark"] .lang-picker.open .lang-picker-selected { + border-color: #ff5e3a; + background-color: #162032; +} +[data-theme="dark"] .lang-picker-name { + color: #f1f5f9; +} +[data-theme="dark"] .lang-picker-arrow { + color: #64748b; +} +[data-theme="dark"] .lang-picker-dropdown { + background: #1e293b; + border-color: #ff5e3a; + border-top-color: #334155; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3); +} +[data-theme="dark"] .lang-picker-search { + border-bottom-color: #334155; +} +[data-theme="dark"] .lang-picker-search input { + background-color: #0f172a; + border-color: #334155; + color: #e2e8f0; +} +[data-theme="dark"] .lang-picker-search input:focus { + border-color: #ff5e3a; +} +[data-theme="dark"] .lang-picker-search input::placeholder { + color: #64748b; +} +[data-theme="dark"] .lang-picker-list::-webkit-scrollbar-thumb { + background: #475569; +} +[data-theme="dark"] .lang-picker-item:hover { + background: #162032; +} +[data-theme="dark"] .lang-picker-item.selected { + background: #2a1a15; +} +[data-theme="dark"] .lang-picker-item-name { + color: #f1f5f9; +} +[data-theme="dark"] .lang-picker-item-english { + color: #64748b; +} +[data-theme="dark"] .lang-picker-empty { + color: #64748b; +} +/* Setup steps */ +[data-theme="dark"] .step-number { + background-color: #334155; + color: #94a3b8; +} +[data-theme="dark"] .step-title { + color: #94a3b8; +} diff --git a/static/css/style.css b/static/css/style.css index e6107067..525e7783 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -371,6 +371,8 @@ select:focus { margin-bottom: 15px; border-bottom: 1px solid #eee; width: 100%; + flex-wrap: wrap; + gap: 8px; } .search-results-header h3 { @@ -379,6 +381,33 @@ select:focus { color: #555; } +.search-results-header .search-time { + font-size: 12px; + font-weight: normal; + color: #999; +} + +.search-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.search-sort-select { + padding: 4px 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 13px; + background: #fff; + color: #333; + cursor: pointer; + outline: none; +} + +.search-sort-select:focus { + border-color: var(--primary-color, #4a90d9); +} + .empty-state { display: flex; flex-direction: column; @@ -4567,4 +4596,146 @@ html[dir='rtl'] .fa-sign-out-alt { /* Search results header */ [data-theme="dark"] .search-results-header h3 { color: #f1f5f9; +} + +/* ── Shared View Dark Mode ── */ +[data-theme="dark"] .shared-filters { + background-color: transparent; +} +[data-theme="dark"] .filter-group label { + color: #94a3b8; +} +[data-theme="dark"] .filter-group select { + background-color: #1e293b; + border-color: #334155; + color: #e2e8f0; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); +} +[data-theme="dark"] .filter-group select:focus { + border-color: #ff5e3a; + box-shadow: 0 0 0 3px rgba(255, 94, 58, 0.15); +} +[data-theme="dark"] .search-box input { + background-color: #1e293b; + border-color: #334155; + color: #e2e8f0; +} +[data-theme="dark"] .search-box input::placeholder { + color: #64748b; +} +[data-theme="dark"] .shared-list-container { + background-color: #1e293b; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} +[data-theme="dark"] .shared-list thead th { + background-color: #162032; + color: #94a3b8; + border-bottom-color: #334155; +} +[data-theme="dark"] .shared-list tbody td { + border-bottom-color: #334155; + color: #e2e8f0; +} +[data-theme="dark"] .shared-list tbody tr:hover { + background-color: #162032; +} +[data-theme="dark"] .page-description { + color: #94a3b8; +} +[data-theme="dark"] .empty-state h3 { + color: #f1f5f9; +} +[data-theme="dark"] .empty-state p { + color: #94a3b8; +} +[data-theme="dark"] .empty-state .button.primary { + background: linear-gradient(135deg, #ff5e3a, #ff2d55); + color: #fff; +} +/* Shared view action buttons */ +[data-theme="dark"] .shared-list .action-btn { + color: #94a3b8; +} +[data-theme="dark"] .shared-list .action-btn:hover { + color: #ff5e3a; +} +/* Share dialog dark mode (shared view specific) */ +[data-theme="dark"] .dialog { + background-color: rgba(0, 0, 0, 0.6); +} +[data-theme="dark"] .dialog-content { + background-color: #1e293b; + color: #e2e8f0; +} +[data-theme="dark"] .dialog-header { + border-bottom-color: #334155; +} +[data-theme="dark"] .dialog-header h3 { + color: #f1f5f9; +} +[data-theme="dark"] .dialog-body label { + color: #94a3b8; +} +[data-theme="dark"] .dialog-body input, +[data-theme="dark"] .dialog-body textarea { + background-color: #0f172a; + border-color: #334155; + color: #e2e8f0; +} +[data-theme="dark"] .dialog-body input:focus, +[data-theme="dark"] .dialog-body textarea:focus { + border-color: #ff5e3a; +} +[data-theme="dark"] .close-dialog-btn { + color: #94a3b8; +} +[data-theme="dark"] .close-dialog-btn:hover { + color: #f1f5f9; +} +[data-theme="dark"] .notification-banner { + background-color: #1e293b; + color: #e2e8f0; + border-color: #334155; +} +[data-theme="dark"] .share-setting label { + color: #94a3b8; +} +[data-theme="dark"] .permissions-options label span { + color: #e2e8f0; +} +[data-theme="dark"] .share-item-info { + color: #e2e8f0; +} +/* Batch bar dark mode */ +[data-theme="dark"] .batch-bar { + background-color: #1e293b; + border-color: #334155; + color: #e2e8f0; +} +/* Search results dark mode */ +[data-theme="dark"] .search-results-header { + color: #f1f5f9; + border-bottom-color: #334155; +} +[data-theme="dark"] .search-results-header h3 { + color: #f1f5f9; +} +[data-theme="dark"] .search-results-header .search-time { + color: #64748b; +} +[data-theme="dark"] .search-results-header .btn-secondary { + background: #1e293b; + border-color: #334155; + color: #e2e8f0; +} +[data-theme="dark"] .search-results-header .btn-secondary:hover { + background: #334155; +} +[data-theme="dark"] .search-sort-select { + background: #1e293b; + border-color: #334155; + color: #e2e8f0; +} +[data-theme="dark"] .search-sort-select:focus { + border-color: #ff5e3a; } \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index 7cde4287..fa017111 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -396,9 +396,16 @@ function setupEventListeners() { // Set up drag and drop ui.setupDragAndDrop(); - // Search input + // Debounce timer for live search + let searchDebounceTimer = null; + const SEARCH_DEBOUNCE_MS = 300; + const SEARCH_MIN_CHARS = 3; + + // Search input — Enter key elements.searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { + // Cancel any pending debounce + if (searchDebounceTimer) clearTimeout(searchDebounceTimer); const query = elements.searchInput.value.trim(); if (query) { performSearch(query); @@ -412,8 +419,29 @@ function setupEventListeners() { } }); + // Search input — Live search (debounced, after 3+ chars) + elements.searchInput.addEventListener('input', () => { + if (searchDebounceTimer) clearTimeout(searchDebounceTimer); + const query = elements.searchInput.value.trim(); + + if (query.length >= SEARCH_MIN_CHARS) { + searchDebounceTimer = setTimeout(() => { + performSearch(query); + }, SEARCH_DEBOUNCE_MS); + } else if (query.length === 0 && app.isSearchMode) { + // User cleared the search input — return to normal view + searchDebounceTimer = setTimeout(() => { + app.isSearchMode = false; + app.currentPath = ''; + ui.updateBreadcrumb(''); + loadFiles(); + }, SEARCH_DEBOUNCE_MS); + } + }); + // Search button document.getElementById('search-button').addEventListener('click', () => { + if (searchDebounceTimer) clearTimeout(searchDebounceTimer); const query = elements.searchInput.value.trim(); if (query) { performSearch(query); @@ -686,14 +714,7 @@ async function loadFiles(options = {}) { // Always ensure a userHomeFolderId is set if (!app.userHomeFolderId) { - // If we don't have a home folder ID yet, try to get the user's username - const USER_DATA_KEY = 'oxicloud_user'; - const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); - if (userData.username) { - // Find user's home folder - console.log("Looking for user folder for", userData.username); - await findUserHomeFolder(userData.username); - } + await resolveHomeFolder(); } // Add timestamp to avoid cache @@ -791,28 +812,9 @@ async function loadFiles(options = {}) { // Add folders (check if it's an array) const folderList = Array.isArray(folders) ? folders : []; - // Get user info for filtering - const USER_DATA_KEY = 'oxicloud_user'; - const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); - const username = userData.username || ''; - - // Filter folders before adding them to the view - const visibleFolders = folderList.filter(folder => { - // Skip system folders (starting with dot) when at root - if (!app.currentPath && folder.name.startsWith('.')) { - return false; - } - - // Skip other users' folders when at root - if (!app.currentPath && folder.name.startsWith('My Folder - ') && !folder.name.includes(username)) { - return false; - } - - return true; - }); - - // Add filtered folders to the view - visibleFolders.forEach(folder => { + // Backend already scopes folders to the authenticated user, + // so no client-side filtering is needed. + folderList.forEach(folder => { ui.addFolderToView(folder); }); @@ -1064,55 +1066,51 @@ function addTrashItemToView(item) { } /** - * Perform search with the given query + * Perform search with the given query. + * All processing (filtering, scoring, sorting, categorization) is done + * server-side in Rust. This function only sends the request and renders. + * * @param {string} query - Search query + * @param {string} [sortBy] - Sort order (relevance|name|name_desc|date|date_desc|size|size_desc) */ -async function performSearch(query) { - console.log(`Performing search for: "${query}"`); +async function performSearch(query, sortBy) { + console.log(`Performing search for: "${query}" (sort: ${sortBy || 'relevance'})`); try { - // Update UI to indicate search mode app.isSearchMode = true; - - // Set breadcrumb for search ui.updateBreadcrumb(`Search: "${query}"`); - // Prepare search options + // Show loading spinner + const filesGrid = document.getElementById('files-grid'); + if (filesGrid) { + filesGrid.innerHTML = ` +
+

Searching for "${query}"...

+
+ `; + } + + // All options — backend handles all processing const options = { - recursive: true, // Search in all subfolders - limit: 100 // Limit results for performance + recursive: true, + limit: 100, + sort_by: sortBy || 'relevance' }; - // Always restrict search to the user's current folder context - // This ensures users can't search outside their personal folder + // Restrict search to user's folder context if (!app.isTrashView) { - // If we're in a subfolder, search from there, otherwise use the user's home folder options.folder_id = app.currentPath; - // Always include folder_id even if it's the root of user's home folder - // so user cannot search outside their allowed scope if (!options.folder_id || options.folder_id === '') { - // Fall back to user's home folder - we should never be here - // because findUserHomeFolder should have set app.currentPath - console.warn("Search without folder_id - this shouldn't happen with proper user context"); - - // Try to get folder from localStorage if available - const USER_DATA_KEY = 'oxicloud_user'; - const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); - if (userData.username) { - console.log("Retrieving home folder for user before search"); - await findUserHomeFolder(userData.username); - options.folder_id = app.currentPath; - } + await resolveHomeFolder(); + options.folder_id = app.currentPath; } } - console.log(`Searching with options:`, options); - - // Perform the search + // Send search request — backend does all processing const searchResults = await window.search.searchFiles(query, options); - // Display search results + // Render enriched results from the server window.search.displaySearchResults(searchResults); } catch (error) { @@ -1121,6 +1119,14 @@ async function performSearch(query) { } } +// Listen for re-sort events from the search sort dropdown +document.addEventListener('search-resort', (e) => { + const searchInput = document.querySelector('.search-container input'); + if (searchInput && searchInput.value.trim()) { + performSearch(searchInput.value.trim(), e.detail.sort_by); + } +}); + // Expose needed functions to global scope window.app = app; window.loadFiles = loadFiles; @@ -1713,7 +1719,7 @@ async function checkAuthentication() { }); // Find and load the user's home folder - findUserHomeFolder(userData.username); + resolveHomeFolder().then(() => loadFiles()); } else { // No user data but token exists — try to fetch from server console.log('No user data, attempting to fetch from server'); @@ -1723,7 +1729,7 @@ async function checkAuthentication() { const userInitials = freshData.username.substring(0, 2).toUpperCase(); document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => el.textContent = userInitials); updateStorageUsageDisplay(freshData); - findUserHomeFolder(freshData.username); + resolveHomeFolder().then(() => loadFiles()); } else { // Server didn't return valid user data — token is likely invalid console.warn('Could not retrieve user data, redirecting to login'); @@ -1757,131 +1763,39 @@ async function checkAuthentication() { * Find the user's home folder and load it * @param {string} username - The current user's username */ -async function findUserHomeFolder(username) { +/** + * Resolve the user's home folder from the backend. + * Since the backend now scopes GET /api/folders to the authenticated user, + * we simply pick the first root-level folder returned. + */ +async function resolveHomeFolder() { + if (app.userHomeFolderId) return; // Already resolved try { - console.log("Finding home folder for user:", username); - - // CRITICAL FIX: Always create a default folder if needed - // This prevents loops when the folder can't be found - const defaultFolder = { - id: 'default-folder', - name: `My Folder - ${username}`, - parent_id: null, - created_at: Date.now() / 1000, - updated_at: Date.now() / 1000 - }; - - // First, load all folders at the root - console.log("Fetching folders from API"); - - // Set max retries and timeout to prevent potential infinite loops - let retries = 0; - const maxRetries = 1; // Reduced from 2 to 1 - - while (retries < maxRetries) { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // Reduced timeout to 3 seconds - - const folderToken = localStorage.getItem('oxicloud_token'); - const folderHeaders = folderToken ? { 'Authorization': `Bearer ${folderToken}` } : {}; - const response = await fetch('/api/folders', { - headers: folderHeaders, - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (response.status === 401 || response.status === 403) { - console.warn(`Authentication error (${response.status}) when fetching folders`); - // Use default folder to break the loop - console.log('Using default folder to prevent redirection loop'); - app.userHomeFolderId = defaultFolder.id; - app.userHomeFolderName = defaultFolder.name; - app.currentPath = defaultFolder.id; - ui.updateBreadcrumb(defaultFolder.name); - loadFiles(); - return; - } - - if (!response.ok) { - throw new Error(`Error loading folders: ${response.status}`); - } - - const folders = await response.json(); - const folderList = Array.isArray(folders) ? folders : []; - - console.log(`Found ${folderList.length} folders at root`); - - // Look for a folder with a name pattern that matches the user's home folder - const homeFolderPattern = `My Folder - ${username}`; - - // Filter first to remove system folders and other users' folders - const visibleFolders = folderList.filter(folder => { - // Skip system folders (starting with dot) - if (folder.name.startsWith('.')) { - return false; - } - - // Skip other users' home folders - if (folder.name.startsWith('My Folder - ') && !folder.name.includes(username)) { - return false; - } - - return true; - }); - - // Find the user's home folder from filtered list - let homeFolder = visibleFolders.find(folder => folder.name === homeFolderPattern); - - if (homeFolder) { - console.log(`Found user's home folder: ${homeFolder.name} (${homeFolder.id})`); - - // Store the home folder ID and name in the app state - // This is used for breadcrumb navigation and restricting user access - app.userHomeFolderId = homeFolder.id; - app.userHomeFolderName = homeFolder.name; - - // Set this as the current path and load its contents - app.currentPath = homeFolder.id; - ui.updateBreadcrumb(homeFolder.name); - loadFiles(); - return; // Success! Exit function - } else { - console.warn("Could not find user's home folder"); - - // SECURITY: Never fall back to another user's folder. - // If user's own folder doesn't exist, show root (empty state). - console.log('User home folder not found, showing root'); - app.currentPath = ''; - ui.updateBreadcrumb(''); - loadFiles(); - return; - } - - // If we get here, we've successfully processed the response - break; - - } catch (fetchError) { - retries++; - console.error(`Fetch attempt ${retries} failed:`, fetchError); - - if (retries >= maxRetries) { - throw fetchError; // Re-throw after max retries - } - - // Wait before retrying - await new Promise(resolve => setTimeout(resolve, 1000)); - } + const token = localStorage.getItem('oxicloud_token'); + const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; + const response = await fetch('/api/folders', { headers }); + if (!response.ok) { + console.warn(`Could not fetch home folder: ${response.status}`); + return; + } + const folders = await response.json(); + const folderList = Array.isArray(folders) ? folders : []; + if (folderList.length > 0) { + const home = folderList[0]; + app.userHomeFolderId = home.id; + app.userHomeFolderName = home.name; + app.currentPath = home.id; + ui.updateBreadcrumb(home.name); + console.log(`Home folder resolved: ${home.name} (${home.id})`); + } else { + console.warn('No root folders found for user'); + app.currentPath = ''; + ui.updateBreadcrumb(''); } } catch (error) { - console.error('Error finding user home folder:', error); - - // Fall back to loading root in case of error - // This is a critical fallback to prevent infinite loops + console.error('Error resolving home folder:', error); app.currentPath = ''; ui.updateBreadcrumb(''); - loadFiles(); } } diff --git a/static/js/components/sharedView.js b/static/js/components/sharedView.js index 7d467206..caf5bd8d 100644 --- a/static/js/components/sharedView.js +++ b/static/js/components/sharedView.js @@ -1,6 +1,6 @@ /** * OxiCloud - Shared View Component - * Encapsulates shared files view functionality + * In-app shared files view. All operations go through the backend API. */ const sharedView = { @@ -8,640 +8,530 @@ const sharedView = { items: [], filteredItems: [], currentItem: null, - - // Initialize the shared view + + /** Auth header helper */ + _headers(json = false) { + const h = {}; + const token = localStorage.getItem('oxicloud_token'); + if (token) h['Authorization'] = `Bearer ${token}`; + if (json) h['Content-Type'] = 'application/json'; + return h; + }, + init() { - console.log('Initializing shared view component'); + console.log('Initializing shared view component (API-backed)'); this.loadItems(); }, - - // Show the shared view UI + show() { - console.log('Showing shared view component'); this.displayUI(); this.attachEventListeners(); - this.filterAndSortItems(); + this.loadItems().then(() => this.filterAndSortItems()); }, - - // Hide the shared view UI + hide() { - const sharedContainer = document.getElementById('shared-container'); - if (sharedContainer) { - sharedContainer.style.display = 'none'; - } + const c = document.getElementById('shared-container'); + if (c) c.style.display = 'none'; }, - - // Load shared items from local storage - loadItems() { + + // Load shared items from backend API + async loadItems() { try { - this.items = JSON.parse(localStorage.getItem('oxicloud_shared_links') || '[]'); - this.filteredItems = [...this.items]; - } catch (error) { - console.error('Error loading shared items:', error); + const res = await fetch('/api/shares?page=1&per_page=1000', { + headers: this._headers() + }); + if (res.ok) { + const data = await res.json(); + this.items = data.items || []; + } else { + this.items = []; + } + } catch (err) { + console.error('Error loading shared items:', err); this.items = []; - this.filteredItems = []; } + this.filteredItems = [...this.items]; }, - + // Create and display the shared view UI displayUI() { const contentArea = document.querySelector('.content-area'); - - // Create container if it doesn't exist - let sharedContainer = document.getElementById('shared-container'); - if (!sharedContainer) { - sharedContainer = document.createElement('div'); - sharedContainer.id = 'shared-container'; - contentArea.appendChild(sharedContainer); + + let container = document.getElementById('shared-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'shared-container'; + container.className = 'shared-view-container'; + if (contentArea) contentArea.appendChild(container); } - - // Show container - sharedContainer.style.display = 'block'; - - // Update container - sharedContainer.innerHTML = ` -
-
- - + + + -
-
- - + + + -
-
-
- + + +
- - - - - - - + + + + + + + - - - +
NameTypeDate SharedExpirationPermissionsPasswordActionsNameTypeDateExpirationPermissionsPasswordActions
-
-
📂
-

No shared resources yet

-

When you share files or folders, they will appear here

- -
- - -
-
-
-

Share Link

+ +
+
+
+ 📄 + Item
-
- - -
-
-
-

Send Notification

+ +
+
+
+ 📧 + Item
-
- `; - + // Hide other UI elements const filesGrid = document.getElementById('files-grid'); const filesListView = document.getElementById('files-list-view'); if (filesGrid) filesGrid.style.display = 'none'; if (filesListView) filesListView.style.display = 'none'; - - // Translate UI if i18n is loaded + if (window.i18n && window.i18n.translatePage) { window.i18n.translatePage(); } }, - - // Attach event listeners to the shared view UI + + // Attach event listeners attachEventListeners() { const filterType = document.getElementById('filter-type'); const sortBy = document.getElementById('sort-by'); const searchFilter = document.getElementById('shared-search-filter'); const searchBtn = document.getElementById('shared-search-filter-btn'); const emptyGoToFiles = document.getElementById('empty-go-to-files'); - + if (filterType) filterType.addEventListener('change', () => this.filterAndSortItems()); if (sortBy) sortBy.addEventListener('change', () => this.filterAndSortItems()); - if (searchFilter) searchFilter.addEventListener('keyup', (e) => { - if (e.key === 'Enter') this.filterAndSortItems(); - }); + if (searchFilter) searchFilter.addEventListener('keyup', e => { if (e.key === 'Enter') this.filterAndSortItems(); }); if (searchBtn) searchBtn.addEventListener('click', () => this.filterAndSortItems()); - - // Back to files button (empty state) if (emptyGoToFiles) emptyGoToFiles.addEventListener('click', () => window.switchToFilesView()); - - // Share dialog buttons + + // Share dialog const shareDialog = document.getElementById('share-dialog'); if (shareDialog) { const closeBtn = shareDialog.querySelector('.close-dialog-btn'); - const copyLinkBtn = document.getElementById('copy-link-btn'); - const enablePassword = document.getElementById('enable-password'); - const sharePassword = document.getElementById('share-password'); - const generatePasswordBtn = document.getElementById('generate-password'); - const enableExpiration = document.getElementById('enable-expiration'); - const shareExpiration = document.getElementById('share-expiration'); - const updateShareBtn = document.getElementById('update-share-btn'); - const removeShareBtn = document.getElementById('remove-share-btn'); - if (closeBtn) closeBtn.addEventListener('click', () => this.closeShareDialog()); + const copyLinkBtn = document.getElementById('copy-link-btn'); if (copyLinkBtn) copyLinkBtn.addEventListener('click', () => this.copyShareLink()); - if (enablePassword) enablePassword.addEventListener('change', () => { - if (sharePassword) { - sharePassword.disabled = !enablePassword.checked; - if (enablePassword.checked) sharePassword.focus(); - } + const enablePw = document.getElementById('enable-password'); + const pwField = document.getElementById('share-password'); + if (enablePw) enablePw.addEventListener('change', () => { + if (pwField) { pwField.disabled = !enablePw.checked; if (enablePw.checked) pwField.focus(); } }); - if (generatePasswordBtn) generatePasswordBtn.addEventListener('click', () => this.generatePassword()); - if (enableExpiration) enableExpiration.addEventListener('change', () => { - if (shareExpiration) { - shareExpiration.disabled = !enableExpiration.checked; - if (enableExpiration.checked) shareExpiration.focus(); - } + const genPwBtn = document.getElementById('generate-password'); + if (genPwBtn) genPwBtn.addEventListener('click', () => this.generatePassword()); + const enableExp = document.getElementById('enable-expiration'); + const expField = document.getElementById('share-expiration'); + if (enableExp) enableExp.addEventListener('change', () => { + if (expField) { expField.disabled = !enableExp.checked; if (enableExp.checked) expField.focus(); } }); - if (updateShareBtn) updateShareBtn.addEventListener('click', () => this.updateSharedItem()); - if (removeShareBtn) removeShareBtn.addEventListener('click', () => this.removeSharedItem()); + const updateBtn = document.getElementById('update-share-btn'); + if (updateBtn) updateBtn.addEventListener('click', () => this.updateSharedItem()); + const removeBtn = document.getElementById('remove-share-btn'); + if (removeBtn) removeBtn.addEventListener('click', () => this.removeSharedItem()); } - - // Notification dialog buttons - const notificationDialog = document.getElementById('share-notification-dialog'); - if (notificationDialog) { - const closeBtn = notificationDialog.querySelector('.close-dialog-btn'); - const sendBtn = document.getElementById('send-notification-btn'); - + + // Notification dialog + const notifDialog = document.getElementById('share-notification-dialog'); + if (notifDialog) { + const closeBtn = notifDialog.querySelector('.close-dialog-btn'); if (closeBtn) closeBtn.addEventListener('click', () => this.closeNotificationDialog()); + const sendBtn = document.getElementById('send-notification-btn'); if (sendBtn) sendBtn.addEventListener('click', () => this.sendNotification()); } }, - - // Filter and sort the items based on the current settings + + // Filter and sort items filterAndSortItems() { const filterType = document.getElementById('filter-type'); const sortBy = document.getElementById('sort-by'); const searchFilter = document.getElementById('shared-search-filter'); - - if (!filterType || !sortBy || !searchFilter) return; - - const type = filterType.value; - const sort = sortBy.value; - const searchTerm = searchFilter.value.toLowerCase(); - - // Filter items + + const type = filterType ? filterType.value : 'all'; + const sort = sortBy ? sortBy.value : 'date'; + const searchTerm = searchFilter ? searchFilter.value.toLowerCase() : ''; + this.filteredItems = this.items.filter(item => { - // Filter by type - if (type !== 'all' && item.type !== type) return false; - - // Filter by search term - const nameMatch = item.name.toLowerCase().includes(searchTerm); - return nameMatch; + if (type !== 'all' && item.item_type !== type) return false; + const name = (item.item_name || item.item_id || '').toLowerCase(); + return name.includes(searchTerm); }); - - // Sort items + this.filteredItems.sort((a, b) => { if (sort === 'name') { - return a.name.localeCompare(b.name); + return (a.item_name || a.item_id || '').localeCompare(b.item_name || b.item_id || ''); } else if (sort === 'date') { - return new Date(b.created_at || b.dateShared) - new Date(a.created_at || a.dateShared); + return (b.created_at || 0) - (a.created_at || 0); } else if (sort === 'expiration') { - // Handle null expiration dates (items without expiration come last) if (!a.expires_at && !b.expires_at) return 0; if (!a.expires_at) return 1; if (!b.expires_at) return -1; - return new Date(a.expires_at) - new Date(b.expires_at); + return a.expires_at - b.expires_at; } return 0; }); - - // Display filtered and sorted items + this.displaySharedItems(); }, - - // Display the shared items in the UI + + // Display items in the table displaySharedItems() { const sharedItemsList = document.getElementById('shared-items-list'); - const emptySharedState = document.getElementById('empty-shared-state'); - const sharedListContainer = document.querySelector('.shared-list-container'); - - if (!sharedItemsList || !emptySharedState || !sharedListContainer) return; - - // Clear the list + const emptyState = document.getElementById('empty-shared-state'); + const listContainer = document.querySelector('.shared-list-container'); + + if (!sharedItemsList || !emptyState || !listContainer) return; sharedItemsList.innerHTML = ''; - - // Show empty state if no items + if (this.filteredItems.length === 0) { - emptySharedState.style.display = 'flex'; - sharedListContainer.style.display = 'none'; + emptyState.style.display = 'flex'; + listContainer.style.display = 'none'; return; } - - // Hide empty state and show table - emptySharedState.style.display = 'none'; - sharedListContainer.style.display = 'block'; - - // Add items to the list + + emptyState.style.display = 'none'; + listContainer.style.display = 'block'; + this.filteredItems.forEach(item => { const row = document.createElement('tr'); - - // Icon and name + const displayName = item.item_name || item.item_id || 'Unknown'; + const nameCell = document.createElement('td'); nameCell.className = 'shared-item-name'; - const icon = document.createElement('span'); - icon.className = 'item-icon'; - icon.textContent = item.type === 'file' ? '📄' : '📁'; - const name = document.createElement('span'); - name.textContent = item.name; - nameCell.appendChild(icon); - nameCell.appendChild(name); - - // Type + nameCell.innerHTML = `${item.item_type === 'file' ? '📄' : '📁'}${displayName}`; + const typeCell = document.createElement('td'); - typeCell.textContent = item.type === 'file' ? this.translate('shared_typeFile', 'File') : this.translate('shared_typeFolder', 'Folder'); - - // Date shared + typeCell.textContent = item.item_type === 'file' ? this.translate('shared_typeFile', 'File') : this.translate('shared_typeFolder', 'Folder'); + const dateCell = document.createElement('td'); - dateCell.textContent = this.formatDate(item.created_at || item.dateShared); - - // Expiration - const expirationCell = document.createElement('td'); - expirationCell.textContent = item.expires_at ? this.formatDate(item.expires_at) : this.translate('shared_noExpiration', 'No expiration'); - - // Permissions - const permissionsCell = document.createElement('td'); - const permissions = []; - if (item.permissions?.read) permissions.push(this.translate('share_permissionRead', 'Read')); - if (item.permissions?.write) permissions.push(this.translate('share_permissionWrite', 'Write')); - if (item.permissions?.reshare) permissions.push(this.translate('share_permissionReshare', 'Reshare')); - permissionsCell.textContent = permissions.join(', ') || 'Read'; - - // Password - const passwordCell = document.createElement('td'); - passwordCell.textContent = (item.password || item.password_protected) ? this.translate('shared_hasPassword', 'Yes') : this.translate('shared_noPassword', 'No'); - - // Actions + dateCell.textContent = this.formatDate(item.created_at); + + const expCell = document.createElement('td'); + expCell.textContent = item.expires_at ? this.formatDate(item.expires_at) : this.translate('shared_noExpiration', 'No expiration'); + + const permCell = document.createElement('td'); + const perms = []; + if (item.permissions?.read) perms.push(this.translate('share_permissionRead', 'Read')); + if (item.permissions?.write) perms.push(this.translate('share_permissionWrite', 'Write')); + if (item.permissions?.reshare) perms.push(this.translate('share_permissionReshare', 'Reshare')); + permCell.textContent = perms.join(', ') || 'Read'; + + const pwCell = document.createElement('td'); + pwCell.textContent = item.has_password ? this.translate('shared_hasPassword', 'Yes') : this.translate('shared_noPassword', 'No'); + const actionsCell = document.createElement('td'); actionsCell.className = 'shared-item-actions'; - - // Edit button + const editBtn = document.createElement('button'); editBtn.className = 'action-btn edit-btn'; editBtn.innerHTML = '✏️'; editBtn.title = this.translate('shared_editShare', 'Edit Share'); editBtn.addEventListener('click', () => this.openShareDialog(item)); - - // Notify button + const notifyBtn = document.createElement('button'); notifyBtn.className = 'action-btn notify-btn'; notifyBtn.innerHTML = '📧'; notifyBtn.title = this.translate('shared_notifyShare', 'Notify Someone'); notifyBtn.addEventListener('click', () => this.openNotificationDialog(item)); - - // Copy link button + const copyBtn = document.createElement('button'); copyBtn.className = 'action-btn copy-btn'; copyBtn.innerHTML = '📋'; copyBtn.title = this.translate('shared_copyLink', 'Copy Link'); copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(item.url) - .then(() => this.showNotification(this.translate('shared_linkCopied', 'Link copied to clipboard!'))) - .catch(err => this.showNotification(this.translate('shared_linkCopyFailed', 'Failed to copy link'), 'error')); + .then(() => this.showNotification(this.translate('shared_linkCopied', 'Link copied!'))) + .catch(() => this.showNotification(this.translate('shared_linkCopyFailed', 'Failed to copy link'), 'error')); }); - - // Remove button - const removeBtn = document.createElement('button'); - removeBtn.className = 'action-btn remove-btn'; - removeBtn.innerHTML = '🗑️'; - removeBtn.title = this.translate('shared_removeShare', 'Remove Share'); - removeBtn.addEventListener('click', () => { - this.currentItem = item; - this.removeSharedItem(); - }); - - actionsCell.appendChild(editBtn); - actionsCell.appendChild(notifyBtn); - actionsCell.appendChild(copyBtn); - actionsCell.appendChild(removeBtn); - - // Add cells to row - row.appendChild(nameCell); - row.appendChild(typeCell); - row.appendChild(dateCell); - row.appendChild(expirationCell); - row.appendChild(permissionsCell); - row.appendChild(passwordCell); - row.appendChild(actionsCell); - - // Add row to table + + const rmBtn = document.createElement('button'); + rmBtn.className = 'action-btn remove-btn'; + rmBtn.innerHTML = '🗑️'; + rmBtn.title = this.translate('shared_removeShare', 'Remove Share'); + rmBtn.addEventListener('click', () => { this.currentItem = item; this.removeSharedItem(); }); + + actionsCell.append(editBtn, notifyBtn, copyBtn, rmBtn); + row.append(nameCell, typeCell, dateCell, expCell, permCell, pwCell, actionsCell); sharedItemsList.appendChild(row); }); }, - - // Open the share dialog for a shared item + + // Open share dialog openShareDialog(item) { this.currentItem = item; const shareDialog = document.getElementById('share-dialog'); - const shareDialogIcon = document.getElementById('share-dialog-icon'); - const shareDialogName = document.getElementById('share-dialog-name'); - const shareLinkUrl = document.getElementById('share-link-url'); - const enablePassword = document.getElementById('enable-password'); - const sharePassword = document.getElementById('share-password'); - const enableExpiration = document.getElementById('enable-expiration'); - const shareExpiration = document.getElementById('share-expiration'); - const permissionRead = document.getElementById('permission-read'); - const permissionWrite = document.getElementById('permission-write'); - const permissionReshare = document.getElementById('permission-reshare'); - - if (!shareDialog || !shareDialogIcon || !shareDialogName || !shareLinkUrl) return; - - // Set dialog content - shareDialogIcon.textContent = item.type === 'file' ? '📄' : '📁'; - shareDialogName.textContent = item.name; - shareLinkUrl.value = item.url; - - // Set permissions - if (permissionRead) permissionRead.checked = item.permissions?.read !== false; - if (permissionWrite) permissionWrite.checked = !!item.permissions?.write; - if (permissionReshare) permissionReshare.checked = !!item.permissions?.reshare; - - // Set password - if (enablePassword) { - enablePassword.checked = !!(item.password || item.password_protected); - if (sharePassword) { - sharePassword.disabled = !enablePassword.checked; - sharePassword.value = item.password || ''; + const dn = item.item_name || item.item_id || 'Unknown'; + + const iconEl = document.getElementById('share-dialog-icon'); + const nameEl = document.getElementById('share-dialog-name'); + const urlEl = document.getElementById('share-link-url'); + const enablePw = document.getElementById('enable-password'); + const pwField = document.getElementById('share-password'); + const enableExp = document.getElementById('enable-expiration'); + const expField = document.getElementById('share-expiration'); + const permRead = document.getElementById('permission-read'); + const permWrite = document.getElementById('permission-write'); + const permReshare = document.getElementById('permission-reshare'); + + if (!shareDialog) return; + if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁'; + if (nameEl) nameEl.textContent = dn; + if (urlEl) urlEl.value = item.url || ''; + + if (permRead) permRead.checked = item.permissions?.read !== false; + if (permWrite) permWrite.checked = !!item.permissions?.write; + if (permReshare) permReshare.checked = !!item.permissions?.reshare; + + if (enablePw) { + enablePw.checked = item.has_password; + if (pwField) { pwField.disabled = !enablePw.checked; pwField.value = ''; } + } + if (enableExp) { + enableExp.checked = !!item.expires_at; + if (expField) { + expField.disabled = !enableExp.checked; + expField.value = item.expires_at ? new Date(item.expires_at * 1000).toISOString().split('T')[0] : ''; } } - - // Set expiration - if (enableExpiration) { - enableExpiration.checked = !!item.expires_at; - if (shareExpiration) { - shareExpiration.disabled = !enableExpiration.checked; - shareExpiration.value = item.expires_at ? new Date(item.expires_at).toISOString().split('T')[0] : ''; - } - } - - // Show dialog + shareDialog.classList.add('active'); }, - - // Close the share dialog + closeShareDialog() { - const shareDialog = document.getElementById('share-dialog'); - if (shareDialog) shareDialog.classList.remove('active'); + const d = document.getElementById('share-dialog'); + if (d) d.classList.remove('active'); this.currentItem = null; }, - - // Open the notification dialog for a shared item + openNotificationDialog(item) { this.currentItem = item; - const notificationDialog = document.getElementById('share-notification-dialog'); - const notifyDialogIcon = document.getElementById('notify-dialog-icon'); - const notifyDialogName = document.getElementById('notify-dialog-name'); - const notificationEmail = document.getElementById('notification-email'); - const notificationMessage = document.getElementById('notification-message'); - - if (!notificationDialog || !notifyDialogIcon || !notifyDialogName || !notificationEmail || !notificationMessage) return; - - // Set dialog content - notifyDialogIcon.textContent = item.type === 'file' ? '📄' : '📁'; - notifyDialogName.textContent = item.name; - notificationEmail.value = ''; - notificationMessage.value = ''; - - // Show dialog - notificationDialog.classList.add('active'); + const dn = item.item_name || item.item_id || 'Unknown'; + const d = document.getElementById('share-notification-dialog'); + const iconEl = document.getElementById('notify-dialog-icon'); + const nameEl = document.getElementById('notify-dialog-name'); + const emailEl = document.getElementById('notification-email'); + const msgEl = document.getElementById('notification-message'); + + if (!d) return; + if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁'; + if (nameEl) nameEl.textContent = dn; + if (emailEl) emailEl.value = ''; + if (msgEl) msgEl.value = ''; + d.classList.add('active'); }, - - // Close the notification dialog + closeNotificationDialog() { - const notificationDialog = document.getElementById('share-notification-dialog'); - if (notificationDialog) notificationDialog.classList.remove('active'); + const d = document.getElementById('share-notification-dialog'); + if (d) d.classList.remove('active'); this.currentItem = null; }, - - // Copy a share link to the clipboard + copyShareLink() { - const shareLinkUrl = document.getElementById('share-link-url'); - if (!shareLinkUrl) return; - - navigator.clipboard.writeText(shareLinkUrl.value) - .then(() => this.showNotification(this.translate('shared_linkCopied', 'Link copied to clipboard!'))) - .catch(err => this.showNotification(this.translate('shared_linkCopyFailed', 'Failed to copy link'), 'error')); + const el = document.getElementById('share-link-url'); + if (!el) return; + navigator.clipboard.writeText(el.value) + .then(() => this.showNotification(this.translate('shared_linkCopied', 'Link copied!'))) + .catch(() => this.showNotification(this.translate('shared_linkCopyFailed', 'Failed to copy link'), 'error')); }, - - // Generate a random password for a share + + // Generate secure password with crypto API generatePassword() { - const sharePassword = document.getElementById('share-password'); - const enablePassword = document.getElementById('enable-password'); - if (!sharePassword || !enablePassword) return; - + const pwField = document.getElementById('share-password'); + const enablePw = document.getElementById('enable-password'); + if (!pwField || !enablePw) return; + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; + const array = new Uint32Array(16); + crypto.getRandomValues(array); let password = ''; - for (let i = 0; i < 12; i++) { - password += chars.charAt(Math.floor(Math.random() * chars.length)); + for (let i = 0; i < 16; i++) { + password += chars[array[i] % chars.length]; } - sharePassword.value = password; - enablePassword.checked = true; - sharePassword.disabled = false; + pwField.value = password; + enablePw.checked = true; + pwField.disabled = false; }, - - // Update a shared item with new settings - updateSharedItem() { + + // Update share via API + async updateSharedItem() { if (!this.currentItem) return; - const permissionRead = document.getElementById('permission-read'); - const permissionWrite = document.getElementById('permission-write'); - const permissionReshare = document.getElementById('permission-reshare'); - const enablePassword = document.getElementById('enable-password'); - const sharePassword = document.getElementById('share-password'); - const enableExpiration = document.getElementById('enable-expiration'); - const shareExpiration = document.getElementById('share-expiration'); - - if (!permissionRead || !permissionWrite || !permissionReshare || !enablePassword || !sharePassword || !enableExpiration || !shareExpiration) return; - - // Get updated settings - const permissions = { - read: permissionRead.checked, - write: permissionWrite.checked, - reshare: permissionReshare.checked + + const permRead = document.getElementById('permission-read'); + const permWrite = document.getElementById('permission-write'); + const permReshare = document.getElementById('permission-reshare'); + const enablePw = document.getElementById('enable-password'); + const pwField = document.getElementById('share-password'); + const enableExp = document.getElementById('enable-expiration'); + const expField = document.getElementById('share-expiration'); + + const body = { + permissions: { + read: permRead ? permRead.checked : true, + write: permWrite ? permWrite.checked : false, + reshare: permReshare ? permReshare.checked : false + }, + password: (enablePw && enablePw.checked && pwField && pwField.value) ? pwField.value : null, + expires_at: (enableExp && enableExp.checked && expField && expField.value) + ? Math.floor(new Date(expField.value).getTime() / 1000) + : null }; - - const password = enablePassword.checked ? sharePassword.value : null; - const expires_at = enableExpiration.checked ? new Date(shareExpiration.value).toISOString() : null; - - // Update the shared link via the global function - if (window.updateSharedLink) { - window.updateSharedLink(this.currentItem.id, { - permissions, - password, - expires_at + + try { + const res = await fetch(`/api/shares/${this.currentItem.id}`, { + method: 'PUT', + headers: this._headers(true), + body: JSON.stringify(body) }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Server error ${res.status}`); + } + this.showNotification(this.translate('shared_itemUpdated', 'Share settings updated')); + } catch (err) { + console.error('Error updating share:', err); + this.showNotification(err.message || 'Error updating share', 'error'); } - - // Reload items and close dialog - this.loadItems(); - this.filterAndSortItems(); + this.closeShareDialog(); - - // Show notification - this.showNotification(this.translate('shared_itemUpdated', 'Share settings updated successfully')); + await this.loadItems(); + this.filterAndSortItems(); }, - - // Remove a shared item - removeSharedItem() { + + // Remove share via API + async removeSharedItem() { if (!this.currentItem) return; - - // Remove the shared link via the global function - if (window.removeSharedLink) { - window.removeSharedLink(this.currentItem.id); + + try { + const res = await fetch(`/api/shares/${this.currentItem.id}`, { + method: 'DELETE', + headers: this._headers() + }); + if (!res.ok && res.status !== 204) throw new Error(`Server error ${res.status}`); + this.showNotification(this.translate('shared_itemRemoved', 'Share removed')); + } catch (err) { + console.error('Error removing share:', err); + this.showNotification('Error removing share', 'error'); } - - // Reload items and close dialog if open - this.loadItems(); - this.filterAndSortItems(); + this.closeShareDialog(); - - // Show notification - this.showNotification(this.translate('shared_itemRemoved', 'Share removed successfully')); + await this.loadItems(); + this.filterAndSortItems(); }, - - // Send a notification for a shared item + + // Send notification (stub) sendNotification() { if (!this.currentItem) return; - const notificationEmail = document.getElementById('notification-email'); - const notificationMessage = document.getElementById('notification-message'); - - if (!notificationEmail || !notificationMessage) return; - - const email = notificationEmail.value.trim(); - const message = notificationMessage.value.trim(); - - // Validate email + const emailEl = document.getElementById('notification-email'); + const msgEl = document.getElementById('notification-message'); + const email = emailEl ? emailEl.value.trim() : ''; + const message = msgEl ? msgEl.value.trim() : ''; + if (!email || !this.validateEmail(email)) { this.showNotification(this.translate('shared_invalidEmail', 'Please enter a valid email address'), 'error'); return; } - - // Send notification via the global function - if (window.sendShareNotification) { - window.sendShareNotification(this.currentItem.id, email, message) - .then(() => { - this.closeNotificationDialog(); - this.showNotification(this.translate('shared_notificationSent', 'Notification sent successfully')); - }) - .catch(error => { - this.showNotification(this.translate('shared_notificationFailed', 'Failed to send notification'), 'error'); - }); + + if (window.fileSharing && window.fileSharing.sendShareNotification) { + window.fileSharing.sendShareNotification(this.currentItem.url, email, message) + .then(() => { this.closeNotificationDialog(); this.showNotification(this.translate('shared_notificationSent', 'Notification sent')); }) + .catch(() => this.showNotification(this.translate('shared_notificationFailed', 'Failed to send notification'), 'error')); } }, - - // Show a notification + showNotification(message, type = 'success') { if (window.ui && window.ui.showNotification) { window.ui.showNotification(message, type); @@ -649,28 +539,21 @@ const sharedView = { alert(message); } }, - - // Validate an email address + validateEmail(email) { - const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return re.test(email); + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }, - - // Format a date string - formatDate(dateString) { - if (!dateString) return 'N/A'; - const options = { year: 'numeric', month: 'short', day: 'numeric' }; - return new Date(dateString).toLocaleDateString(undefined, options); + + formatDate(value) { + if (!value) return 'N/A'; + const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value); + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); }, - - // Translate a string using i18n if available + translate(key, defaultText) { - if (window.i18n && window.i18n.t) { - return window.i18n.t(key, defaultText); - } + if (window.i18n && window.i18n.t) return window.i18n.t(key, defaultText); return defaultText; } }; -// Export the shared view component -window.sharedView = sharedView; \ No newline at end of file +window.sharedView = sharedView; diff --git a/static/js/contextMenus.js b/static/js/contextMenus.js index 11f5ffcf..54bba865 100644 --- a/static/js/contextMenus.js +++ b/static/js/contextMenus.js @@ -206,22 +206,39 @@ const contextMenus = { if (window.app.moveDialogMode === 'batch' && window.multiSelect) { const targetId = window.app.selectedTargetFolderId; const items = window.app.batchMoveItems || []; + + const fileIds = items.filter(i => i.type === 'file').map(i => i.id); + const folderIds = items.filter(i => i.type === 'folder' && i.id !== targetId).map(i => i.id); + let success = 0, errors = 0; - for (const item of items) { - try { - if (item.type === 'folder') { - if (item.id === targetId) continue; - const ok = await window.fileOps.moveFolder(item.id, targetId); - if (ok) success++; else errors++; - } else { - const ok = await window.fileOps.moveFile(item.id, targetId); - if (ok) success++; else errors++; - } - } catch (err) { - console.error('Error moving item:', item, err); - errors++; + try { + // Batch move files in a single request + if (fileIds.length > 0) { + const res = await fetch('/api/batch/files/move', { + method: 'POST', + headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_ids: fileIds, target_folder_id: targetId }) + }); + const data = await res.json(); + success += data.stats?.successful || 0; + errors += data.stats?.failed || 0; } + + // Batch move folders in a single request + if (folderIds.length > 0) { + const res = await fetch('/api/batch/folders/move', { + method: 'POST', + headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder_ids: folderIds, target_folder_id: targetId }) + }); + const data = await res.json(); + success += data.stats?.successful || 0; + errors += data.stats?.failed || 0; + } + } catch (err) { + console.error('Batch move error:', err); + errors++; } this.closeMoveDialog(); @@ -465,7 +482,7 @@ const contextMenus = { * @param {Object} item - File or folder object * @param {string} itemType - 'file' or 'folder' */ - showShareDialog(item, itemType) { + async showShareDialog(item, itemType) { try { const shareDialog = document.getElementById('share-dialog'); if (!shareDialog) { @@ -507,8 +524,8 @@ const contextMenus = { window.app.shareDialogItem = item; window.app.shareDialogItemType = itemType; - // Check if item already has shares - const existingShares = window.fileSharing.getSharedLinksForItem(item.id, itemType); + // Check if item already has shares (async API call) + const existingShares = await window.fileSharing.getSharedLinksForItem(item.id, itemType); const existingSharesContainer = document.getElementById('existing-shares-container'); // Clear existing shares container @@ -529,7 +546,7 @@ const contextMenus = { shareEl.innerHTML = `