feat: menu contextuel Déplacer/Dupliquer sidebar + réparation drag & drop interne #201 #BUG-117
CI / lint (push) Successful in 3m13s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 5m16s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 18m2s

This commit is contained in:
2026-10-15 10:02:48 -04:00
parent ed9029d760
commit 411667fd88
19 changed files with 314 additions and 20 deletions
+43
View File
@@ -34,6 +34,8 @@ from backend.schemas import (
FileCreateRequest,
FileCreateResponse,
FileDeleteResponse,
FileDuplicateRequest,
FileDuplicateResponse,
FileMoveRequest,
FileMoveResponse,
FileRenameRequest,
@@ -55,6 +57,9 @@ from backend.services.mutations import (
from backend.services.mutations import (
delete_file as service_delete_file,
)
from backend.services.mutations import (
duplicate_path as service_duplicate_path,
)
from backend.services.mutations import (
edit_file as service_edit_file,
)
@@ -743,3 +748,41 @@ async def api_file_move(
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
@router.post("/api/duplicate/{vault_name}", response_model=FileDuplicateResponse)
async def api_file_duplicate(
vault_name: str,
body: FileDuplicateRequest,
current_user=Depends(require_auth),
):
"""Duplicate (copy) a file or directory within the same vault."""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_duplicate_path(vault_name, body.source_path, body.destination_dir)
new_path_str = result["new_path"]
item_type = result["item_type"]
# Index: incrémental pour chaque fichier copié (évite un reload complet).
from backend.services.vaults import get_vault_root
root = get_vault_root(vault_name)
target = root / new_path_str
if item_type == "directory":
for fp in sorted(target.rglob("*")):
if fp.is_file():
await update_single_file(vault_name, str(fp.relative_to(root)).replace("\\", "/"))
else:
await update_single_file(vault_name, new_path_str)
await sse_manager.broadcast("item_duplicated", {
"vault": vault_name,
"source_path": result["source_path"],
"new_path": new_path_str,
"item_type": item_type,
})
await dispatch_webhooks(
"file_created" if item_type == "file" else "directory_created",
{"vault": vault_name, "path": new_path_str},
)
return {"success": True, "source_path": result["source_path"], "new_path": new_path_str, "item_type": item_type}
+14
View File
@@ -580,6 +580,20 @@ class FileMoveResponse(BaseModel):
success: bool = Field(description="Whether move succeeded")
old_path: str = Field(description="Original path")
new_path: str = Field(description="New path after move")
class FileDuplicateRequest(BaseModel):
"""Request to duplicate (copy) a file or directory."""
source_path: str = Field(description="Relative path of the item to duplicate")
destination_dir: str = Field(default="", description="Target directory relative path (empty = source parent)")
class FileDuplicateResponse(BaseModel):
"""Response after duplicating a file or directory."""
success: bool = Field(description="Whether duplication succeeded")
source_path: str = Field(description="Original path")
new_path: str = Field(description="Path of the created copy")
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
+74
View File
@@ -1534,6 +1534,80 @@ def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> d
"item_type": item_type,
}
def duplicate_path(vault_name: str, source_path: str, destination_dir: str = "") -> dict[str, Any]:
"""Duplicate a file or directory (copy) into *destination_dir* (same vault).
The copy is named ``<stem> copie<ext>`` (then ``copie 2``, ``copie 3``… on
collision). Directories are copied recursively.
Raises:
ServiceError: ``not_found`` (404), ``read_only`` (403),
``permission_denied`` (403), ``invalid_arguments`` (400) when the
destination is inside the source directory.
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
source = resolve_safe_path(root, source_path)
if not source.exists():
raise ServiceError(
f"Source not found: {source_path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": source_path},
)
is_directory = source.is_dir()
dest_clean = (destination_dir or "").strip("/")
if dest_clean:
dest_parent = resolve_safe_path(root, dest_clean)
if not dest_parent.exists() or not dest_parent.is_dir():
raise ServiceError(
f"Destination directory not found: {destination_dir}",
code="not_found",
status=404,
details={"vault": vault_name, "path": destination_dir},
)
else:
dest_parent = root
if is_directory and str(dest_parent.resolve()) in (str(source.resolve()), str(source.resolve()) + os.sep, str(source.resolve()) + "/"):
raise ServiceError(
"Cannot duplicate a directory inside itself",
code="invalid_arguments",
status=400,
details={"vault": vault_name, "path": source_path},
)
ext = "" if is_directory else source.suffix
base = dest_parent / (source.stem + " copie" + ext)
target = base
n = 2
while target.exists():
target = base.parent / (source.stem + " copie " + str(n) + ext)
n += 1
try:
if is_directory:
shutil.copytree(source, target)
else:
shutil.copy2(source, target)
except PermissionError as e:
raise ServiceError("Permission denied: cannot duplicate item", code="permission_denied", status=403) from e
new_rel = _rel(root, target)
item_type = "directory" if is_directory else "file"
logger.info(f"Item duplicated: {vault_name}/{source_path} -> {new_rel}")
return {
"success": True,
"vault": vault_name,
"source_path": _rel(root, source),
"new_path": new_rel,
"item_type": item_type,
}
# ── D3. Find & replace ─────────────────────────────────────────────────────