fix(security): patch 3 vulnerabilities — IDOR, ownership bypass, XSS
V1: Add owner-scoped folder pagination (list_folders_by_owner_paginated) - New method in FolderRepository trait, PG implementation, service & handler - Prevents IDOR by filtering folder listings to authenticated user V2: Enforce ownership checks on folder mutations - rename_folder, move_folder, delete_folder now require caller_id - Service verifies folder.owner_id == caller_id (returns 404 on mismatch) - Propagated to folder_handler, batch_handler, batch_operations, webdav_handler - delete_folder_with_trash upgraded from OptionalAuthUser to AuthUser - download_folder_zip now checks ownership before streaming V3: Fix XSS in frontend via DOM APIs - sharedView.js: innerHTML → createElement + textContent - contextMenus.js: innerHTML → DOM construction for share dialog Cleanup: removed unused OptionalAuthUser import, updated all stubs/mocks
This commit is contained in:
@@ -38,15 +38,23 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
|||||||
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
|
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
|
||||||
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
|
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
|
||||||
|
|
||||||
/// Renames a folder
|
/// Lists folders with pagination, scoped to a specific owner.
|
||||||
async fn rename_folder(&self, id: &str, dto: RenameFolderDto)
|
async fn list_folders_for_owner_paginated(
|
||||||
|
&self,
|
||||||
|
parent_id: Option<&str>,
|
||||||
|
owner_id: &str,
|
||||||
|
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
|
||||||
|
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
|
||||||
|
|
||||||
|
/// Renames a folder (ownership verified against caller_id)
|
||||||
|
async fn rename_folder(&self, id: &str, dto: RenameFolderDto, caller_id: &str)
|
||||||
-> Result<FolderDto, DomainError>;
|
-> Result<FolderDto, DomainError>;
|
||||||
|
|
||||||
/// Moves a folder to another parent
|
/// Moves a folder to another parent (ownership verified against caller_id)
|
||||||
async fn move_folder(&self, id: &str, dto: MoveFolderDto) -> Result<FolderDto, DomainError>;
|
async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result<FolderDto, DomainError>;
|
||||||
|
|
||||||
/// Deletes a folder
|
/// Deletes a folder (ownership verified against caller_id)
|
||||||
async fn delete_folder(&self, id: &str) -> Result<(), DomainError>;
|
async fn delete_folder(&self, id: &str, caller_id: &str) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -408,6 +408,7 @@ impl BatchOperationService {
|
|||||||
&self,
|
&self,
|
||||||
folder_ids: Vec<String>,
|
folder_ids: Vec<String>,
|
||||||
_recursive: bool,
|
_recursive: bool,
|
||||||
|
caller_id: &str,
|
||||||
) -> Result<BatchResult<String>, BatchOperationError> {
|
) -> Result<BatchResult<String>, BatchOperationError> {
|
||||||
info!("Starting batch deletion of {} folders", folder_ids.len());
|
info!("Starting batch deletion of {} folders", folder_ids.len());
|
||||||
let start_time = std::time::Instant::now();
|
let start_time = std::time::Instant::now();
|
||||||
@@ -427,14 +428,13 @@ impl BatchOperationService {
|
|||||||
let folder_service = self.folder_service.clone();
|
let folder_service = self.folder_service.clone();
|
||||||
let semaphore = self.semaphore.clone();
|
let semaphore = self.semaphore.clone();
|
||||||
let id_clone = folder_id.clone();
|
let id_clone = folder_id.clone();
|
||||||
|
let caller = caller_id.to_string();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
// Acquire semaphore permit
|
// Acquire semaphore permit
|
||||||
let permit = semaphore.acquire().await.unwrap();
|
let permit = semaphore.acquire().await.unwrap();
|
||||||
|
|
||||||
// For both recursive and non-recursive, use the standard delete_folder method
|
let delete_result = folder_service.delete_folder(&folder_id, &caller).await;
|
||||||
// since FolderUseCase only has a single delete_folder method
|
|
||||||
let delete_result = folder_service.delete_folder(&folder_id).await;
|
|
||||||
|
|
||||||
// Release the permit explicitly
|
// Release the permit explicitly
|
||||||
drop(permit);
|
drop(permit);
|
||||||
@@ -616,6 +616,7 @@ impl BatchOperationService {
|
|||||||
&self,
|
&self,
|
||||||
folder_ids: Vec<String>,
|
folder_ids: Vec<String>,
|
||||||
target_folder_id: Option<String>,
|
target_folder_id: Option<String>,
|
||||||
|
caller_id: &str,
|
||||||
) -> Result<BatchResult<FolderDto>, BatchOperationError> {
|
) -> Result<BatchResult<FolderDto>, BatchOperationError> {
|
||||||
info!("Starting batch move of {} folders", folder_ids.len());
|
info!("Starting batch move of {} folders", folder_ids.len());
|
||||||
let start_time = std::time::Instant::now();
|
let start_time = std::time::Instant::now();
|
||||||
@@ -633,11 +634,12 @@ impl BatchOperationService {
|
|||||||
let folder_service = self.folder_service.clone();
|
let folder_service = self.folder_service.clone();
|
||||||
let target = target_folder_id.clone();
|
let target = target_folder_id.clone();
|
||||||
let semaphore = self.semaphore.clone();
|
let semaphore = self.semaphore.clone();
|
||||||
|
let caller = caller_id.to_string();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let permit = semaphore.acquire().await.unwrap();
|
let permit = semaphore.acquire().await.unwrap();
|
||||||
let dto = MoveFolderDto { parent_id: target };
|
let dto = MoveFolderDto { parent_id: target };
|
||||||
let move_result = folder_service.move_folder(&folder_id, dto).await;
|
let move_result = folder_service.move_folder(&folder_id, dto, &caller).await;
|
||||||
drop(permit);
|
drop(permit);
|
||||||
(folder_id, move_result)
|
(folder_id, move_result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,10 +71,30 @@ impl FolderService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_folders_for_owner_paginated(
|
||||||
|
&self,
|
||||||
|
_parent_id: Option<&str>,
|
||||||
|
_owner_id: &str,
|
||||||
|
_pagination: &crate::application::dtos::pagination::PaginationRequestDto,
|
||||||
|
) -> Result<
|
||||||
|
crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>,
|
||||||
|
DomainError,
|
||||||
|
> {
|
||||||
|
Ok(
|
||||||
|
crate::application::dtos::pagination::PaginatedResponseDto::new(
|
||||||
|
vec![],
|
||||||
|
0,
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn rename_folder(
|
async fn rename_folder(
|
||||||
&self,
|
&self,
|
||||||
_id: &str,
|
_id: &str,
|
||||||
_dto: RenameFolderDto,
|
_dto: RenameFolderDto,
|
||||||
|
_caller_id: &str,
|
||||||
) -> Result<FolderDto, DomainError> {
|
) -> Result<FolderDto, DomainError> {
|
||||||
Ok(FolderDto::empty())
|
Ok(FolderDto::empty())
|
||||||
}
|
}
|
||||||
@@ -83,11 +103,12 @@ impl FolderService {
|
|||||||
&self,
|
&self,
|
||||||
_id: &str,
|
_id: &str,
|
||||||
_dto: MoveFolderDto,
|
_dto: MoveFolderDto,
|
||||||
|
_caller_id: &str,
|
||||||
) -> Result<FolderDto, DomainError> {
|
) -> Result<FolderDto, DomainError> {
|
||||||
Ok(FolderDto::empty())
|
Ok(FolderDto::empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
|
async fn delete_folder(&self, _id: &str, _caller_id: &str) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,17 +232,15 @@ impl FolderUseCase for FolderService {
|
|||||||
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
|
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
|
||||||
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>
|
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>
|
||||||
{
|
{
|
||||||
// Validate and adjust pagination
|
|
||||||
let pagination = pagination.validate_and_adjust();
|
let pagination = pagination.validate_and_adjust();
|
||||||
|
|
||||||
// Get paginated folders and total count
|
|
||||||
let (folders, total_items) = self
|
let (folders, total_items) = self
|
||||||
.folder_storage
|
.folder_storage
|
||||||
.list_folders_paginated(
|
.list_folders_paginated(
|
||||||
parent_id,
|
parent_id,
|
||||||
pagination.offset(),
|
pagination.offset(),
|
||||||
pagination.limit(),
|
pagination.limit(),
|
||||||
true, // Always include total for better UX
|
true,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -234,10 +253,8 @@ impl FolderUseCase for FolderService {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// The total is needed to calculate pagination
|
|
||||||
let total = total_items.unwrap_or(folders.len());
|
let total = total_items.unwrap_or(folders.len());
|
||||||
|
|
||||||
// Convert to PaginatedResponseDto
|
|
||||||
let response = crate::application::dtos::pagination::PaginatedResponseDto::new(
|
let response = crate::application::dtos::pagination::PaginatedResponseDto::new(
|
||||||
folders.into_iter().map(FolderDto::from).collect(),
|
folders.into_iter().map(FolderDto::from).collect(),
|
||||||
pagination.page,
|
pagination.page,
|
||||||
@@ -248,11 +265,54 @@ impl FolderUseCase for FolderService {
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renames a folder
|
/// Lists folders with pagination, scoped to a specific owner.
|
||||||
|
async fn list_folders_for_owner_paginated(
|
||||||
|
&self,
|
||||||
|
parent_id: Option<&str>,
|
||||||
|
owner_id: &str,
|
||||||
|
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
|
||||||
|
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>
|
||||||
|
{
|
||||||
|
let pagination = pagination.validate_and_adjust();
|
||||||
|
|
||||||
|
let (folders, total_items) = self
|
||||||
|
.folder_storage
|
||||||
|
.list_folders_by_owner_paginated(
|
||||||
|
parent_id,
|
||||||
|
owner_id,
|
||||||
|
pagination.offset(),
|
||||||
|
pagination.limit(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error(
|
||||||
|
"FolderStorage",
|
||||||
|
format!(
|
||||||
|
"Failed to list folders for owner '{}' with pagination in parent {:?}: {}",
|
||||||
|
owner_id, parent_id, e
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let total = total_items.unwrap_or(folders.len());
|
||||||
|
|
||||||
|
let response = crate::application::dtos::pagination::PaginatedResponseDto::new(
|
||||||
|
folders.into_iter().map(FolderDto::from).collect(),
|
||||||
|
pagination.page,
|
||||||
|
pagination.page_size,
|
||||||
|
total,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames a folder after verifying ownership.
|
||||||
async fn rename_folder(
|
async fn rename_folder(
|
||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
dto: RenameFolderDto,
|
dto: RenameFolderDto,
|
||||||
|
caller_id: &str,
|
||||||
) -> Result<FolderDto, DomainError> {
|
) -> Result<FolderDto, DomainError> {
|
||||||
// Input validation
|
// Input validation
|
||||||
if dto.name.is_empty() {
|
if dto.name.is_empty() {
|
||||||
@@ -263,7 +323,7 @@ impl FolderUseCase for FolderService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the folder exists
|
// Verify the folder exists and belongs to the caller
|
||||||
let existing_folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
let existing_folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
||||||
DomainError::internal_error(
|
DomainError::internal_error(
|
||||||
"FolderStorage",
|
"FolderStorage",
|
||||||
@@ -271,6 +331,14 @@ impl FolderUseCase for FolderService {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if existing_folder.owner_id() != Some(caller_id) {
|
||||||
|
tracing::warn!(
|
||||||
|
"rename_folder: user '{}' attempted to rename folder '{}' owned by '{:?}'",
|
||||||
|
caller_id, id, existing_folder.owner_id()
|
||||||
|
);
|
||||||
|
return Err(DomainError::not_found("Folder", id));
|
||||||
|
}
|
||||||
|
|
||||||
// Create transaction for renaming
|
// Create transaction for renaming
|
||||||
let mut transaction = StorageTransaction::new("rename_folder");
|
let mut transaction = StorageTransaction::new("rename_folder");
|
||||||
|
|
||||||
@@ -323,9 +391,9 @@ impl FolderUseCase for FolderService {
|
|||||||
Ok(FolderDto::from(folder))
|
Ok(FolderDto::from(folder))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves a folder to a new parent
|
/// Moves a folder to a new parent after verifying ownership.
|
||||||
async fn move_folder(&self, id: &str, dto: MoveFolderDto) -> Result<FolderDto, DomainError> {
|
async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result<FolderDto, DomainError> {
|
||||||
// Verify the source folder exists
|
// Verify the source folder exists and belongs to the caller
|
||||||
let source_folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
let source_folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
||||||
DomainError::internal_error(
|
DomainError::internal_error(
|
||||||
"FolderStorage",
|
"FolderStorage",
|
||||||
@@ -333,6 +401,14 @@ impl FolderUseCase for FolderService {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if source_folder.owner_id() != Some(caller_id) {
|
||||||
|
tracing::warn!(
|
||||||
|
"move_folder: user '{}' attempted to move folder '{}' owned by '{:?}'",
|
||||||
|
caller_id, id, source_folder.owner_id()
|
||||||
|
);
|
||||||
|
return Err(DomainError::not_found("Folder", id));
|
||||||
|
}
|
||||||
|
|
||||||
// If a parent_id is specified, verify it exists
|
// If a parent_id is specified, verify it exists
|
||||||
if let Some(parent_id) = &dto.parent_id {
|
if let Some(parent_id) = &dto.parent_id {
|
||||||
// Verify we are not trying to move the folder into itself or one of its descendants
|
// Verify we are not trying to move the folder into itself or one of its descendants
|
||||||
@@ -408,17 +484,23 @@ impl FolderUseCase for FolderService {
|
|||||||
Ok(FolderDto::from(folder))
|
Ok(FolderDto::from(folder))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes a folder
|
/// Deletes a folder after verifying ownership.
|
||||||
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
|
async fn delete_folder(&self, id: &str, caller_id: &str) -> Result<(), DomainError> {
|
||||||
// Verify the folder exists
|
// Verify the folder exists and belongs to the caller
|
||||||
let _folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
let folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
||||||
DomainError::internal_error(
|
DomainError::internal_error(
|
||||||
"FolderStorage",
|
"FolderStorage",
|
||||||
format!("Failed to get folder with ID: {} for deletion: {}", id, e),
|
format!("Failed to get folder with ID: {} for deletion: {}", id, e),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// In a real implementation, we could verify permissions, dependencies, etc.
|
if folder.owner_id() != Some(caller_id) {
|
||||||
|
tracing::warn!(
|
||||||
|
"delete_folder: user '{}' attempted to delete folder '{}' owned by '{:?}'",
|
||||||
|
caller_id, id, folder.owner_id()
|
||||||
|
);
|
||||||
|
return Err(DomainError::not_found("Folder", id));
|
||||||
|
}
|
||||||
|
|
||||||
// Delete the folder
|
// Delete the folder
|
||||||
self.folder_storage.delete_folder(id).await.map_err(|e| {
|
self.folder_storage.delete_folder(id).await.map_err(|e| {
|
||||||
|
|||||||
@@ -544,6 +544,18 @@ mod tests {
|
|||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_folders_by_owner_paginated(
|
||||||
|
&self,
|
||||||
|
_parent_id: Option<&str>,
|
||||||
|
_owner_id: &str,
|
||||||
|
_offset: usize,
|
||||||
|
_limit: usize,
|
||||||
|
_include_total: bool,
|
||||||
|
) -> Result<(Vec<crate::domain::entities::folder::Folder>, Option<usize>), DomainError>
|
||||||
|
{
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn rename_folder(
|
async fn rename_folder(
|
||||||
&self,
|
&self,
|
||||||
_id: &str,
|
_id: &str,
|
||||||
|
|||||||
@@ -373,6 +373,17 @@ impl FolderRepository for MockFolderRepository {
|
|||||||
Ok((vec![], Some(0)))
|
Ok((vec![], Some(0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_folders_by_owner_paginated(
|
||||||
|
&self,
|
||||||
|
_parent_id: Option<&str>,
|
||||||
|
_owner_id: &str,
|
||||||
|
_offset: usize,
|
||||||
|
_limit: usize,
|
||||||
|
_include_total: bool,
|
||||||
|
) -> std::result::Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||||
|
Ok((vec![], Some(0)))
|
||||||
|
}
|
||||||
|
|
||||||
async fn rename_folder(
|
async fn rename_folder(
|
||||||
&self,
|
&self,
|
||||||
_id: &str,
|
_id: &str,
|
||||||
|
|||||||
+23
-2
@@ -268,6 +268,17 @@ impl FolderRepository for StubFolderStoragePort {
|
|||||||
Ok((Vec::new(), Some(0)))
|
Ok((Vec::new(), Some(0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_folders_by_owner_paginated(
|
||||||
|
&self,
|
||||||
|
_parent_id: Option<&str>,
|
||||||
|
_owner_id: &str,
|
||||||
|
_offset: usize,
|
||||||
|
_limit: usize,
|
||||||
|
_include_total: bool,
|
||||||
|
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||||
|
Ok((Vec::new(), Some(0)))
|
||||||
|
}
|
||||||
|
|
||||||
async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<Folder, DomainError> {
|
async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<Folder, DomainError> {
|
||||||
Ok(Folder::default())
|
Ok(Folder::default())
|
||||||
}
|
}
|
||||||
@@ -374,19 +385,29 @@ impl FolderUseCase for StubFolderUseCase {
|
|||||||
Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0))
|
Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_folders_for_owner_paginated(
|
||||||
|
&self,
|
||||||
|
_parent_id: Option<&str>,
|
||||||
|
_owner_id: &str,
|
||||||
|
_pagination: &PaginationRequestDto,
|
||||||
|
) -> Result<PaginatedResponseDto<FolderDto>, DomainError> {
|
||||||
|
Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0))
|
||||||
|
}
|
||||||
|
|
||||||
async fn rename_folder(
|
async fn rename_folder(
|
||||||
&self,
|
&self,
|
||||||
_id: &str,
|
_id: &str,
|
||||||
_dto: RenameFolderDto,
|
_dto: RenameFolderDto,
|
||||||
|
_caller_id: &str,
|
||||||
) -> Result<FolderDto, DomainError> {
|
) -> Result<FolderDto, DomainError> {
|
||||||
Ok(FolderDto::default())
|
Ok(FolderDto::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn move_folder(&self, _id: &str, _dto: MoveFolderDto) -> Result<FolderDto, DomainError> {
|
async fn move_folder(&self, _id: &str, _dto: MoveFolderDto, _caller_id: &str) -> Result<FolderDto, DomainError> {
|
||||||
Ok(FolderDto::default())
|
Ok(FolderDto::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
|
async fn delete_folder(&self, _id: &str, _caller_id: &str) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ pub trait FolderRepository: Send + Sync + 'static {
|
|||||||
include_total: bool,
|
include_total: bool,
|
||||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
|
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
|
||||||
|
|
||||||
|
/// Lists folders with pagination, scoped to a specific owner.
|
||||||
|
/// Combines the owner filtering of `list_folders_by_owner` with
|
||||||
|
/// the pagination of `list_folders_paginated`.
|
||||||
|
async fn list_folders_by_owner_paginated(
|
||||||
|
&self,
|
||||||
|
parent_id: Option<&str>,
|
||||||
|
owner_id: &str,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
include_total: bool,
|
||||||
|
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
|
||||||
|
|
||||||
/// Renames a folder
|
/// Renames a folder
|
||||||
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError>;
|
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError>;
|
||||||
|
|
||||||
|
|||||||
@@ -369,6 +369,82 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
Ok((folders, total))
|
Ok((folders, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_folders_by_owner_paginated(
|
||||||
|
&self,
|
||||||
|
parent_id: Option<&str>,
|
||||||
|
owner_id: &str,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
include_total: bool,
|
||||||
|
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||||
|
let total = if include_total {
|
||||||
|
let count: i64 = if let Some(pid) = parent_id {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM storage.folders WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed",
|
||||||
|
)
|
||||||
|
.bind(pid)
|
||||||
|
.bind(owner_id)
|
||||||
|
.fetch_one(self.pool())
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM storage.folders WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed",
|
||||||
|
)
|
||||||
|
.bind(owner_id)
|
||||||
|
.fetch_one(self.pool())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("count_by_owner: {e}")))?;
|
||||||
|
Some(count as usize)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows: Vec<(String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||||
|
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
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(pid)
|
||||||
|
.bind(owner_id)
|
||||||
|
.bind(limit as i64)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(self.pool())
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(owner_id)
|
||||||
|
.bind(limit as i64)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(self.pool())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_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, total))
|
||||||
|
}
|
||||||
|
|
||||||
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError> {
|
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ pub async fn delete_files_batch(
|
|||||||
/// Handler for deleting multiple folders in batch
|
/// Handler for deleting multiple folders in batch
|
||||||
pub async fn delete_folders_batch(
|
pub async fn delete_folders_batch(
|
||||||
State(state): State<BatchHandlerState>,
|
State(state): State<BatchHandlerState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
Json(request): Json<BatchFolderOperationRequest>,
|
Json(request): Json<BatchFolderOperationRequest>,
|
||||||
) -> ApiResult<impl IntoResponse> {
|
) -> ApiResult<impl IntoResponse> {
|
||||||
// Verify there are folders to process
|
// Verify there are folders to process
|
||||||
@@ -274,7 +275,7 @@ pub async fn delete_folders_batch(
|
|||||||
// Execute batch operation
|
// Execute batch operation
|
||||||
let result = state
|
let result = state
|
||||||
.batch_service
|
.batch_service
|
||||||
.delete_folders(request.folder_ids, request.recursive)
|
.delete_folders(request.folder_ids, request.recursive, &auth_user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
@@ -555,6 +556,7 @@ pub async fn trash_batch(
|
|||||||
/// Handler for moving multiple folders in batch
|
/// Handler for moving multiple folders in batch
|
||||||
pub async fn move_folders_batch(
|
pub async fn move_folders_batch(
|
||||||
State(state): State<BatchHandlerState>,
|
State(state): State<BatchHandlerState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
Json(request): Json<BatchFolderOperationRequest>,
|
Json(request): Json<BatchFolderOperationRequest>,
|
||||||
) -> ApiResult<impl IntoResponse> {
|
) -> ApiResult<impl IntoResponse> {
|
||||||
if request.folder_ids.is_empty() {
|
if request.folder_ids.is_empty() {
|
||||||
@@ -569,7 +571,7 @@ pub async fn move_folders_batch(
|
|||||||
|
|
||||||
let result = state
|
let result = state
|
||||||
.batch_service
|
.batch_service
|
||||||
.move_folders(request.folder_ids, request.target_folder_id)
|
.move_folders(request.folder_ids, request.target_folder_id, &auth_user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use crate::application::ports::inbound::FolderUseCase;
|
|||||||
use crate::application::services::folder_service::FolderService;
|
use crate::application::services::folder_service::FolderService;
|
||||||
use crate::common::di::AppState as GlobalAppState;
|
use crate::common::di::AppState as GlobalAppState;
|
||||||
use crate::common::errors::ErrorKind;
|
use crate::common::errors::ErrorKind;
|
||||||
use crate::interfaces::middleware::auth::{AuthUser, OptionalAuthUser};
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
|
|
||||||
type AppState = Arc<FolderService>;
|
type AppState = Arc<FolderService>;
|
||||||
|
|
||||||
@@ -136,15 +136,14 @@ impl FolderHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Lists contents of a specific folder with pagination.
|
/// Lists contents of a specific folder with pagination.
|
||||||
|
/// Scoped to the authenticated user — only returns folders owned by this user.
|
||||||
pub async fn list_folder_contents_paginated(
|
pub async fn list_folder_contents_paginated(
|
||||||
State(service): State<AppState>,
|
State(service): State<AppState>,
|
||||||
_auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
pagination: Query<PaginationRequestDto>,
|
pagination: Query<PaginationRequestDto>,
|
||||||
) -> axum::response::Response {
|
) -> axum::response::Response {
|
||||||
// For sub-folder pagination, use the standard paginated path
|
match service.list_folders_for_owner_paginated(Some(&id), &auth_user.id, &pagination).await {
|
||||||
// (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(),
|
Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let status = match err.kind {
|
let status = match err.kind {
|
||||||
@@ -184,13 +183,14 @@ impl FolderHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renames a folder
|
/// Renames a folder (ownership enforced by service layer)
|
||||||
pub async fn rename_folder(
|
pub async fn rename_folder(
|
||||||
State(service): State<AppState>,
|
State(service): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(dto): Json<RenameFolderDto>,
|
Json(dto): Json<RenameFolderDto>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match service.rename_folder(&id, dto).await {
|
match service.rename_folder(&id, dto, &auth_user.id).await {
|
||||||
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let status = match err.kind {
|
let status = match err.kind {
|
||||||
@@ -211,13 +211,14 @@ impl FolderHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves a folder to a new parent
|
/// Moves a folder to a new parent (ownership enforced by service layer)
|
||||||
pub async fn move_folder(
|
pub async fn move_folder(
|
||||||
State(service): State<AppState>,
|
State(service): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(dto): Json<MoveFolderDto>,
|
Json(dto): Json<MoveFolderDto>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match service.move_folder(&id, dto).await {
|
match service.move_folder(&id, dto, &auth_user.id).await {
|
||||||
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let status = match err.kind {
|
let status = match err.kind {
|
||||||
@@ -231,13 +232,13 @@ impl FolderHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes a folder (with trash support)
|
/// Deletes a folder (ownership enforced by service layer)
|
||||||
pub async fn delete_folder(
|
pub async fn delete_folder(
|
||||||
State(service): State<AppState>,
|
State(service): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// For folder deletion without trash functionality
|
match service.delete_folder(&id, &auth_user.id).await {
|
||||||
match service.delete_folder(&id).await {
|
|
||||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let status = match err.kind {
|
let status = match err.kind {
|
||||||
@@ -250,16 +251,13 @@ impl FolderHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes a folder with trash functionality
|
/// Deletes a folder with trash functionality (ownership enforced by service layer)
|
||||||
pub async fn delete_folder_with_trash(
|
pub async fn delete_folder_with_trash(
|
||||||
State(state): State<GlobalAppState>,
|
State(state): State<GlobalAppState>,
|
||||||
OptionalAuthUser(auth_user): OptionalAuthUser,
|
auth_user: AuthUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let user_id = auth_user
|
let user_id = &auth_user.id;
|
||||||
.as_ref()
|
|
||||||
.map(|u| u.id.as_str())
|
|
||||||
.unwrap_or("anonymous");
|
|
||||||
// Check if trash service is available
|
// Check if trash service is available
|
||||||
if let Some(trash_service) = &state.trash_service {
|
if let Some(trash_service) = &state.trash_service {
|
||||||
tracing::info!("Moving folder to trash: {}", id);
|
tracing::info!("Moving folder to trash: {}", id);
|
||||||
@@ -282,7 +280,7 @@ impl FolderHandler {
|
|||||||
|
|
||||||
// Fallback to permanent delete if trash is unavailable or failed
|
// Fallback to permanent delete if trash is unavailable or failed
|
||||||
let folder_service = &state.applications.folder_service;
|
let folder_service = &state.applications.folder_service;
|
||||||
match folder_service.delete_folder(&id).await {
|
match folder_service.delete_folder(&id, user_id).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tracing::info!("Folder permanently deleted: {}", id);
|
tracing::info!("Folder permanently deleted: {}", id);
|
||||||
StatusCode::NO_CONTENT.into_response()
|
StatusCode::NO_CONTENT.into_response()
|
||||||
@@ -306,19 +304,32 @@ impl FolderHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads a folder as a ZIP file
|
/// Downloads a folder as a ZIP file (ownership enforced)
|
||||||
pub async fn download_folder_zip(
|
pub async fn download_folder_zip(
|
||||||
State(state): State<GlobalAppState>,
|
State(state): State<GlobalAppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Query(_params): Query<HashMap<String, String>>,
|
Query(_params): Query<HashMap<String, String>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
tracing::info!("Downloading folder as ZIP: {}", id);
|
tracing::info!("Downloading folder as ZIP: {}", id);
|
||||||
|
|
||||||
// Get folder information first to check it exists and get name
|
// Get folder information and verify ownership
|
||||||
let folder_service = &state.applications.folder_service;
|
let folder_service = &state.applications.folder_service;
|
||||||
|
|
||||||
match folder_service.get_folder(&id).await {
|
match folder_service.get_folder(&id).await {
|
||||||
Ok(folder) => {
|
Ok(folder) => {
|
||||||
|
// Access check: folder must belong to the requesting user
|
||||||
|
if folder.owner_id.as_deref() != Some(&auth_user.id) {
|
||||||
|
tracing::warn!(
|
||||||
|
"download_folder_zip: user '{}' attempted to download folder '{}' owned by '{:?}'",
|
||||||
|
auth_user.id, id, folder.owner_id
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({ "error": "Folder not found" })),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id);
|
tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id);
|
||||||
|
|
||||||
// Use ZIP service from DI container
|
// Use ZIP service from DI container
|
||||||
|
|||||||
@@ -645,9 +645,10 @@ async fn handle_delete(
|
|||||||
let folder_result = folder_service.get_folder_by_path(&path).await;
|
let folder_result = folder_service.get_folder_by_path(&path).await;
|
||||||
|
|
||||||
if let Ok(folder) = folder_result {
|
if let Ok(folder) = folder_result {
|
||||||
// Delete folder
|
// Delete folder — use the folder's own owner as caller_id
|
||||||
|
let caller_id = folder.owner_id.as_deref().unwrap_or("webdav");
|
||||||
folder_service
|
folder_service
|
||||||
.delete_folder(&folder.id)
|
.delete_folder(&folder.id, caller_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
|
||||||
} else {
|
} else {
|
||||||
@@ -761,7 +762,7 @@ async fn handle_move(
|
|||||||
};
|
};
|
||||||
|
|
||||||
folder_service
|
folder_service
|
||||||
.move_folder(&folder.id, move_dto)
|
.move_folder(&folder.id, move_dto, folder.owner_id.as_deref().unwrap_or("webdav"))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?;
|
||||||
|
|
||||||
@@ -771,7 +772,7 @@ async fn handle_move(
|
|||||||
};
|
};
|
||||||
|
|
||||||
folder_service
|
folder_service
|
||||||
.rename_folder(&folder.id, rename_dto)
|
.rename_folder(&folder.id, rename_dto, folder.owner_id.as_deref().unwrap_or("webdav"))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?;
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-2
@@ -3919,7 +3919,27 @@ html[dir='rtl'] .fa-sign-out-alt {
|
|||||||
/* Checked via JS */
|
/* Checked via JS */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- Batch action bar -- */
|
/* ── Selection-mode header (replaces Name/Type/Size/Modified) ── */
|
||||||
|
.list-header.selection-mode {
|
||||||
|
grid-template-columns: 36px 1fr;
|
||||||
|
background-color: #1e293b;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header.selection-mode .list-header-checkbox input[type="checkbox"] {
|
||||||
|
accent-color: #ff5e3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-selection-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Batch action bar (grid view floating bar) -- */
|
||||||
.batch-action-bar {
|
.batch-action-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -4720,11 +4740,16 @@ html[dir='rtl'] .fa-sign-out-alt {
|
|||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
/* Batch bar dark mode */
|
/* Batch bar dark mode */
|
||||||
[data-theme="dark"] .batch-bar {
|
[data-theme="dark"] .batch-action-bar {
|
||||||
background-color: #1e293b;
|
background-color: #1e293b;
|
||||||
border-color: #334155;
|
border-color: #334155;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
[data-theme="dark"] .list-header.selection-mode {
|
||||||
|
background-color: #0f172a;
|
||||||
|
border-bottom-color: #334155;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
/* Search results dark mode */
|
/* Search results dark mode */
|
||||||
[data-theme="dark"] .search-results-header {
|
[data-theme="dark"] .search-results-header {
|
||||||
color: #f1f5f9;
|
color: #f1f5f9;
|
||||||
|
|||||||
+3
-2
@@ -678,10 +678,11 @@ function setupEventListeners() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Deselect all cards when clicking empty area (not on a card, menu, or modal)
|
// Deselect all cards when clicking empty area (not on a card, menu, or modal)
|
||||||
if (!e.target.closest('.file-card') && !e.target.closest('.file-item') && !e.target.closest('.context-menu') && !e.target.closest('.about-modal') && !e.target.closest('.batch-action-bar')) {
|
// Note: multiSelect._hookGlobalDeselect() handles clearing the internal
|
||||||
|
// selection state; this handler only covers the legacy CSS class removal.
|
||||||
|
if (!e.target.closest('.file-card') && !e.target.closest('.file-item') && !e.target.closest('.context-menu') && !e.target.closest('.about-modal') && !e.target.closest('.batch-action-bar') && !e.target.closest('.list-header.selection-mode')) {
|
||||||
document.querySelectorAll('.file-card.selected').forEach(c => c.classList.remove('selected'));
|
document.querySelectorAll('.file-card.selected').forEach(c => c.classList.remove('selected'));
|
||||||
document.querySelectorAll('.file-item.selected').forEach(c => c.classList.remove('selected'));
|
document.querySelectorAll('.file-item.selected').forEach(c => c.classList.remove('selected'));
|
||||||
if (window.multiSelect) window.multiSelect.clear();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,7 +292,13 @@ const sharedView = {
|
|||||||
|
|
||||||
const nameCell = document.createElement('td');
|
const nameCell = document.createElement('td');
|
||||||
nameCell.className = 'shared-item-name';
|
nameCell.className = 'shared-item-name';
|
||||||
nameCell.innerHTML = `<span class="item-icon">${item.item_type === 'file' ? '📄' : '📁'}</span><span>${displayName}</span>`;
|
const iconSpan = document.createElement('span');
|
||||||
|
iconSpan.className = 'item-icon';
|
||||||
|
iconSpan.textContent = item.item_type === 'file' ? '📄' : '📁';
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.textContent = displayName;
|
||||||
|
nameCell.appendChild(iconSpan);
|
||||||
|
nameCell.appendChild(nameSpan);
|
||||||
|
|
||||||
const typeCell = document.createElement('td');
|
const typeCell = document.createElement('td');
|
||||||
typeCell.textContent = item.item_type === 'file' ? this.translate('shared_typeFile', 'File') : this.translate('shared_typeFolder', 'Folder');
|
typeCell.textContent = item.item_type === 'file' ? this.translate('shared_typeFile', 'File') : this.translate('shared_typeFolder', 'Folder');
|
||||||
|
|||||||
+38
-15
@@ -543,21 +543,44 @@ const contextMenus = {
|
|||||||
`Expires: ${window.fileSharing.formatExpirationDate(share.expires_at)}` :
|
`Expires: ${window.fileSharing.formatExpirationDate(share.expires_at)}` :
|
||||||
'No expiration';
|
'No expiration';
|
||||||
|
|
||||||
shareEl.innerHTML = `
|
// Share URL
|
||||||
<div class="share-url">${share.url}</div>
|
const urlDiv = document.createElement('div');
|
||||||
<div class="share-info">
|
urlDiv.className = 'share-url';
|
||||||
${share.has_password ? '<span class="share-protected"><i class="fas fa-lock"></i> Password protected</span>' : ''}
|
urlDiv.textContent = share.url;
|
||||||
<span class="share-expiration">${expiresText}</span>
|
shareEl.appendChild(urlDiv);
|
||||||
</div>
|
|
||||||
<div class="share-actions">
|
// Share info
|
||||||
<button class="btn btn-small copy-link-btn" data-share-url="${share.url}">
|
const infoDiv = document.createElement('div');
|
||||||
<i class="fas fa-copy"></i> Copy
|
infoDiv.className = 'share-info';
|
||||||
</button>
|
if (share.has_password) {
|
||||||
<button class="btn btn-small btn-danger delete-link-btn" data-share-id="${share.id}">
|
const protectedSpan = document.createElement('span');
|
||||||
<i class="fas fa-trash"></i> Delete
|
protectedSpan.className = 'share-protected';
|
||||||
</button>
|
protectedSpan.innerHTML = '<i class="fas fa-lock"></i> Password protected';
|
||||||
</div>
|
infoDiv.appendChild(protectedSpan);
|
||||||
`;
|
}
|
||||||
|
const expirationSpan = document.createElement('span');
|
||||||
|
expirationSpan.className = 'share-expiration';
|
||||||
|
expirationSpan.textContent = expiresText;
|
||||||
|
infoDiv.appendChild(expirationSpan);
|
||||||
|
shareEl.appendChild(infoDiv);
|
||||||
|
|
||||||
|
// Share actions
|
||||||
|
const actionsDiv = document.createElement('div');
|
||||||
|
actionsDiv.className = 'share-actions';
|
||||||
|
|
||||||
|
const copyBtn = document.createElement('button');
|
||||||
|
copyBtn.className = 'btn btn-small copy-link-btn';
|
||||||
|
copyBtn.dataset.shareUrl = share.url;
|
||||||
|
copyBtn.innerHTML = '<i class="fas fa-copy"></i> Copy';
|
||||||
|
actionsDiv.appendChild(copyBtn);
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.className = 'btn btn-small btn-danger delete-link-btn';
|
||||||
|
deleteBtn.dataset.shareId = share.id;
|
||||||
|
deleteBtn.innerHTML = '<i class="fas fa-trash"></i> Delete';
|
||||||
|
actionsDiv.appendChild(deleteBtn);
|
||||||
|
|
||||||
|
shareEl.appendChild(actionsDiv);
|
||||||
|
|
||||||
existingSharesContainer.appendChild(shareEl);
|
existingSharesContainer.appendChild(shareEl);
|
||||||
});
|
});
|
||||||
|
|||||||
+239
-193
@@ -1,89 +1,75 @@
|
|||||||
/**
|
/**
|
||||||
* OxiCloud - Multi-Select & Batch Actions Module
|
* OxiCloud - Multi-Select & Batch Actions Module
|
||||||
* Adds checkboxes to both grid and list views, a batch action bar,
|
*
|
||||||
* and batch delete / move / download operations.
|
* Adds checkboxes to grid and list views, replaces the list-view header
|
||||||
|
* with a NextCloud-style selection bar when items are selected, and
|
||||||
|
* provides batch delete / move / download / favorites operations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const multiSelect = {
|
const multiSelect = {
|
||||||
/** Currently selected items: { id, name, type: 'file'|'folder', parentId } */
|
/** Currently selected items: Map<id, { id, name, type, parentId }> */
|
||||||
_selected: new Map(),
|
_selected: new Map(),
|
||||||
|
|
||||||
/** Last clicked index for Shift-range selection */
|
/** Last clicked index for Shift-range selection */
|
||||||
_lastClickedIndex: -1,
|
_lastClickedIndex: -1,
|
||||||
|
|
||||||
/** Whether the batch bar is currently visible */
|
/** Whether the selection bar is currently visible */
|
||||||
_barVisible: false,
|
_barVisible: false,
|
||||||
|
|
||||||
|
/** Saved original list-header HTML so we can restore it */
|
||||||
|
_savedHeaderHTML: '',
|
||||||
|
|
||||||
// ── Public API ──────────────────────────────────────────
|
// ── Public API ──────────────────────────────────────────
|
||||||
|
|
||||||
/** Number of selected items */
|
get count() { return this._selected.size; },
|
||||||
get count() { return this._selected.size; },
|
get items() { return Array.from(this._selected.values()); },
|
||||||
|
|
||||||
/** All selected items as an array */
|
|
||||||
get items() { return Array.from(this._selected.values()); },
|
|
||||||
|
|
||||||
/** True when at least one item is selected */
|
|
||||||
get hasSelection() { return this._selected.size > 0; },
|
get hasSelection() { return this._selected.size > 0; },
|
||||||
|
get files() { return this.items.filter(i => i.type === 'file'); },
|
||||||
|
get folders() { return this.items.filter(i => i.type === 'folder'); },
|
||||||
|
|
||||||
/** Get selected files only */
|
// ── Helpers for i18n ────────────────────────────────────
|
||||||
get files() { return this.items.filter(i => i.type === 'file'); },
|
|
||||||
|
|
||||||
/** Get selected folders only */
|
_t(key, vars) {
|
||||||
get folders() { return this.items.filter(i => i.type === 'folder'); },
|
if (window.i18n && typeof window.i18n.t === 'function') {
|
||||||
|
const val = window.i18n.t(key, vars);
|
||||||
|
// If i18n returned the key itself, it's missing → fall back
|
||||||
|
if (val && val !== key) return val;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Selection state management ──────────────────────────
|
// ── Selection state management ──────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle an item in the selection.
|
|
||||||
* @param {string} id
|
|
||||||
* @param {string} name
|
|
||||||
* @param {'file'|'folder'} type
|
|
||||||
* @param {string} parentId parent / folder id
|
|
||||||
* @returns {boolean} new selected state
|
|
||||||
*/
|
|
||||||
toggle(id, name, type, parentId) {
|
toggle(id, name, type, parentId) {
|
||||||
if (this._selected.has(id)) {
|
if (this._selected.has(id)) { this._selected.delete(id); return false; }
|
||||||
this._selected.delete(id);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this._selected.set(id, { id, name, type, parentId });
|
this._selected.set(id, { id, name, type, parentId });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Select a single item (add if not present) */
|
|
||||||
select(id, name, type, parentId) {
|
select(id, name, type, parentId) {
|
||||||
this._selected.set(id, { id, name, type, parentId });
|
this._selected.set(id, { id, name, type, parentId });
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Deselect a single item */
|
deselect(id) { this._selected.delete(id); },
|
||||||
deselect(id) {
|
|
||||||
this._selected.delete(id);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Clear the whole selection */
|
|
||||||
clear() {
|
clear() {
|
||||||
this._selected.clear();
|
this._selected.clear();
|
||||||
this._lastClickedIndex = -1;
|
this._lastClickedIndex = -1;
|
||||||
// Remove visual state from DOM
|
document.querySelectorAll('.file-card.selected, .file-item.selected')
|
||||||
document.querySelectorAll('.file-card.selected, .file-item.selected').forEach(el => {
|
.forEach(el => el.classList.remove('selected'));
|
||||||
el.classList.remove('selected');
|
|
||||||
});
|
|
||||||
// Uncheck all item checkboxes
|
|
||||||
document.querySelectorAll('.item-checkbox').forEach(cb => cb.checked = false);
|
document.querySelectorAll('.item-checkbox').forEach(cb => cb.checked = false);
|
||||||
this._syncUI();
|
this._syncUI();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Select all visible items */
|
|
||||||
selectAll() {
|
selectAll() {
|
||||||
this._selectAllInContainer('files-grid', '.file-card');
|
this._selectAllInContainer('files-grid', '.file-card');
|
||||||
this._selectAllInContainer('files-list-view', '.file-item');
|
this._selectAllInContainer('files-list-view', '.file-item');
|
||||||
this._syncUI();
|
this._syncUI();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Deselect/select all toggle */
|
|
||||||
toggleAll() {
|
toggleAll() {
|
||||||
const allItems = this._getAllVisibleItems();
|
const allItems = this._getAllVisibleItems();
|
||||||
if (this._selected.size === allItems.length && allItems.length > 0) {
|
if (this._selected.size >= allItems.length && allItems.length > 0) {
|
||||||
this.clear();
|
this.clear();
|
||||||
} else {
|
} else {
|
||||||
this.selectAll();
|
this.selectAll();
|
||||||
@@ -92,7 +78,6 @@ const multiSelect = {
|
|||||||
|
|
||||||
// ── DOM helpers ─────────────────────────────────────────
|
// ── DOM helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
/** Gather info from a DOM element and add to selection */
|
|
||||||
_selectElement(el) {
|
_selectElement(el) {
|
||||||
const info = this._extractInfo(el);
|
const info = this._extractInfo(el);
|
||||||
if (info) {
|
if (info) {
|
||||||
@@ -108,62 +93,41 @@ const multiSelect = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_getAllVisibleItems() {
|
_getAllVisibleItems() {
|
||||||
const gridItems = [...document.querySelectorAll('#files-grid .file-card')];
|
|
||||||
const listItems = [...document.querySelectorAll('#files-list-view .file-item')];
|
|
||||||
// Only return items from the currently visible view
|
|
||||||
const grid = document.getElementById('files-grid');
|
const grid = document.getElementById('files-grid');
|
||||||
if (grid && grid.style.display !== 'none') return gridItems;
|
if (grid && grid.style.display !== 'none') {
|
||||||
return listItems;
|
return [...grid.querySelectorAll('.file-card')];
|
||||||
|
}
|
||||||
|
return [...document.querySelectorAll('#files-list-view .file-item')];
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Extract item info from a DOM element */
|
|
||||||
_extractInfo(el) {
|
_extractInfo(el) {
|
||||||
if (el.dataset.folderId && el.dataset.folderName !== undefined) {
|
if (el.dataset.folderId && el.dataset.folderName !== undefined) {
|
||||||
return {
|
return { id: el.dataset.folderId, name: el.dataset.folderName, type: 'folder', parentId: el.dataset.parentId || '' };
|
||||||
id: el.dataset.folderId,
|
|
||||||
name: el.dataset.folderName,
|
|
||||||
type: 'folder',
|
|
||||||
parentId: el.dataset.parentId || ''
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (el.dataset.fileId) {
|
if (el.dataset.fileId) {
|
||||||
return {
|
return { id: el.dataset.fileId, name: el.dataset.fileName, type: 'file', parentId: el.dataset.folderId || '' };
|
||||||
id: el.dataset.fileId,
|
|
||||||
name: el.dataset.fileName,
|
|
||||||
type: 'file',
|
|
||||||
parentId: el.dataset.folderId || ''
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Click handler (shared by grid + list) ───────────────
|
// ── Click handler (shared by grid + list) ───────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a checkbox/selection click on an item element.
|
|
||||||
* Supports Shift-click for range selection.
|
|
||||||
*/
|
|
||||||
handleItemClick(el, event) {
|
handleItemClick(el, event) {
|
||||||
const items = this._getAllVisibleItems();
|
const items = this._getAllVisibleItems();
|
||||||
const index = items.indexOf(el);
|
const index = items.indexOf(el);
|
||||||
|
|
||||||
// Also find the matching element in the other view
|
|
||||||
const info = this._extractInfo(el);
|
const info = this._extractInfo(el);
|
||||||
if (!info) return;
|
if (!info) return;
|
||||||
|
|
||||||
const selectorOther = info.type === 'folder'
|
const selectorOther = info.type === 'folder'
|
||||||
? `[data-folder-id="${info.id}"]`
|
? `[data-folder-id="${info.id}"]`
|
||||||
: `[data-file-id="${info.id}"]`;
|
: `[data-file-id="${info.id}"]`;
|
||||||
const otherEl = [...document.querySelectorAll(selectorOther)]
|
const otherEl = [...document.querySelectorAll(selectorOther)].find(e => e !== el);
|
||||||
.find(e => e !== el);
|
|
||||||
|
|
||||||
if (event && event.shiftKey && this._lastClickedIndex >= 0 && index >= 0) {
|
if (event && event.shiftKey && this._lastClickedIndex >= 0 && index >= 0) {
|
||||||
// Range selection
|
|
||||||
const start = Math.min(this._lastClickedIndex, index);
|
const start = Math.min(this._lastClickedIndex, index);
|
||||||
const end = Math.max(this._lastClickedIndex, index);
|
const end = Math.max(this._lastClickedIndex, index);
|
||||||
for (let i = start; i <= end; i++) {
|
for (let i = start; i <= end; i++) {
|
||||||
this._selectElement(items[i]);
|
this._selectElement(items[i]);
|
||||||
// Mirror to other view
|
|
||||||
const iInfo = this._extractInfo(items[i]);
|
const iInfo = this._extractInfo(items[i]);
|
||||||
if (iInfo) {
|
if (iInfo) {
|
||||||
const sel = iInfo.type === 'folder'
|
const sel = iInfo.type === 'folder'
|
||||||
@@ -173,96 +137,184 @@ const multiSelect = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal toggle
|
|
||||||
const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId);
|
const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId);
|
||||||
el.classList.toggle('selected', nowSelected);
|
el.classList.toggle('selected', nowSelected);
|
||||||
if (otherEl) otherEl.classList.toggle('selected', nowSelected);
|
if (otherEl) otherEl.classList.toggle('selected', nowSelected);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._lastClickedIndex = index;
|
this._lastClickedIndex = index;
|
||||||
this._syncUI();
|
this._syncUI();
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Batch action bar ────────────────────────────────────
|
// ── Selection bar (replaces list-header when items selected) ────
|
||||||
|
|
||||||
/** Create the batch action bar if it doesn't exist */
|
/**
|
||||||
_ensureBar() {
|
* Build the inner HTML for the selection bar that replaces the
|
||||||
if (document.getElementById('batch-action-bar')) return;
|
* normal list-header columns (Name / Type / Size / Modified).
|
||||||
|
*/
|
||||||
|
_buildSelectionBarHTML(n) {
|
||||||
|
const countText = n === 1
|
||||||
|
? (this._t('batch.one_selected') || '1 item selected')
|
||||||
|
: (this._t('batch.n_selected', { count: n }) || `${n} items selected`);
|
||||||
|
|
||||||
const bar = document.createElement('div');
|
const favLabel = this._t('batch.add_favorites') || 'Add to favorites';
|
||||||
bar.id = 'batch-action-bar';
|
const moveLabel = this._t('batch.move_copy') || 'Move or copy';
|
||||||
bar.className = 'batch-action-bar';
|
const dlLabel = this._t('actions.download') || 'Download';
|
||||||
bar.innerHTML = `
|
const delLabel = this._t('actions.delete') || 'Delete';
|
||||||
<div class="batch-bar-left">
|
|
||||||
<button class="batch-bar-close" id="batch-bar-close" title="Cancel selection">
|
return `
|
||||||
<i class="fas fa-times"></i>
|
<div class="list-header-checkbox">
|
||||||
</button>
|
<input type="checkbox" id="select-all-checkbox" title="Toggle all" checked>
|
||||||
<span class="batch-bar-count" id="batch-bar-count">0 selected</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="batch-bar-actions">
|
<div class="batch-selection-info">
|
||||||
<button class="batch-btn" id="batch-download" title="Download">
|
<span class="batch-bar-count">${countText}</span>
|
||||||
<i class="fas fa-download"></i>
|
<div class="batch-bar-actions">
|
||||||
<span data-i18n="actions.download">Download</span>
|
<button class="batch-btn" id="batch-fav" title="${favLabel}">
|
||||||
</button>
|
<i class="fas fa-star"></i>
|
||||||
<button class="batch-btn" id="batch-move" title="Move">
|
<span>${favLabel}</span>
|
||||||
<i class="fas fa-arrows-alt"></i>
|
</button>
|
||||||
<span data-i18n="actions.move">Move</span>
|
<button class="batch-btn" id="batch-move" title="${moveLabel}">
|
||||||
</button>
|
<i class="fas fa-arrows-alt"></i>
|
||||||
<button class="batch-btn batch-btn-danger" id="batch-delete" title="Delete">
|
<span>${moveLabel}</span>
|
||||||
<i class="fas fa-trash-alt"></i>
|
</button>
|
||||||
<span data-i18n="actions.delete">Delete</span>
|
<button class="batch-btn" id="batch-download" title="${dlLabel}">
|
||||||
</button>
|
<i class="fas fa-download"></i>
|
||||||
|
<span>${dlLabel}</span>
|
||||||
|
</button>
|
||||||
|
<button class="batch-btn batch-btn-danger" id="batch-delete" title="${delLabel}">
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
<span>${delLabel}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Insert before the files-container (inside main-content)
|
|
||||||
const filesContainer = document.querySelector('.files-container');
|
|
||||||
if (filesContainer && filesContainer.parentNode) {
|
|
||||||
filesContainer.parentNode.insertBefore(bar, filesContainer);
|
|
||||||
} else {
|
|
||||||
document.body.appendChild(bar);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire up events
|
|
||||||
document.getElementById('batch-bar-close').addEventListener('click', () => this.clear());
|
|
||||||
document.getElementById('batch-delete').addEventListener('click', () => this.batchDelete());
|
|
||||||
document.getElementById('batch-move').addEventListener('click', () => this.batchMove());
|
|
||||||
document.getElementById('batch-download').addEventListener('click', () => this.batchDownload());
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Show/hide the bar and update the count */
|
/** Ensure the grid-view batch bar exists (shown only when grid is visible) */
|
||||||
|
_ensureGridBar() {
|
||||||
|
if (document.getElementById('batch-grid-bar')) return;
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.id = 'batch-grid-bar';
|
||||||
|
bar.className = 'batch-action-bar'; // reuse same styles
|
||||||
|
const container = document.querySelector('.files-container');
|
||||||
|
if (container) {
|
||||||
|
container.insertBefore(bar, container.firstChild);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Main UI sync — called after every selection change */
|
||||||
_syncUI() {
|
_syncUI() {
|
||||||
this._ensureBar();
|
const listHeader = document.querySelector('.list-header');
|
||||||
const bar = document.getElementById('batch-action-bar');
|
const n = this._selected.size;
|
||||||
const count = document.getElementById('batch-bar-count');
|
|
||||||
|
|
||||||
if (this._selected.size > 0) {
|
// ── Save original header HTML on first use ──
|
||||||
bar.classList.add('visible');
|
if (listHeader && !this._savedHeaderHTML) {
|
||||||
this._barVisible = true;
|
this._savedHeaderHTML = listHeader.innerHTML;
|
||||||
const n = this._selected.size;
|
|
||||||
const itemsText = n === 1
|
|
||||||
? (window.i18n ? window.i18n.t('batch.one_selected') : '1 item selected')
|
|
||||||
: (window.i18n ? window.i18n.t('batch.n_selected', { count: n }) : `${n} items selected`);
|
|
||||||
count.textContent = itemsText;
|
|
||||||
} else {
|
|
||||||
bar.classList.remove('visible');
|
|
||||||
this._barVisible = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update select-all checkbox state
|
if (n > 0) {
|
||||||
this._syncSelectAllCheckbox();
|
this._barVisible = true;
|
||||||
|
|
||||||
// Sync individual list-view checkboxes
|
// ── List view: replace header with selection bar ──
|
||||||
|
if (listHeader) {
|
||||||
|
listHeader.classList.add('selection-mode');
|
||||||
|
listHeader.innerHTML = this._buildSelectionBarHTML(n);
|
||||||
|
|
||||||
|
// Wire checkbox
|
||||||
|
const cb = document.getElementById('select-all-checkbox');
|
||||||
|
if (cb) cb.addEventListener('change', () => this.toggleAll());
|
||||||
|
|
||||||
|
// Wire action buttons
|
||||||
|
this._wireBarButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grid view: show floating bar ──
|
||||||
|
this._ensureGridBar();
|
||||||
|
const gridBar = document.getElementById('batch-grid-bar');
|
||||||
|
if (gridBar) {
|
||||||
|
const grid = document.getElementById('files-grid');
|
||||||
|
const gridVisible = grid && grid.style.display !== 'none';
|
||||||
|
if (gridVisible) {
|
||||||
|
gridBar.classList.add('visible');
|
||||||
|
gridBar.innerHTML = `
|
||||||
|
<div class="batch-bar-left">
|
||||||
|
<button class="batch-bar-close" id="batch-grid-close" title="Cancel selection">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
<span class="batch-bar-count">${
|
||||||
|
n === 1
|
||||||
|
? (this._t('batch.one_selected') || '1 item selected')
|
||||||
|
: (this._t('batch.n_selected', { count: n }) || `${n} items selected`)
|
||||||
|
}</span>
|
||||||
|
</div>
|
||||||
|
<div class="batch-bar-actions">
|
||||||
|
<button class="batch-btn" id="batch-fav" title="Add to favorites">
|
||||||
|
<i class="fas fa-star"></i>
|
||||||
|
<span>${this._t('batch.add_favorites') || 'Add to favorites'}</span>
|
||||||
|
</button>
|
||||||
|
<button class="batch-btn" id="batch-move" title="Move or copy">
|
||||||
|
<i class="fas fa-arrows-alt"></i>
|
||||||
|
<span>${this._t('batch.move_copy') || 'Move or copy'}</span>
|
||||||
|
</button>
|
||||||
|
<button class="batch-btn" id="batch-download" title="Download">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
<span>${this._t('actions.download') || 'Download'}</span>
|
||||||
|
</button>
|
||||||
|
<button class="batch-btn batch-btn-danger" id="batch-delete" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
<span>${this._t('actions.delete') || 'Delete'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const closeBtn = document.getElementById('batch-grid-close');
|
||||||
|
if (closeBtn) closeBtn.addEventListener('click', () => this.clear());
|
||||||
|
this._wireBarButtons();
|
||||||
|
} else {
|
||||||
|
gridBar.classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._barVisible = false;
|
||||||
|
|
||||||
|
// Restore original list header
|
||||||
|
if (listHeader) {
|
||||||
|
listHeader.classList.remove('selection-mode');
|
||||||
|
if (this._savedHeaderHTML) {
|
||||||
|
listHeader.innerHTML = this._savedHeaderHTML;
|
||||||
|
}
|
||||||
|
// Re-wire the select-all checkbox
|
||||||
|
const cb = document.getElementById('select-all-checkbox');
|
||||||
|
if (cb) cb.addEventListener('change', () => this.toggleAll());
|
||||||
|
// Translate restored header
|
||||||
|
if (window.i18n && window.i18n.translatePage) window.i18n.translatePage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide grid bar
|
||||||
|
const gridBar = document.getElementById('batch-grid-bar');
|
||||||
|
if (gridBar) gridBar.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync individual item checkboxes
|
||||||
this._syncItemCheckboxes();
|
this._syncItemCheckboxes();
|
||||||
|
// Sync select-all checkbox state (for non-selection-mode)
|
||||||
|
if (!this._barVisible) this._syncSelectAllCheckbox();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Wire click handlers on batch action buttons (idempotent per render) */
|
||||||
|
_wireBarButtons() {
|
||||||
|
const del = document.getElementById('batch-delete');
|
||||||
|
const move = document.getElementById('batch-move');
|
||||||
|
const dl = document.getElementById('batch-download');
|
||||||
|
const fav = document.getElementById('batch-fav');
|
||||||
|
if (del) del.onclick = () => this.batchDelete();
|
||||||
|
if (move) move.onclick = () => this.batchMove();
|
||||||
|
if (dl) dl.onclick = () => this.batchDownload();
|
||||||
|
if (fav) fav.onclick = () => this.batchFavorites();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Sync individual item checkboxes with selection state */
|
|
||||||
_syncItemCheckboxes() {
|
_syncItemCheckboxes() {
|
||||||
document.querySelectorAll('.file-item').forEach(el => {
|
document.querySelectorAll('.file-item').forEach(el => {
|
||||||
const cb = el.querySelector('.item-checkbox');
|
const cb = el.querySelector('.item-checkbox');
|
||||||
if (cb) {
|
if (cb) cb.checked = el.classList.contains('selected');
|
||||||
cb.checked = el.classList.contains('selected');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -273,7 +325,7 @@ const multiSelect = {
|
|||||||
if (all.length === 0) {
|
if (all.length === 0) {
|
||||||
cb.checked = false;
|
cb.checked = false;
|
||||||
cb.indeterminate = false;
|
cb.indeterminate = false;
|
||||||
} else if (this._selected.size === all.length) {
|
} else if (this._selected.size >= all.length) {
|
||||||
cb.checked = true;
|
cb.checked = true;
|
||||||
cb.indeterminate = false;
|
cb.indeterminate = false;
|
||||||
} else if (this._selected.size > 0) {
|
} else if (this._selected.size > 0) {
|
||||||
@@ -294,21 +346,19 @@ const multiSelect = {
|
|||||||
|
|
||||||
const n = items.length;
|
const n = items.length;
|
||||||
const msg = n === 1
|
const msg = n === 1
|
||||||
? (window.i18n
|
? (this._t('dialogs.confirm_delete_file', { name: items[0].name })
|
||||||
? window.i18n.t('dialogs.confirm_delete_file', { name: items[0].name })
|
|| `Are you sure you want to move "${items[0].name}" to trash?`)
|
||||||
: `Are you sure you want to move "${items[0].name}" to trash?`)
|
: (this._t('batch.confirm_delete', { count: n })
|
||||||
: (window.i18n
|
|| `Are you sure you want to move ${n} items to trash?`);
|
||||||
? window.i18n.t('batch.confirm_delete', { count: n })
|
|
||||||
: `Are you sure you want to move ${n} items to trash?`);
|
|
||||||
|
|
||||||
const confirmed = await showConfirmDialog({
|
const confirmed = await showConfirmDialog({
|
||||||
title: window.i18n ? window.i18n.t('dialogs.confirm_delete') : 'Move to trash',
|
title: this._t('dialogs.confirm_delete') || 'Move to trash',
|
||||||
message: msg,
|
message: msg,
|
||||||
confirmText: window.i18n ? window.i18n.t('actions.delete') : 'Delete',
|
confirmText: this._t('actions.delete') || 'Delete',
|
||||||
});
|
});
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
|
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
|
||||||
const folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
|
const folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -317,21 +367,17 @@ const multiSelect = {
|
|||||||
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
|
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds })
|
body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const success = data.stats?.successful || 0;
|
const success = data.stats?.successful || 0;
|
||||||
const errors = data.stats?.failed || 0;
|
const errors = data.stats?.failed || 0;
|
||||||
|
|
||||||
this.clear();
|
this.clear();
|
||||||
window.loadFiles();
|
window.loadFiles();
|
||||||
|
|
||||||
if (errors > 0) {
|
if (errors > 0) {
|
||||||
const failedNames = (data.failed || []).map(f => f.id).join(', ');
|
window.ui.showNotification('Batch delete', `${success} moved to trash, ${errors} failed`);
|
||||||
window.ui.showNotification('Batch delete',
|
|
||||||
`${success} moved to trash, ${errors} failed`);
|
|
||||||
} else {
|
} else {
|
||||||
window.ui.showNotification('Moved to trash',
|
window.ui.showNotification('Moved to trash', `${success} item${success !== 1 ? 's' : ''} moved to trash`);
|
||||||
`${success} item${success !== 1 ? 's' : ''} moved to trash`);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Batch trash error:', e);
|
console.error('Batch trash error:', e);
|
||||||
@@ -346,26 +392,19 @@ const multiSelect = {
|
|||||||
const items = this.items;
|
const items = this.items;
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
|
|
||||||
// Set a special batch mode flag
|
|
||||||
window.app.moveDialogMode = 'batch';
|
window.app.moveDialogMode = 'batch';
|
||||||
window.app.batchMoveItems = items;
|
window.app.batchMoveItems = items;
|
||||||
|
|
||||||
// Reset selection
|
|
||||||
window.app.selectedTargetFolderId = "";
|
window.app.selectedTargetFolderId = "";
|
||||||
|
|
||||||
// Update dialog title
|
|
||||||
const dialog = document.getElementById('move-file-dialog');
|
const dialog = document.getElementById('move-file-dialog');
|
||||||
const dialogHeader = dialog.querySelector('.rename-dialog-header');
|
const dialogHeader = dialog.querySelector('.rename-dialog-header');
|
||||||
const n = items.length;
|
const n = items.length;
|
||||||
const titleText = window.i18n
|
const titleText = this._t('batch.move_title', { count: n })
|
||||||
? window.i18n.t('batch.move_title', { count: n })
|
|| `Move ${n} item${n !== 1 ? 's' : ''}`;
|
||||||
: `Move ${n} item${n !== 1 ? 's' : ''}`;
|
|
||||||
dialogHeader.innerHTML = `<i class="fas fa-arrows-alt" style="color:#ff5e3a"></i> <span>${titleText}</span>`;
|
dialogHeader.innerHTML = `<i class="fas fa-arrows-alt" style="color:#ff5e3a"></i> <span>${titleText}</span>`;
|
||||||
|
|
||||||
// Load folders, excluding selected folder IDs
|
|
||||||
const excludeIds = items.filter(i => i.type === 'folder').map(i => i.id);
|
const excludeIds = items.filter(i => i.type === 'folder').map(i => i.id);
|
||||||
await contextMenus.loadAllFolders(excludeIds[0] || null, 'batch');
|
await contextMenus.loadAllFolders(excludeIds[0] || null, 'batch');
|
||||||
|
|
||||||
dialog.style.display = 'flex';
|
dialog.style.display = 'flex';
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -377,7 +416,7 @@ const multiSelect = {
|
|||||||
window.ui.showNotification('Preparing download', 'Creating ZIP archive...');
|
window.ui.showNotification('Preparing download', 'Creating ZIP archive...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
|
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
|
||||||
const folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
|
const folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
|
||||||
|
|
||||||
const response = await fetch('/api/batch/download', {
|
const response = await fetch('/api/batch/download', {
|
||||||
@@ -386,12 +425,10 @@ const multiSelect = {
|
|||||||
body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds })
|
body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error(`Server returned ${response.status}`);
|
||||||
throw new Error(`Server returned ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = `oxicloud-download-${Date.now()}.zip`;
|
link.download = `oxicloud-download-${Date.now()}.zip`;
|
||||||
@@ -405,61 +442,70 @@ const multiSelect = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Batch add to favorites */
|
||||||
|
async batchFavorites() {
|
||||||
|
const items = this.items;
|
||||||
|
if (items.length === 0 || !window.favorites) return;
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const alreadyFav = window.favorites.isFavorite(item.id, item.type);
|
||||||
|
if (!alreadyFav) {
|
||||||
|
await window.favorites.addToFavorites(item.id, item.name, item.type, item.parentId);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clear();
|
||||||
|
if (typeof window.loadFiles === 'function') window.loadFiles();
|
||||||
|
if (added > 0) {
|
||||||
|
window.ui.showNotification(
|
||||||
|
this._t('favorites.add') || 'Added to favorites',
|
||||||
|
`${added} item${added !== 1 ? 's' : ''} added to favorites`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
window.ui.showNotification(
|
||||||
|
this._t('favorites.add') || 'Favorites',
|
||||||
|
'All selected items are already favorites'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ── Initialization ──────────────────────────────────────
|
// ── Initialization ──────────────────────────────────────
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Inject the select-all checkbox into the list header
|
// Wire the initial select-all checkbox
|
||||||
this._injectListHeaderCheckbox();
|
this._injectListHeaderCheckbox();
|
||||||
|
|
||||||
// Override the deselect-on-empty-area handler to also clear our state
|
// Global deselect on empty-area click
|
||||||
this._hookGlobalDeselect();
|
this._hookGlobalDeselect();
|
||||||
|
|
||||||
// Hook into the move dialog confirm to handle batch mode
|
// Keyboard shortcuts
|
||||||
// (handled in contextMenus.js — moveDialogMode === 'batch')
|
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl+A to select all, Escape to clear
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
// Don't trigger when inside an input/textarea/modal
|
|
||||||
if (e.target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
|
if (e.target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
|
||||||
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||||
// Only when in file view (not favorites, trash etc.)
|
|
||||||
const grid = document.getElementById('files-grid');
|
const grid = document.getElementById('files-grid');
|
||||||
if (grid && grid.closest('.files-container')) {
|
if (grid && grid.closest('.files-container')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.selectAll();
|
this.selectAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (e.key === 'Escape' && this.hasSelection) this.clear();
|
||||||
if (e.key === 'Escape' && this.hasSelection) {
|
if (e.key === 'Delete' && this.hasSelection) this.batchDelete();
|
||||||
this.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === 'Delete' && this.hasSelection) {
|
|
||||||
this.batchDelete();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Inject a checkbox into the list-header (or wire existing one) */
|
|
||||||
_injectListHeaderCheckbox() {
|
_injectListHeaderCheckbox() {
|
||||||
const cb = document.getElementById('select-all-checkbox');
|
const cb = document.getElementById('select-all-checkbox');
|
||||||
if (!cb) return;
|
if (!cb) return;
|
||||||
|
cb.addEventListener('change', () => this.toggleAll());
|
||||||
cb.addEventListener('change', () => {
|
|
||||||
this.toggleAll();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Override global click deselect to also clear our internal state */
|
|
||||||
_hookGlobalDeselect() {
|
_hookGlobalDeselect() {
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
// Don't deselect if clicking on batch bar, context menu, modal, or any file item
|
if (e.target.closest('.file-card, .file-item, .context-menu, .batch-action-bar, .list-header.selection-mode, .about-modal, .rename-dialog, .share-dialog, .confirm-dialog, .modal-overlay, input, button')) return;
|
||||||
if (e.target.closest('.file-card, .file-item, .context-menu, .batch-action-bar, .about-modal, .rename-dialog, .share-dialog, .confirm-dialog, .modal-overlay, input, button')) return;
|
if (this.hasSelection) this.clear();
|
||||||
|
|
||||||
if (this.hasSelection) {
|
|
||||||
this.clear();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1250,8 +1250,18 @@ function initRubberBandSelection() {
|
|||||||
|
|
||||||
if (intersects) {
|
if (intersects) {
|
||||||
card.classList.add('selected');
|
card.classList.add('selected');
|
||||||
|
// Sync with multiSelect module
|
||||||
|
if (window.multiSelect) {
|
||||||
|
const info = window.multiSelect._extractInfo(card);
|
||||||
|
if (info) window.multiSelect.select(info.id, info.name, info.type, info.parentId);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
card.classList.remove('selected');
|
card.classList.remove('selected');
|
||||||
|
// Deselect from multiSelect module
|
||||||
|
if (window.multiSelect) {
|
||||||
|
const info = window.multiSelect._extractInfo(card);
|
||||||
|
if (info) window.multiSelect.deselect(info.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1260,6 +1270,8 @@ function initRubberBandSelection() {
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
active = false;
|
active = false;
|
||||||
selRect.style.display = 'none';
|
selRect.style.display = 'none';
|
||||||
|
// Update the batch bar after rubber band selection completes
|
||||||
|
if (window.multiSelect) window.multiSelect._syncUI();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -339,5 +339,13 @@
|
|||||||
"folder_deleted": "Ordner in Papierkorb verschoben",
|
"folder_deleted": "Ordner in Papierkorb verschoben",
|
||||||
"item_deleted_permanently": "Element endgültig gelöscht",
|
"item_deleted_permanently": "Element endgültig gelöscht",
|
||||||
"trash_emptied": "Papierkorb erfolgreich geleert"
|
"trash_emptied": "Papierkorb erfolgreich geleert"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"one_selected": "1 Element ausgewählt",
|
||||||
|
"n_selected": "{{count}} Elemente ausgewählt",
|
||||||
|
"confirm_delete": "Möchten Sie wirklich {{count}} Elemente in den Papierkorb verschieben?",
|
||||||
|
"move_title": "{{count}} Element(e) verschieben",
|
||||||
|
"add_favorites": "Zu Favoriten hinzufügen",
|
||||||
|
"move_copy": "Verschieben oder kopieren"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,5 +341,13 @@
|
|||||||
"trash_emptied": "Trash emptied successfully",
|
"trash_emptied": "Trash emptied successfully",
|
||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"empty": "No notifications"
|
"empty": "No notifications"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"one_selected": "1 item selected",
|
||||||
|
"n_selected": "{{count}} items selected",
|
||||||
|
"confirm_delete": "Are you sure you want to move {{count}} items to trash?",
|
||||||
|
"move_title": "Move {{count}} item(s)",
|
||||||
|
"add_favorites": "Add to favorites",
|
||||||
|
"move_copy": "Move or copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,5 +341,13 @@
|
|||||||
"trash_emptied": "Papelera vaciada correctamente",
|
"trash_emptied": "Papelera vaciada correctamente",
|
||||||
"title": "Notificaciones",
|
"title": "Notificaciones",
|
||||||
"empty": "Sin notificaciones"
|
"empty": "Sin notificaciones"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"one_selected": "1 elemento seleccionado",
|
||||||
|
"n_selected": "{{count}} elementos seleccionados",
|
||||||
|
"confirm_delete": "¿Estás seguro de que quieres mover {{count}} elementos a la papelera?",
|
||||||
|
"move_title": "Mover {{count}} elemento(s)",
|
||||||
|
"add_favorites": "Añadir a favoritos",
|
||||||
|
"move_copy": "Mover o copiar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,5 +314,13 @@
|
|||||||
"accessed": "دسترسی یافته",
|
"accessed": "دسترسی یافته",
|
||||||
"empty_state": "هنوز هیچ پروندهٔ اخیر وجود ندارد",
|
"empty_state": "هنوز هیچ پروندهٔ اخیر وجود ندارد",
|
||||||
"empty_hint": "پروندههایی که باز میکنید اینجا ظاهر میشوند"
|
"empty_hint": "پروندههایی که باز میکنید اینجا ظاهر میشوند"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"one_selected": "۱ مورد انتخاب شده",
|
||||||
|
"n_selected": "{{count}} مورد انتخاب شده",
|
||||||
|
"confirm_delete": "آیا مطمئنید که میخواهید {{count}} مورد را به سطل زباله منتقل کنید؟",
|
||||||
|
"move_title": "انتقال {{count}} مورد",
|
||||||
|
"add_favorites": "افزودن به موارد علاقهمند",
|
||||||
|
"move_copy": "انتقال یا کپی"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,5 +339,13 @@
|
|||||||
"folder_deleted": "Dossier déplacé vers la corbeille",
|
"folder_deleted": "Dossier déplacé vers la corbeille",
|
||||||
"item_deleted_permanently": "Élément supprimé définitivement",
|
"item_deleted_permanently": "Élément supprimé définitivement",
|
||||||
"trash_emptied": "Corbeille vidée avec succès"
|
"trash_emptied": "Corbeille vidée avec succès"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"one_selected": "1 élément sélectionné",
|
||||||
|
"n_selected": "{{count}} éléments sélectionnés",
|
||||||
|
"confirm_delete": "Voulez-vous vraiment déplacer {{count}} éléments vers la corbeille ?",
|
||||||
|
"move_title": "Déplacer {{count}} élément(s)",
|
||||||
|
"add_favorites": "Ajouter aux favoris",
|
||||||
|
"move_copy": "Déplacer ou copier"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,7 +313,7 @@
|
|||||||
"fa": "Persiano",
|
"fa": "Persiano",
|
||||||
"fr": "Francese",
|
"fr": "Francese",
|
||||||
"de": "Tedesco",
|
"de": "Tedesco",
|
||||||
"pt": "Portoghese"
|
"pt": "Portoghese",
|
||||||
"it": "Italiano"
|
"it": "Italiano"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -340,5 +340,13 @@
|
|||||||
"folder_deleted": "Cartella spostata nel cestino",
|
"folder_deleted": "Cartella spostata nel cestino",
|
||||||
"item_deleted_permanently": "Elemento eliminato definitivamente",
|
"item_deleted_permanently": "Elemento eliminato definitivamente",
|
||||||
"trash_emptied": "Cestino svuotato con successo"
|
"trash_emptied": "Cestino svuotato con successo"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"one_selected": "1 elemento selezionato",
|
||||||
|
"n_selected": "{{count}} elementi selezionati",
|
||||||
|
"confirm_delete": "Sei sicuro di voler spostare {{count}} elementi nel cestino?",
|
||||||
|
"move_title": "Sposta {{count}} elemento/i",
|
||||||
|
"add_favorites": "Aggiungi ai preferiti",
|
||||||
|
"move_copy": "Sposta o copia"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,5 +339,13 @@
|
|||||||
"folder_deleted": "Pasta movida para a lixeira",
|
"folder_deleted": "Pasta movida para a lixeira",
|
||||||
"item_deleted_permanently": "Item excluído permanentemente",
|
"item_deleted_permanently": "Item excluído permanentemente",
|
||||||
"trash_emptied": "Lixeira esvaziada com sucesso"
|
"trash_emptied": "Lixeira esvaziada com sucesso"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"one_selected": "1 item selecionado",
|
||||||
|
"n_selected": "{{count}} itens selecionados",
|
||||||
|
"confirm_delete": "Tem certeza de que deseja mover {{count}} itens para a lixeira?",
|
||||||
|
"move_title": "Mover {{count}} item(ns)",
|
||||||
|
"add_favorites": "Adicionar aos favoritos",
|
||||||
|
"move_copy": "Mover ou copiar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,5 +276,13 @@
|
|||||||
"accessed": "访问于",
|
"accessed": "访问于",
|
||||||
"empty_state": "没有最近文件",
|
"empty_state": "没有最近文件",
|
||||||
"empty_hint": "您打开的文件将显示在这里"
|
"empty_hint": "您打开的文件将显示在这里"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"one_selected": "已选择 1 个项目",
|
||||||
|
"n_selected": "已选择 {{count}} 个项目",
|
||||||
|
"confirm_delete": "确定要将 {{count}} 个项目移至回收站吗?",
|
||||||
|
"move_title": "移动 {{count}} 个项目",
|
||||||
|
"add_favorites": "添加到收藏夹",
|
||||||
|
"move_copy": "移动或复制"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user