diff --git a/TODO-LIST.md b/TODO-LIST.md index 4bce0f6b..d28beea5 100644 --- a/TODO-LIST.md +++ b/TODO-LIST.md @@ -20,12 +20,12 @@ This document contains the task list for the development of OxiCloud, a minimali - [ ] Add text/code preview ### Enhanced Search -- [ ] Implement search by name -- [ ] Add filters by file type -- [ ] Implement search by date range -- [ ] Add filter by file size -- [ ] Add search within specific folders -- [ ] Implement cache for search results +- [x] Implement search by name +- [x] Add filters by file type +- [x] Implement search by date range +- [x] Add filter by file size +- [x] Add search within specific folders +- [x] Implement cache for search results ### UI/UX Optimizations - [ ] Improve responsive design for mobile devices diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index 839e1dd6..4d41657b 100644 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -1,8 +1,8 @@ -use serde::Serialize; +use serde::{Serialize, Deserialize}; use crate::domain::entities::file::File; /// DTO for file responses -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileDto { /// File ID pub id: String, diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index 8d31a58c..7a66996d 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -26,7 +26,7 @@ pub struct MoveFolderDto { } /// DTO for folder responses -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct FolderDto { /// Folder ID pub id: String, diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index d2a1399c..7d3af6c6 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -4,4 +4,5 @@ pub mod i18n_dto; pub mod pagination; pub mod user_dto; pub mod trash_dto; +pub mod search_dto; diff --git a/src/application/dtos/search_dto.rs b/src/application/dtos/search_dto.rs new file mode 100644 index 00000000..a23b464c --- /dev/null +++ b/src/application/dtos/search_dto.rs @@ -0,0 +1,152 @@ +use serde::{Serialize, Deserialize}; + +/** + * Data Transfer Object for file search criteria. + * + * This structure represents all possible search parameters that can be used + * to filter files and folders in the system. It supports various filter types + * including name matching, file types, date ranges, and size constraints. + */ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchCriteriaDto { + /// Optional text to search in file/folder names + #[serde(skip_serializing_if = "Option::is_none")] + pub name_contains: Option, + + /// Optional list of file extensions to include (e.g., "pdf", "jpg") + #[serde(skip_serializing_if = "Option::is_none")] + pub file_types: Option>, + + /// Optional minimum creation date (seconds since epoch) + #[serde(skip_serializing_if = "Option::is_none")] + pub created_after: Option, + + /// Optional maximum creation date (seconds since epoch) + #[serde(skip_serializing_if = "Option::is_none")] + pub created_before: Option, + + /// Optional minimum modification date (seconds since epoch) + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_after: Option, + + /// Optional maximum modification date (seconds since epoch) + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_before: Option, + + /// Optional minimum file size in bytes + #[serde(skip_serializing_if = "Option::is_none")] + pub min_size: Option, + + /// Optional maximum file size in bytes + #[serde(skip_serializing_if = "Option::is_none")] + pub max_size: Option, + + /// Optional folder ID to limit search scope + #[serde(skip_serializing_if = "Option::is_none")] + pub folder_id: Option, + + /// Whether to search recursively within subfolders (default: true) + #[serde(default = "default_recursive")] + pub recursive: bool, + + /// Maximum number of results to return + #[serde(default = "default_limit")] + pub limit: usize, + + /// Offset for pagination + #[serde(default)] + pub offset: usize, +} + +/// Default value for recursive search (true) +fn default_recursive() -> bool { + true +} + +/// Default limit for search results (100) +fn default_limit() -> usize { + 100 +} + +impl Default for SearchCriteriaDto { + fn default() -> Self { + Self { + name_contains: None, + file_types: None, + created_after: None, + created_before: None, + modified_after: None, + modified_before: None, + min_size: None, + max_size: None, + folder_id: None, + recursive: default_recursive(), + limit: default_limit(), + offset: 0, + } + } +} + +/** + * 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. + */ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResultsDto { + /// Files matching the search criteria + pub files: Vec, + + /// Folders matching the search criteria + pub folders: Vec, + + /// Total count of matching items (for pagination) + pub total_count: Option, + + /// Limit used in the search + pub limit: usize, + + /// Offset used in the search + pub offset: usize, + + /// Whether there are more results available + pub has_more: bool, +} + +impl SearchResultsDto { + /// Creates a new empty search results object + pub fn empty() -> Self { + Self { + files: Vec::new(), + folders: Vec::new(), + total_count: None, + limit: 0, + offset: 0, + has_more: false, + } + } + + /// Creates a new search results object from files and folders + pub fn new( + files: Vec, + folders: Vec, + limit: usize, + offset: usize, + total_count: Option, + ) -> Self { + let has_more = match total_count { + Some(total) => (offset + files.len() + folders.len()) < total, + None => false, + }; + + Self { + files, + folders, + total_count, + limit, + offset, + has_more, + } + } +} \ No newline at end of file diff --git a/src/application/ports/inbound.rs b/src/application/ports/inbound.rs index 1d8cdc61..a056777a 100644 --- a/src/application/ports/inbound.rs +++ b/src/application/ports/inbound.rs @@ -5,6 +5,7 @@ use futures::Stream; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::{CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto}; +use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto}; use crate::common::errors::DomainError; /// Puerto primario para operaciones de archivos @@ -70,8 +71,33 @@ pub trait FolderUseCase: Send + Sync + 'static { async fn delete_folder(&self, id: &str) -> Result<(), DomainError>; } +/** + * Puerto primario para búsqueda de archivos y carpetas + * + * Define las operaciones relacionadas con la búsqueda avanzada de + * archivos y carpetas basándose en diversos criterios. + */ +#[async_trait] +pub trait SearchUseCase: Send + Sync + 'static { + /** + * Realiza una búsqueda basada en los criterios especificados + * + * @param criteria Criterios de búsqueda que incluyen texto, fechas, tamaños, etc. + * @return Resultados de la búsqueda que contienen archivos y carpetas coincidentes + */ + async fn search(&self, criteria: SearchCriteriaDto) -> Result; + + /** + * Limpia la caché de resultados de búsqueda + * + * @return Resultado indicando éxito o error + */ + async fn clear_search_cache(&self) -> Result<(), DomainError>; +} + /// Factory para crear implementaciones de casos de uso pub trait UseCaseFactory { fn create_file_use_case(&self) -> Arc; fn create_folder_use_case(&self) -> Arc; + fn create_search_use_case(&self) -> Arc; } \ No newline at end of file diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 5614ea7a..90b2f0ea 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -11,6 +11,7 @@ pub mod file_management_service; pub mod file_use_case_factory; pub mod auth_application_service; pub mod trash_service; +pub mod search_service; #[cfg(test)] mod trash_service_test; @@ -21,3 +22,4 @@ pub use file_retrieval_service::FileRetrievalService; pub use file_management_service::FileManagementService; pub use file_use_case_factory::AppFileUseCaseFactory; pub use trash_service::TrashService; +pub use search_service::SearchService; diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs new file mode 100644 index 00000000..ac803f65 --- /dev/null +++ b/src/application/services/search_service.rs @@ -0,0 +1,494 @@ +use std::sync::Arc; +use std::collections::HashMap; +use std::time::{Duration, Instant}; +use std::sync::Mutex; +use async_trait::async_trait; +use tokio::time; + +use crate::common::errors::Result; +use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto}; +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::FolderDto; +use crate::application::ports::inbound::SearchUseCase; +use crate::application::ports::outbound::{FileStoragePort, FolderStoragePort}; + +/** + * Implementación del servicio de búsqueda para archivos y carpetas. + * + * Este servicio implementa la funcionalidad de búsqueda avanzada que permite + * a los usuarios encontrar archivos y carpetas basados en diversos criterios + * como nombre, tipo, fecha y tamaño. También incluye una caché para mejorar + * el rendimiento de búsquedas repetidas. + */ +pub struct SearchService { + /// Repositorio para operaciones con archivos + file_repository: Arc, + + /// Repositorio para operaciones con carpetas + folder_repository: Arc, + + /// Caché de resultados de búsqueda con tiempo de expiración + search_cache: Arc>>, + + /// Duración de validez de la caché en segundos + cache_ttl: u64, + + /// Tamaño máximo de la caché (número de resultados almacenados) + max_cache_size: usize, +} + +/// Clave para la caché de búsqueda +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct SearchCacheKey { + /// Representación serializada de los criterios de búsqueda + criteria_hash: String, + + /// ID del usuario (para aislar búsquedas entre usuarios) + user_id: String, +} + +/// Resultado de búsqueda en caché con tiempo de expiración +struct CachedSearchResult { + /// Resultados de la búsqueda + results: SearchResultsDto, + + /// Momento en que se creó la entrada de caché + timestamp: Instant, +} + +impl SearchService { + /** + * Crea una nueva instancia del servicio de búsqueda. + * + * @param file_repository Repositorio para operaciones con archivos + * @param folder_repository Repositorio para operaciones con carpetas + * @param cache_ttl Tiempo de vida de la caché en segundos (0 para desactivar) + * @param max_cache_size Tamaño máximo de la caché + */ + pub fn new( + file_repository: Arc, + folder_repository: Arc, + cache_ttl: u64, + max_cache_size: usize, + ) -> Self { + let search_service = Self { + file_repository, + folder_repository, + search_cache: Arc::new(Mutex::new(HashMap::new())), + cache_ttl, + max_cache_size, + }; + + // Iniciar tarea de limpieza de caché si TTL > 0 + if cache_ttl > 0 { + Self::start_cache_cleanup_task(search_service.search_cache.clone(), cache_ttl); + } + + search_service + } + + /** + * Inicia una tarea asíncrona para limpiar entradas expiradas de la caché. + * + * @param cache_ref Referencia a la caché compartida + * @param ttl_seconds TTL en segundos + */ + fn start_cache_cleanup_task( + cache_ref: Arc>>, + ttl_seconds: u64, + ) { + tokio::spawn(async move { + let cleanup_interval = Duration::from_secs(ttl_seconds / 2); + let ttl = Duration::from_secs(ttl_seconds); + + loop { + time::sleep(cleanup_interval).await; + + // Obtener lock y limpiar entradas expiradas + if let Ok(mut cache) = cache_ref.lock() { + let now = Instant::now(); + + // Identificar entradas expiradas + let expired_keys: Vec = cache + .iter() + .filter(|(_, result)| now.duration_since(result.timestamp) > ttl) + .map(|(key, _)| key.clone()) + .collect(); + + // Eliminar entradas expiradas + for key in expired_keys { + cache.remove(&key); + } + } + } + }); + } + + /** + * Crea una clave de caché a partir de los criterios de búsqueda. + * + * @param criteria Criterios de búsqueda + * @param user_id ID del usuario (para aislar caché entre usuarios) + * @return Clave para la caché + */ + fn create_cache_key(&self, criteria: &SearchCriteriaDto, user_id: &str) -> SearchCacheKey { + // Serializar criterios para generar un hash + let criteria_str = serde_json::to_string(criteria).unwrap_or_default(); + + SearchCacheKey { + criteria_hash: criteria_str, + user_id: user_id.to_string(), + } + } + + /** + * Intenta obtener resultados de la caché. + * + * @param key Clave de caché + * @return Opcionalmente, los resultados si existen y no han expirado + */ + fn get_from_cache(&self, key: &SearchCacheKey) -> Option { + // Si TTL es 0, la caché está desactivada + if self.cache_ttl == 0 { + return None; + } + + if let Ok(cache) = self.search_cache.lock() { + if let Some(cached_result) = cache.get(key) { + let now = Instant::now(); + let ttl = Duration::from_secs(self.cache_ttl); + + // Comprobar si la entrada ha expirado + if now.duration_since(cached_result.timestamp) < ttl { + return Some(cached_result.results.clone()); + } + } + } + + None + } + + /** + * Almacena resultados en la caché. + * + * @param key Clave de caché + * @param results Resultados a almacenar + */ + fn store_in_cache(&self, key: SearchCacheKey, results: SearchResultsDto) { + // Si TTL es 0, la caché está desactivada + if self.cache_ttl == 0 { + return; + } + + if let Ok(mut cache) = self.search_cache.lock() { + // Si la caché está llena, eliminar la entrada más antigua + if cache.len() >= self.max_cache_size { + if let Some((oldest_key, _)) = cache + .iter() + .min_by_key(|(_, result)| result.timestamp) { + let key_to_remove = oldest_key.clone(); + cache.remove(&key_to_remove); + } + } + + // Almacenar el nuevo resultado + cache.insert(key, CachedSearchResult { + results, + timestamp: Instant::now(), + }); + } + } + + /** + * Filtra archivos según los criterios de búsqueda. + * + * @param files Lista de archivos a filtrar + * @param criteria Criterios de búsqueda + * @return Archivos que cumplen con los criterios + */ + fn filter_files(&self, files: Vec, criteria: &SearchCriteriaDto) -> Vec { + files.into_iter() + .filter(|file| { + // Filtrar por nombre + if let Some(name_query) = &criteria.name_contains { + if !file.name.to_lowercase().contains(&name_query.to_lowercase()) { + return false; + } + } + + // Filtrar por tipo de archivo (extensión) + if let Some(file_types) = &criteria.file_types { + if let Some(extension) = file.name.split('.').last() { + if !file_types.iter().any(|ext| ext.eq_ignore_ascii_case(extension)) { + return false; + } + } else { + // No tiene extensión + return false; + } + } + + // Filtrar por fecha de creación + if let Some(created_after) = criteria.created_after { + if file.created_at < created_after { + return false; + } + } + + if let Some(created_before) = criteria.created_before { + if file.created_at > created_before { + return false; + } + } + + // Filtrar por fecha de modificación + if let Some(modified_after) = criteria.modified_after { + if file.modified_at < modified_after { + return false; + } + } + + if let Some(modified_before) = criteria.modified_before { + if file.modified_at > modified_before { + return false; + } + } + + // Filtrar por tamaño + if let Some(min_size) = criteria.min_size { + if file.size < min_size { + return false; + } + } + + if let Some(max_size) = criteria.max_size { + if file.size > max_size { + return false; + } + } + + true + }) + .collect() + } + + /** + * Filtra carpetas según los criterios de búsqueda. + * + * @param folders Lista de carpetas a filtrar + * @param criteria Criterios de búsqueda + * @return Carpetas que cumplen con los criterios + */ + fn filter_folders(&self, folders: Vec, criteria: &SearchCriteriaDto) -> Vec { + folders.into_iter() + .filter(|folder| { + // Filtrar por nombre + if let Some(name_query) = &criteria.name_contains { + if !folder.name.to_lowercase().contains(&name_query.to_lowercase()) { + return false; + } + } + + // Filtrar por fecha de creación + if let Some(created_after) = criteria.created_after { + if folder.created_at < created_after { + return false; + } + } + + if let Some(created_before) = criteria.created_before { + if folder.created_at > created_before { + return false; + } + } + + // Filtrar por fecha de modificación + if let Some(modified_after) = criteria.modified_after { + if folder.modified_at < modified_after { + return false; + } + } + + if let Some(modified_before) = criteria.modified_before { + if folder.modified_at > modified_before { + return false; + } + } + + true + }) + .collect() + } + + /** + * Implementación de la búsqueda recursiva a través de carpetas. + * + * @param current_folder_id ID de la carpeta actual + * @param criteria Criterios de búsqueda + * @param found_files Archivos encontrados hasta ahora + * @param found_folders Carpetas encontradas hasta ahora + */ + async fn search_recursive( + &self, + current_folder_id: Option<&str>, + criteria: &SearchCriteriaDto, + found_files: &mut Vec, + found_folders: &mut Vec, + ) -> Result<()> { + Box::pin(async move { + // Listar archivos en la carpeta actual + let files = self.file_repository.list_files(current_folder_id).await?; + + // Filtrar archivos según criterios y agregarlos a los resultados + let filtered_files = self.filter_files( + files.into_iter().map(FileDto::from).collect(), + criteria + ); + found_files.extend(filtered_files); + + // Si la búsqueda es recursiva, procesar subcarpetas + if criteria.recursive { + // Listar subcarpetas + let folders = self.folder_repository.list_folders(current_folder_id).await?; + + // Filtrar carpetas según criterios y agregarlas a los resultados + let filtered_folders: Vec = self.filter_folders( + folders.into_iter().map(FolderDto::from).collect(), + criteria + ); + + // Añadir las carpetas filtradas a los resultados + found_folders.extend(filtered_folders.iter().cloned()); + + // Buscar recursivamente en cada subcarpeta + for folder in filtered_folders { + self.search_recursive( + Some(&folder.id), + criteria, + found_files, + found_folders, + ).await?; + } + } + + Ok(()) + }).await + } +} + +#[async_trait] +impl SearchUseCase for SearchService { + /** + * Realiza una búsqueda basada en los criterios especificados. + * + * @param criteria Criterios de búsqueda + * @return Resultados de la búsqueda + */ + async fn search(&self, criteria: SearchCriteriaDto) -> Result { + // TODO: Obtener ID de usuario del contexto de autenticación + let user_id = "default-user"; + let cache_key = self.create_cache_key(&criteria, user_id); + + // Intentar obtener resultados de la caché + if let Some(cached_results) = self.get_from_cache(&cache_key) { + return Ok(cached_results); + } + + // Inicializar colecciones para resultados + let mut found_files: Vec = Vec::new(); + let mut found_folders: Vec = Vec::new(); + + // Realizar búsqueda en la carpeta especificada o en la raíz + self.search_recursive( + criteria.folder_id.as_deref(), + &criteria, + &mut found_files, + &mut found_folders, + ).await?; + + // Aplicar paginación + let total_count = found_files.len() + found_folders.len(); + + // Ordenar por relevancia o fecha según criterios + // Por defecto, ordenamos por fecha de modificación (más reciente primero) + 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)); + + // Aplicar límite y offset para paginación + 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) // Es una carpeta + } else { + (false, i - found_folders.len()) // Es un archivo + } + }) + .collect(); + + // Extraer elementos paginados + 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()); + } + } else { + if idx < found_files.len() { + paginated_files.push(found_files[idx].clone()); + } + } + } + + // Crear objeto de resultados + let search_results = SearchResultsDto::new( + paginated_files, + paginated_folders, + criteria.limit, + criteria.offset, + Some(total_count), + ); + + // Almacenar en caché + self.store_in_cache(cache_key, search_results.clone()); + + Ok(search_results) + } + + /** + * Limpia la caché de resultados de búsqueda. + * + * @return Resultado indicando éxito + */ + async fn clear_search_cache(&self) -> Result<()> { + if let Ok(mut cache) = self.search_cache.lock() { + cache.clear(); + } + Ok(()) + } +} + +// Implementar el caso de uso de prueba (stub) +impl SearchService { + /// Crea una versión stub del servicio para pruebas + pub fn new_stub() -> impl SearchUseCase { + struct SearchServiceStub; + + #[async_trait] + impl SearchUseCase for SearchServiceStub { + async fn search(&self, _criteria: SearchCriteriaDto) -> Result { + Ok(SearchResultsDto::empty()) + } + + async fn clear_search_cache(&self) -> Result<()> { + Ok(()) + } + } + + SearchServiceStub + } +} \ No newline at end of file diff --git a/src/common/config.rs b/src/common/config.rs index c466c062..0c366093 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -273,6 +273,7 @@ pub struct FeaturesConfig { pub enable_user_storage_quotas: bool, pub enable_file_sharing: bool, pub enable_trash: bool, + pub enable_search: bool, } impl Default for FeaturesConfig { @@ -282,6 +283,7 @@ impl Default for FeaturesConfig { enable_user_storage_quotas: false, enable_file_sharing: false, enable_trash: true, // Enable trash feature + enable_search: true, // Enable search feature } } } diff --git a/src/common/di.rs b/src/common/di.rs index bcb3eb05..d851335b 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -19,7 +19,7 @@ use crate::application::services::file_service::FileService; use crate::application::services::i18n_application_service::I18nApplicationService; use crate::application::ports::trash_ports::TrashUseCase; use crate::application::services::storage_mediator::{StorageMediator, FileSystemStorageMediator}; -use crate::application::ports::inbound::{FileUseCase, FolderUseCase}; +use crate::application::ports::inbound::{FileUseCase, FolderUseCase, SearchUseCase}; use crate::application::ports::outbound::{FileStoragePort, FolderStoragePort}; use crate::application::ports::file_ports::{FileUploadUseCase, FileRetrievalUseCase, FileManagementUseCase, FileUseCaseFactory}; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; @@ -226,6 +226,9 @@ impl AppServiceFactory { // Servicio de papelera (deshabilitado temporalmente) let trash_service = None; // La función de papelera está deshabilitada por defecto + // Servicio de búsqueda (deshabilitado por defecto) + let search_service = None; // La función de búsqueda se activa según la configuración + ApplicationServices { folder_service, file_service, @@ -235,6 +238,7 @@ impl AppServiceFactory { file_use_case_factory, i18n_service, trash_service, + search_service, } } } @@ -276,6 +280,7 @@ pub struct ApplicationServices { pub file_use_case_factory: Arc, pub i18n_service: Arc, pub trash_service: Option>, + pub search_service: Option>, } /// Contenedor para servicios de autenticación @@ -698,7 +703,7 @@ impl Default for AppState { } } - struct DummyI18nApplicationService {}; + struct DummyI18nApplicationService {} // Need to implement the actual service to match the type signature in DI container impl DummyI18nApplicationService { @@ -746,6 +751,22 @@ impl Default for AppState { trash_repository: None, // No trash repository in minimal mode }; + // Create dummy search use case + struct DummySearchUseCase; + #[async_trait::async_trait] + impl crate::application::ports::inbound::SearchUseCase for DummySearchUseCase { + async fn search( + &self, + _criteria: crate::application::dtos::search_dto::SearchCriteriaDto + ) -> Result { + Ok(crate::application::dtos::search_dto::SearchResultsDto::empty()) + } + + async fn clear_search_cache(&self) -> Result<(), crate::common::errors::DomainError> { + Ok(()) + } + } + // Create application services let application_services = ApplicationServices { folder_service, @@ -756,6 +777,7 @@ impl Default for AppState { file_use_case_factory, i18n_service: Arc::new(DummyI18nApplicationService::dummy()), trash_service: None, // No trash service in minimal mode + search_service: Some(Arc::new(DummySearchUseCase) as Arc), }; // Return a minimal app state diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index c6aafd3d..bdccd095 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -4,6 +4,7 @@ pub mod i18n_handler; pub mod batch_handler; pub mod auth_handler; pub mod trash_handler; +pub mod search_handler; /// Tipo de resultado para controladores de API pub type ApiResult = Result; diff --git a/src/interfaces/api/handlers/search_handler.rs b/src/interfaces/api/handlers/search_handler.rs new file mode 100644 index 00000000..03632700 --- /dev/null +++ b/src/interfaces/api/handlers/search_handler.rs @@ -0,0 +1,225 @@ +use axum::{ + extract::{State, Query, Json}, + response::IntoResponse, + http::StatusCode, +}; +use serde_json::json; +use tracing::{info, error}; + +use crate::application::dtos::search_dto::SearchCriteriaDto; +use crate::common::di::AppState; + +/** + * Manejador para las operaciones de búsqueda a través de la API. + * + * Este manejador expone endpoints relacionados con la funcionalidad de búsqueda, + * permitiendo a los usuarios buscar archivos y carpetas usando diversos criterios. + */ +pub struct SearchHandler; + +impl SearchHandler { + /** + * Realiza una búsqueda basada en los criterios proporcionados como parámetros de consulta. + * + * Este endpoint permite búsquedas simples directamente con parámetros URL. + * + * @param state Estado de la aplicación con servicios + * @param query_params Parámetros de búsqueda como query string + * @return Respuesta HTTP con los resultados de la búsqueda + */ + pub async fn search_files_get( + State(state): State, + Query(params): Query, + ) -> impl IntoResponse { + info!("API: Búsqueda de archivos con parámetros: {:?}", params); + + // Extraer el servicio de búsqueda o devolver error si no está disponible + let search_service = match &state.applications.search_service { + Some(service) => service, + None => { + error!("Servicio de búsqueda no disponible"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "error": "Search service is not available" + })) + ).into_response(); + } + }; + + // Convertir parámetros de búsqueda a DTO + let search_criteria = SearchCriteriaDto { + name_contains: params.query, + file_types: params.type_filter.map(|t| t.split(',').map(|s| s.trim().to_string()).collect()), + created_after: params.created_after, + created_before: params.created_before, + modified_after: params.modified_after, + modified_before: params.modified_before, + min_size: params.min_size, + max_size: params.max_size, + folder_id: params.folder_id, + recursive: params.recursive.unwrap_or(true), + limit: params.limit.unwrap_or(100), + offset: params.offset.unwrap_or(0), + }; + + // Realizar la búsqueda + match search_service.search(search_criteria).await { + Ok(results) => { + info!("Búsqueda completada, {} archivos y {} carpetas encontrados", + results.files.len(), results.folders.len()); + (StatusCode::OK, Json(results)).into_response() + }, + Err(err) => { + error!("Error en búsqueda: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Search error: {}", err) + })) + ).into_response() + } + } + } + + /** + * Realiza una búsqueda avanzada basada en un objeto de criterios JSON completo. + * + * Este endpoint permite búsquedas más complejas con todos los criterios posibles + * proporcionados en el cuerpo de la solicitud. + * + * @param state Estado de la aplicación con servicios + * @param criteria Criterios de búsqueda completos + * @return Respuesta HTTP con los resultados de la búsqueda + */ + pub async fn search_files_post( + State(state): State, + Json(criteria): Json, + ) -> impl IntoResponse { + info!("API: Búsqueda avanzada de archivos"); + + // Extraer el servicio de búsqueda o devolver error si no está disponible + let search_service = match &state.applications.search_service { + Some(service) => service, + None => { + error!("Servicio de búsqueda no disponible"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "error": "Search service is not available" + })) + ).into_response(); + } + }; + + // Realizar la búsqueda + match search_service.search(criteria).await { + Ok(results) => { + info!("Búsqueda completada, {} archivos y {} carpetas encontrados", + results.files.len(), results.folders.len()); + (StatusCode::OK, Json(results)).into_response() + }, + Err(err) => { + error!("Error en búsqueda: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Search error: {}", err) + })) + ).into_response() + } + } + } + + /** + * Limpia la caché de resultados de búsqueda. + * + * Este endpoint es útil para forzar búsquedas frescas después de cambios + * significativos en el sistema de archivos. + * + * @param state Estado de la aplicación con servicios + * @return Respuesta HTTP indicando éxito o error + */ + pub async fn clear_search_cache( + State(state): State, + ) -> impl IntoResponse { + info!("API: Limpiando caché de búsqueda"); + + // Extraer el servicio de búsqueda o devolver error si no está disponible + let search_service = match &state.applications.search_service { + Some(service) => service, + None => { + error!("Servicio de búsqueda no disponible"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "error": "Search service is not available" + })) + ).into_response(); + } + }; + + // Limpiar la caché + match search_service.clear_search_cache().await { + Ok(_) => { + info!("Caché de búsqueda limpiada correctamente"); + ( + StatusCode::OK, + Json(json!({ + "message": "Search cache cleared successfully" + })) + ).into_response() + }, + Err(err) => { + error!("Error al limpiar caché de búsqueda: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Error clearing search cache: {}", err) + })) + ).into_response() + } + } + } +} + +/// Parámetros de búsqueda para el endpoint GET +#[derive(Debug, serde::Deserialize)] +pub struct SearchParams { + /// Texto a buscar en nombres de archivos y carpetas + pub query: Option, + + /// Filtro por tipos de archivo (extensiones separadas por comas) + #[serde(rename = "type")] + pub type_filter: Option, + + /// Filtrar elementos creados después de esta fecha (timestamp) + pub created_after: Option, + + /// Filtrar elementos creados antes de esta fecha (timestamp) + pub created_before: Option, + + /// Filtrar elementos modificados después de esta fecha (timestamp) + pub modified_after: Option, + + /// Filtrar elementos modificados antes de esta fecha (timestamp) + pub modified_before: Option, + + /// Tamaño mínimo en bytes + pub min_size: Option, + + /// Tamaño máximo en bytes + pub max_size: Option, + + /// ID de carpeta para limitar la búsqueda + pub folder_id: Option, + + /// Búsqueda recursiva en subcarpetas + pub recursive: Option, + + /// Límite de resultados para paginación + pub limit: Option, + + /// Desplazamiento para paginación + pub offset: Option, +} \ No newline at end of file diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 62cc6ed6..e6190fd9 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -23,6 +23,7 @@ use crate::application::services::file_service::FileService; use crate::application::services::i18n_application_service::I18nApplicationService; use crate::application::services::batch_operations::BatchOperationService; use crate::application::ports::trash_ports::TrashUseCase; +use crate::application::ports::inbound::SearchUseCase; use crate::interfaces::api::handlers::folder_handler::FolderHandler; use crate::interfaces::api::handlers::file_handler::FileHandler; @@ -38,6 +39,7 @@ pub fn create_api_routes( file_service: Arc, i18n_service: Option>, trash_service: Option>, + search_service: Option>, ) -> Router { // Create a simplified AppState for the trash view // Setup required components for repository construction @@ -99,6 +101,7 @@ pub fn create_api_routes( Arc::new(crate::application::services::i18n_application_service::I18nApplicationService::dummy()) ), trash_service: trash_service.clone(), // Include the trash service here too for consistency + search_service: search_service.clone(), // Include the search service }, db_pool: None, auth_service: None, @@ -265,11 +268,28 @@ pub fn create_api_routes( .route("/folders/get", post(batch_handler::get_folders_batch)) .with_state(batch_handler_state); + // Create search routes if the service is available + let search_router = if search_service.is_some() { + use crate::interfaces::api::handlers::search_handler::SearchHandler; + + Router::new() + // Simple search with query parameters + .route("/", get(SearchHandler::search_files_get)) + // Advanced search with full criteria object + .route("/advanced", post(SearchHandler::search_files_post)) + // Clear search cache + .route("/cache", delete(SearchHandler::clear_search_cache)) + .with_state(app_state.clone()) + } else { + Router::new() + }; + // Create a router without the i18n routes let mut router = Router::new() .nest("/folders", folders_router) .nest("/files", files_router) - .nest("/batch", batch_router); + .nest("/batch", batch_router) + .nest("/search", search_router); // Re-enable trash routes to make the trash view work if let Some(trash_service_ref) = trash_service.clone() { diff --git a/src/main.rs b/src/main.rs index d8e367ee..6dc131f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,31 +6,29 @@ use axum::Router; use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -/** - * OxiCloud - Cloud Storage Platform - * - * OxiCloud is a NextCloud-like file storage system built in Rust with a focus on - * performance, security, and clean architecture. The system provides: - * - * - File and folder management with rich metadata - * - User authentication and authorization - * - File trash system with automatic cleanup - * - Efficient handling of large files through parallel processing - * - Compression capabilities for bandwidth optimization - * - RESTful API and web interface - * - * The architecture follows the Clean/Hexagonal Architecture pattern with: - * - * - Domain Layer: Core business entities and repository interfaces (domain/*) - * - Application Layer: Use cases and service orchestration (application/*) - * - Infrastructure Layer: Technical implementations of repositories (infrastructure/*) - * - Interface Layer: API endpoints and web controllers (interfaces/*) - * - * Dependencies are managed through dependency inversion, with high-level modules - * defining interfaces (ports) that low-level modules implement (adapters). - * - * @author OxiCloud Development Team - */ +/// OxiCloud - Cloud Storage Platform +/// +/// OxiCloud is a NextCloud-like file storage system built in Rust with a focus on +/// performance, security, and clean architecture. The system provides: +/// +/// - File and folder management with rich metadata +/// - User authentication and authorization +/// - File trash system with automatic cleanup +/// - Efficient handling of large files through parallel processing +/// - Compression capabilities for bandwidth optimization +/// - RESTful API and web interface +/// +/// The architecture follows the Clean/Hexagonal Architecture pattern with: +/// +/// - Domain Layer: Core business entities and repository interfaces (domain/*) +/// - Application Layer: Use cases and service orchestration (application/*) +/// - Infrastructure Layer: Technical implementations of repositories (infrastructure/*) +/// - Interface Layer: API endpoints and web controllers (interfaces/*) +/// +/// Dependencies are managed through dependency inversion, with high-level modules +/// defining interfaces (ports) that low-level modules implement (adapters). +/// +/// @author OxiCloud Development Team /// Common utilities, configuration, and error handling mod common; @@ -580,6 +578,20 @@ async fn main() -> Result<(), Box> { }), }; + // Create the search service + let search_service: Option> = { + // Create the search service with caching + let search_service = Arc::new(application::services::search_service::SearchService::new( + file_repository.clone(), + folder_repository.clone(), + 300, // Cache TTL in seconds (5 minutes) + 1000, // Maximum cache entries + )); + + tracing::info!("Search service initialized with caching (TTL: 300s, max entries: 1000)"); + Some(search_service) + }; + let application_services = common::di::ApplicationServices { folder_service: folder_service.clone(), file_service: file_service.clone(), @@ -589,6 +601,7 @@ async fn main() -> Result<(), Box> { file_use_case_factory: Arc::new(application::services::file_use_case_factory::AppFileUseCaseFactory::default_stub()), i18n_service: i18n_service.clone(), trash_service: trash_service.clone(), + search_service: search_service.clone(), }; // Create the AppState without Arc first @@ -613,7 +626,7 @@ async fn main() -> Result<(), Box> { let app_state = Arc::new(app_state); // Build application router - let api_routes = create_api_routes(folder_service, file_service, Some(i18n_service), trash_service); + let api_routes = create_api_routes(folder_service, file_service, Some(i18n_service), trash_service, search_service); let web_routes = create_web_routes(); // Build the app router diff --git a/static/css/style.css b/static/css/style.css index ea36cb7f..997cefea 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -144,6 +144,8 @@ body { max-width: 500px; position: relative; margin-right: 20px; + display: flex; + align-items: center; } .search-container input { @@ -165,6 +167,53 @@ body { font-size: 16px; } +.search-button { + background-color: #ff5e3a; + color: white; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + margin-left: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; +} + +.search-button:hover { + background-color: #e64a29; +} + +/* Estilos para resultados de búsqueda */ +.search-results-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + margin-bottom: 15px; + border-bottom: 1px solid #eee; + width: 100%; +} + +.search-results-header h3 { + margin: 0; + font-size: 16px; + color: #555; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + text-align: center; + color: #888; + width: 100%; +} + .user-controls { display: flex; align-items: center; diff --git a/static/index.html b/static/index.html index b71a7d40..d8bb0d63 100644 --- a/static/index.html +++ b/static/index.html @@ -16,6 +16,7 @@ + @@ -82,6 +83,7 @@
+
diff --git a/static/js/app.js b/static/js/app.js index a240ca1b..05429c6c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -14,6 +14,7 @@ const app = { moveDialogMode: 'file', // Move dialog mode: 'file' or 'folder' isTrashView: false, // Whether we're in trash view currentSection: 'files', // Current section: 'files' or 'trash' + isSearchMode: false, // Whether we're in search mode }; // DOM elements @@ -34,11 +35,6 @@ function initApp() { // Setup event listeners setupEventListeners(); - // Load initial view - app.currentPath = ''; - ui.updateBreadcrumb(''); - loadFiles(); - // Initialize file renderer if available if (window.fileRenderer) { console.log('Using optimized file renderer'); @@ -46,8 +42,26 @@ function initApp() { console.log('Using standard file rendering'); } - // Check authentication - checkAuthentication(); + // Wait for translations to load before checking authentication + if (window.i18n && window.i18n.isLoaded && window.i18n.isLoaded()) { + // Translations already loaded, proceed with authentication + checkAuthentication(); + } else { + // Wait for translations to be loaded before proceeding + console.log('Waiting for translations to load...'); + window.addEventListener('translationsLoaded', () => { + console.log('Translations loaded, proceeding with authentication'); + checkAuthentication(); + }); + + // Set a timeout as a fallback in case translations take too long + setTimeout(() => { + if (!window.i18n || !window.i18n.isLoaded || !window.i18n.isLoaded()) { + console.warn('Translations loading timeout, proceeding with authentication anyway'); + checkAuthentication(); + } + }, 3000); // 3 second timeout + } } /** @@ -68,6 +82,7 @@ function cacheElements() { elements.actionsBar = document.querySelector('.actions-bar'); elements.navItems = document.querySelectorAll('.nav-item'); elements.trashBtn = document.querySelector('.nav-item:nth-child(5)'); // The trash nav item + elements.searchInput = document.querySelector('.search-container input'); } /** @@ -77,6 +92,30 @@ function setupEventListeners() { // Set up drag and drop ui.setupDragAndDrop(); + // Search input + elements.searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const query = elements.searchInput.value.trim(); + if (query) { + performSearch(query); + } else if (app.isSearchMode) { + // If search is empty and we're in search mode, return to normal view + app.isSearchMode = false; + app.currentPath = ''; + ui.updateBreadcrumb(''); + loadFiles(); + } + } + }); + + // Search button + document.getElementById('search-button').addEventListener('click', () => { + const query = elements.searchInput.value.trim(); + if (query) { + performSearch(query); + } + }); + // Upload button elements.uploadBtn.addEventListener('click', () => { elements.dropzone.style.display = elements.dropzone.style.display === 'none' ? 'block' : 'none'; @@ -328,12 +367,17 @@ async function loadTrashItems() {
Nombre
Tipo
-
Ubicación original
-
Fecha eliminación
-
Acciones
+
Ubicación original
+
Fecha eliminación
+
Acciones
`; + // Translate the header if i18n is available + if (window.i18n && window.i18n.translatePage) { + window.i18n.translatePage(); + } + // Update breadcrumb for trash ui.updateBreadcrumb(window.i18n ? window.i18n.t('nav.trash') : 'Papelera'); @@ -394,10 +438,10 @@ function addTrashItemToView(item) {
${item.name}
${typeLabel} - ${formattedDate}
- -
@@ -438,10 +482,10 @@ function addTrashItemToView(item) {
${item.original_path || '--'}
${formattedDate}
- -
@@ -465,11 +509,70 @@ function addTrashItemToView(item) { elements.filesListView.appendChild(listElement); } +/** + * Perform search with the given query + * @param {string} query - Search query + */ +async function performSearch(query) { + console.log(`Performing search for: "${query}"`); + + try { + // Update UI to indicate search mode + app.isSearchMode = true; + + // Set breadcrumb for search + ui.updateBreadcrumb(`Búsqueda: "${query}"`); + + // Prepare search options + const options = { + recursive: true, // Search in all subfolders + limit: 100 // Limit results for performance + }; + + // Always restrict search to the user's current folder context + // This ensures users can't search outside their personal folder + 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; + } + } + } + + console.log(`Searching with options:`, options); + + // Perform the search + const searchResults = await window.search.searchFiles(query, options); + + // Display search results + window.search.displaySearchResults(searchResults); + + } catch (error) { + console.error('Search error:', error); + window.ui.showNotification('Error', 'Error al realizar la búsqueda'); + } +} + // Expose needed functions to global scope window.app = app; window.loadFiles = loadFiles; window.loadTrashItems = loadTrashItems; window.formatFileSize = formatFileSize; +window.performSearch = performSearch; // Set up global selectFolder function for navigation window.selectFolder = (id, name) => { @@ -479,7 +582,7 @@ window.selectFolder = (id, name) => { }; /** - * Check if user is authenticated + * Check if user is authenticated and load user's home folder */ function checkAuthentication() { // Nombres de variables según auth.js @@ -505,6 +608,88 @@ function checkAuthentication() { if (userAvatar) { userAvatar.textContent = userInitials; } + + // Find and load the user's home folder + findUserHomeFolder(userData.username); + } else { + // If no user data, fallback to standard load + app.currentPath = ''; + ui.updateBreadcrumb(''); + loadFiles(); + } +} + +/** + * Find the user's home folder and load it + * @param {string} username - The current user's username + */ +async function findUserHomeFolder(username) { + try { + console.log("Finding home folder for user:", username); + + // First, load all folders at the root + const response = await fetch('/api/folders'); + if (!response.ok) { + throw new Error(`Error loading folders: ${response.status}`); + } + + const folders = await response.json(); + const folderList = Array.isArray(folders) ? folders : []; + + // Look for a folder with a name pattern that matches the user's home folder + // Typically named "Mi Carpeta - username" + const homeFolderPattern = `Mi Carpeta - ${username}`; + let homeFolder = folderList.find(folder => folder.name === homeFolderPattern); + + // If exact match not found, try a more flexible match + if (!homeFolder) { + homeFolder = folderList.find(folder => + folder.name.toLowerCase().includes(username.toLowerCase()) || + folder.name.startsWith('Mi Carpeta -') + ); + } + + 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(); + } else { + console.warn("Could not find user's home folder, fallback to first folder or root"); + + // If we can't find a specific home folder but there are folders, + // use the first folder as the user's home + if (folderList.length > 0) { + const fallbackFolder = folderList[0]; + console.log(`Using first folder as fallback: ${fallbackFolder.name} (${fallbackFolder.id})`); + + app.userHomeFolderId = fallbackFolder.id; + app.userHomeFolderName = fallbackFolder.name; + app.currentPath = fallbackFolder.id; + ui.updateBreadcrumb(fallbackFolder.name); + loadFiles(); + } else { + // No folders at all - this is an edge case + console.warn("No folders found, using root"); + app.currentPath = ''; + ui.updateBreadcrumb(''); + loadFiles(); + } + } + } catch (error) { + console.error('Error finding user home folder:', error); + + // Fall back to loading root in case of error + app.currentPath = ''; + ui.updateBreadcrumb(''); + loadFiles(); } } diff --git a/static/js/fileOperations.js b/static/js/fileOperations.js index c5e3d44d..157f5195 100644 --- a/static/js/fileOperations.js +++ b/static/js/fileOperations.js @@ -419,7 +419,8 @@ const fileOps = { * @returns {Promise} - Éxito de la operación */ async emptyTrash() { - if (!confirm('¿Estás seguro de que quieres vaciar la papelera? Esta acción eliminará permanentemente todos los elementos y no se puede deshacer.')) { + const confirmMsg = window.i18n ? window.i18n.t('trash.empty_confirm') : '¿Estás seguro de que quieres vaciar la papelera? Esta acción eliminará permanentemente todos los elementos.'; + if (!confirm(confirmMsg)) { return false; } diff --git a/static/js/i18n.js b/static/js/i18n.js index 249dd45c..4fcbb63c 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -227,14 +227,33 @@ function getSupportedLocales() { return [...supportedLocales]; } +// Flag to track if translations are loaded +let translationsLoaded = false; + // Initialize when DOM is ready -document.addEventListener('DOMContentLoaded', initI18n); +document.addEventListener('DOMContentLoaded', async () => { + await initI18n(); + translationsLoaded = true; + // Dispatch an event when translations are fully loaded + window.dispatchEvent(new Event('translationsLoaded')); +}); + +// Improved t function with fallback for early calls +function safeT(key, params = {}) { + if (!translationsLoaded) { + console.warn(`Translations for ${currentLocale} not loaded yet`); + // Return a default value or the key depending on context + return key.split('.').pop() || key; + } + return t(key, params); +} // Export functions for use in other modules window.i18n = { - t, + t: safeT, setLocale, getCurrentLocale, getSupportedLocales, - translatePage + translatePage, + isLoaded: () => translationsLoaded }; \ No newline at end of file diff --git a/static/js/search.js b/static/js/search.js new file mode 100644 index 00000000..1f9d3a63 --- /dev/null +++ b/static/js/search.js @@ -0,0 +1,196 @@ +/** + * OxiCloud - Search Module + * This file handles search functionality for files and folders + */ + +const search = { + /** + * Perform a basic search using query string + * @param {string} query - Search query + * @param {Object} options - Additional search options + * @returns {Promise} - Search results + */ + async searchFiles(query, options = {}) { + try { + // Prepare search parameters + const params = new URLSearchParams(); + params.append('query', query); + + // Add optional parameters + if (options.folder_id) params.append('folder_id', options.folder_id); + if (options.recursive !== undefined) params.append('recursive', options.recursive); + if (options.file_types) params.append('type', options.file_types); + if (options.min_size) params.append('min_size', options.min_size); + if (options.max_size) params.append('max_size', options.max_size); + if (options.created_after) params.append('created_after', options.created_after); + if (options.created_before) params.append('created_before', options.created_before); + if (options.modified_after) params.append('modified_after', options.modified_after); + if (options.modified_before) params.append('modified_before', options.modified_before); + if (options.limit) params.append('limit', options.limit); + if (options.offset) params.append('offset', options.offset); + + // Create search URL + const url = `/api/search?${params.toString()}`; + console.log(`Performing search with URL: ${url}`); + + // Perform the search request + const response = await fetch(url); + + if (response.ok) { + return await response.json(); + } else { + let errorText = ''; + try { + const errorJson = await response.json(); + errorText = errorJson.error || response.statusText; + } catch (e) { + errorText = response.statusText; + } + + console.error(`Search error: ${errorText}`); + throw new Error(`Search failed: ${errorText}`); + } + } catch (error) { + console.error('Error performing search:', error); + window.ui.showNotification('Error', 'Error al realizar la búsqueda'); + return { files: [], folders: [], total_count: 0 }; + } + }, + + /** + * Perform advanced search with multiple criteria + * @param {Object} criteria - Search criteria + * @returns {Promise} - Search results + */ + async advancedSearch(criteria) { + try { + console.log('Performing advanced search with criteria:', criteria); + + // Use POST endpoint for advanced search + const response = await fetch('/api/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(criteria) + }); + + if (response.ok) { + return await response.json(); + } else { + let errorText = ''; + try { + const errorJson = await response.json(); + errorText = errorJson.error || response.statusText; + } catch (e) { + errorText = response.statusText; + } + + console.error(`Advanced search error: ${errorText}`); + throw new Error(`Advanced search failed: ${errorText}`); + } + } catch (error) { + console.error('Error performing advanced search:', error); + window.ui.showNotification('Error', 'Error al realizar la búsqueda avanzada'); + return { files: [], folders: [], total_count: 0 }; + } + }, + + /** + * Display search results in the UI + * @param {Object} results - Search results object with files and folders arrays + */ + displaySearchResults(results) { + // Get the files grid and list view elements + const filesGrid = document.getElementById('files-grid'); + const filesListView = document.getElementById('files-list-view'); + + // Clear existing content + filesGrid.innerHTML = ''; + filesListView.innerHTML = ` +
+
Nombre
+
Tipo
+
Tamaño
+
Modificado
+
+ `; + + // Add search results header + const searchHeader = document.createElement('div'); + searchHeader.className = 'search-results-header'; + searchHeader.innerHTML = ` +

Resultados de búsqueda (${results.total_count || (results.files.length + results.folders.length)})

+ + `; + filesGrid.appendChild(searchHeader); + + // Add event listener to clear search button + const clearSearchBtn = document.getElementById('clear-search-btn'); + if (clearSearchBtn) { + clearSearchBtn.addEventListener('click', () => { + // Clear search input + document.querySelector('.search-container input').value = ''; + + // Load regular files view + window.app.currentPath = ''; + window.ui.updateBreadcrumb(''); + window.loadFiles(); + }); + } + + // If no results, show empty state + if (results.files.length === 0 && results.folders.length === 0) { + const emptyState = document.createElement('div'); + emptyState.className = 'empty-state'; + emptyState.innerHTML = ` + +

No se encontraron resultados para esta búsqueda

+ `; + filesGrid.appendChild(emptyState); + return; + } + + // Process folders + results.folders.forEach(folder => { + window.ui.addFolderToView(folder); + }); + + // Process files + results.files.forEach(file => { + window.ui.addFileToView(file); + }); + + // Update file icons + window.ui.updateFileIcons(); + }, + + /** + * Clear the search cache on the server + * @returns {Promise} - Success status + */ + async clearSearchCache() { + try { + const response = await fetch('/api/search/cache', { + method: 'DELETE' + }); + + if (response.ok) { + window.ui.showNotification('Caché limpiada', 'Caché de búsqueda limpiada correctamente'); + return true; + } else { + window.ui.showNotification('Error', 'Error al limpiar la caché de búsqueda'); + return false; + } + } catch (error) { + console.error('Error clearing search cache:', error); + window.ui.showNotification('Error', 'Error al limpiar la caché de búsqueda'); + return false; + } + } +}; + +// Expose the search module globally +window.search = search; \ No newline at end of file diff --git a/static/js/ui.js b/static/js/ui.js index cafb7c3c..4088413a 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -190,19 +190,54 @@ const ui = { updateBreadcrumb(folderName) { const breadcrumb = document.querySelector('.breadcrumb'); breadcrumb.innerHTML = ''; - + + // Get user info to help determine home folder + const USER_DATA_KEY = 'oxicloud_user'; + const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); + const username = userData.username || ''; + + // Create the home item - for users, this is their personal folder const homeItem = document.createElement('span'); homeItem.className = 'breadcrumb-item'; - homeItem.textContent = window.i18n ? window.i18n.t('breadcrumb.home') : 'Home'; - homeItem.addEventListener('click', () => { - window.app.currentPath = ''; - this.updateBreadcrumb(''); - window.loadFiles(); - }); - + + // Helper function to safely get translation text + const getTranslatedText = (key, defaultValue) => { + if (!window.i18n || !window.i18n.t) return defaultValue; + return window.i18n.t(key); + }; + + // Set appropriate text for home item + if (username && folderName && folderName.includes(username)) { + // If the current folder is the user's home folder, label it as "Home" + homeItem.textContent = getTranslatedText('breadcrumb.home', 'Home'); + } else if (folderName && folderName.startsWith('Mi Carpeta')) { + // If the current folder is another user's home folder or a special folder, use its name + homeItem.textContent = folderName; + } else { + // Default - use "Home" label + homeItem.textContent = getTranslatedText('breadcrumb.home', 'Home'); + + // For searching, we might have a custom breadcrumb text + if (folderName && folderName.startsWith('Búsqueda:')) { + // We're in search mode - don't add click handler + breadcrumb.appendChild(homeItem); + return; + } + } + + // Add click handler - but only if we have a user home folder to return to + if (window.app.userHomeFolderId) { + homeItem.addEventListener('click', () => { + window.app.currentPath = window.app.userHomeFolderId; + this.updateBreadcrumb(window.app.userHomeFolderName || 'Home'); + window.loadFiles(); + }); + } + breadcrumb.appendChild(homeItem); - if (folderName) { + // If we have a subfolder, add it to the breadcrumb + if (folderName && !folderName.startsWith('Mi Carpeta') && !folderName.startsWith('Búsqueda:')) { const separator = document.createElement('span'); separator.className = 'breadcrumb-separator'; separator.textContent = '>'; diff --git a/static/locales/en.json b/static/locales/en.json index f2cb8e1d..e6fbaf56 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -67,6 +67,16 @@ "breadcrumb": { "home": "Home" }, + "trash": { + "empty_trash": "Empty Trash", + "empty_state": "Trash is empty", + "original_location": "Original location", + "deleted_date": "Deletion date", + "actions": "Actions", + "restore": "Restore", + "delete_permanently": "Delete permanently", + "empty_confirm": "Are you sure you want to empty the trash? This will permanently delete all items." + }, "auth": { "login_title": "Sign in", "username": "Username", diff --git a/static/locales/es.json b/static/locales/es.json index 4e4d3613..fd89e047 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -67,6 +67,16 @@ "breadcrumb": { "home": "Inicio" }, + "trash": { + "empty_trash": "Vaciar papelera", + "empty_state": "La papelera está vacía", + "original_location": "Ubicación original", + "deleted_date": "Fecha de eliminación", + "actions": "Acciones", + "restore": "Restaurar", + "delete_permanently": "Eliminar permanentemente", + "empty_confirm": "¿Estás seguro de que quieres vaciar la papelera? Esta acción eliminará permanentemente todos los elementos." + }, "auth": { "login_title": "Iniciar sesión", "username": "Usuario", diff --git a/storage/storage/Mi Carpeta - torrefacto/Dionisio Pozo_signed.pdf b/storage/storage/Mi Carpeta - torrefacto/Dionisio Pozo_signed.pdf new file mode 100644 index 00000000..8612beb3 Binary files /dev/null and b/storage/storage/Mi Carpeta - torrefacto/Dionisio Pozo_signed.pdf differ