"""File & directory mutation endpoints (ROADMAP #85, tranche 6b). Handlers déplacés depuis :mod:`backend.main` sans changement de comportement : mêmes chemins (``PUT/DELETE/PATCH/POST /api/file/*``, ``/api/directory/*``, ``/api/move/*``, ``/api/vault/*/batch-upload``), mêmes modèles de requête/réponse (déménagés dans :mod:`backend.schemas`), mêmes dépendances d'authentification et mêmes effets de bord (audit, index incrémental, SSE, webhooks, plugins, historique). La logique métier vit déjà dans :mod:`backend.services.mutations`. """ import logging from typing import Any from fastapi import APIRouter, Body, Depends, HTTPException, Query from backend.audit import log_file_delete, log_file_save from backend.auth.middleware import check_vault_access, require_auth from backend.history import ( remove_recent, update_bookmarks_after_rename, update_history_after_rename, ) from backend.indexer import handle_file_move, remove_single_file, update_single_file from backend.schemas import ( BatchUploadRequest, BatchUploadResponse, DirectoryCreateRequest, DirectoryCreateResponse, DirectoryDeleteResponse, DirectoryRenameRequest, DirectoryRenameResponse, FileCreateRequest, FileCreateResponse, FileDeleteResponse, FileDuplicateRequest, FileDuplicateResponse, FileMoveRequest, FileMoveResponse, FileRenameRequest, FileRenameResponse, FileSaveResponse, ) from backend.services.mutations import ( batch_upload_files as service_batch_upload_files, ) from backend.services.mutations import ( create_directory as service_create_directory, ) from backend.services.mutations import ( create_file as service_create_file, ) from backend.services.mutations import ( delete_directory as service_delete_directory, ) 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, ) from backend.services.mutations import ( edit_xlsx_cells as service_edit_xlsx_cells, ) from backend.services.mutations import ( move_path as service_move_path, ) from backend.services.mutations import ( mutate_xlsx_structure as service_mutate_xlsx_structure, ) from backend.services.mutations import ( mutate_xlsx_style as service_mutate_xlsx_style, ) from backend.services.mutations import ( rename_directory as service_rename_directory, ) from backend.services.mutations import ( rename_file as service_rename_file, ) from backend.services.mutations import ( save_csv_cells as service_save_csv_cells, ) from backend.share import update_shares_after_rename from backend.sse import sse_manager from backend.webhooks import dispatch_webhooks logger = logging.getLogger("obsigate") router = APIRouter(tags=["files"]) @router.put("/api/file/{vault_name}/save", response_model=FileSaveResponse) async def api_file_save( vault_name: str, path: str = Query(..., description="Relative path to file"), body: dict = Body(...), backup: bool = Query(True, description="Create a backup before saving (default true, set false for auto-save)"), current_user=Depends(require_auth), ): """Save (overwrite) a file's content. Expects a JSON body with a ``content`` key containing the new text. The path is validated against traversal attacks before writing. Args: vault_name: Name of the vault. path: Relative file path within the vault. body: JSON body with ``content`` string. Returns: ``FileSaveResponse`` confirming the write. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") content = body.get("content", "") result = service_edit_file(vault_name, path, content, backup=backup) # #197 (synchro TikTak) : rafraîchir l'index de façon synchrone pour une # cohérence read-after-write — un client qui liste juste après le save # doit voir le nouveau mtime/size sans attendre le watcher. await update_single_file(vault_name, result["path"]) # #198 : la sauvegarde texte émet file_modified (événement clé pour tirer # les retours de synchro côté TikTak en phase 3). await dispatch_webhooks("file_modified", {"vault": vault_name, "path": result["path"]}) # Audit log client_ip = current_user.get("_request_ip", "unknown") log_file_save(current_user["username"], vault_name, path, len(content), client_ip) return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]} @router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse) def api_file_xlsx_save( vault_name: str, path: str = Query(..., description="Relative path to the .xlsx file"), body: dict = Body( ..., description=( '{"sheet": str, "cells": {"A1": value}, ' '"allow_formula": false, "force": false, "if_match": str}' ), ), current_user=Depends(require_auth), ): """Apply cell edits to an .xlsx workbook. Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new scalar values, max 500 per request) plus two optional boolean flags: * ``allow_formula`` — keep values starting with ``=``/``@`` as real formulas. Off by default (#153 A4): such a value is stored as text so a later Excel session cannot execute it (DDE). * ``force`` — write a workbook carrying features openpyxl cannot re-serialize (slicers, form controls, connections, custom XML, signature, cached formula results). Without it the call fails **409** ``xlsx_lossy_content`` and the client asks the user to confirm (#153 A1). * ``if_match`` — revision token returned by the read (#156-A12). When it no longer matches the file on disk the write is refused with **409** ``conflict`` (``reason=stale_revision``) instead of overwriting a change made by another writer. Omitted: last writer wins (curl, AI tools). A backup is created before the workbook is rewritten, and the new archive swaps in atomically. Declared as a sync endpoint on purpose: the openpyxl round-trip and the per-file lock wait (#153 A3) then run in the threadpool instead of blocking the event loop. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") sheet = body.get("sheet") cells = body.get("cells") if not isinstance(sheet, str) or not sheet: raise HTTPException(status_code=400, detail="Feuille manquante") if not isinstance(cells, dict) or not cells or len(cells) > 500: raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)") for ref, value in cells.items(): if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))): raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}") flags: dict[str, bool] = {} for name in ("allow_formula", "force"): raw = body.get(name, False) if not isinstance(raw, bool): raise HTTPException(status_code=400, detail=f"Flag invalide: {name}") flags[name] = raw if_match = body.get("if_match") if if_match is not None and not isinstance(if_match, str): raise HTTPException(status_code=400, detail="Jeton invalide: if_match") result = service_edit_xlsx_cells( vault_name, path, sheet, cells, expected_revision=if_match, **flags ) log_file_save( current_user["username"], vault_name, path, sum(len(str(v)) for v in cells.values()), current_user.get("_request_ip", "unknown"), ) return { "status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"], "revision": result.get("revision"), } @router.put("/api/file/{vault_name}/csv/save", response_model=FileSaveResponse) def api_file_csv_save( vault_name: str, path: str = Query(..., description="Relative path to the .csv file"), body: dict = Body( ..., description=( '{"cells": {"A1": value}, "if_match": str} — A1-addressed text ' 'edits (#153 A16). `if_match` is the revision token of the read ' '(#156-A12): a stale token fails with 409 `conflict`.' ), ), current_user=Depends(require_auth), ): """Apply A1-addressed cell edits to a ``.csv`` file (#153 A16). The grid is re-parsed with :mod:`csv`, patched and re-serialized (RFC 4180 quoting). References beyond the extent grow the grid. Values are stored verbatim as text — a CSV has no formula engine. ``if_match`` (optional, #156-A12) is the revision the client read: when the file changed in the meantime the write is refused with **409** ``conflict``. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") cells = body.get("cells") if not isinstance(cells, dict) or not cells or len(cells) > 500: raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)") for ref, value in cells.items(): if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))): raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}") if_match = body.get("if_match") if if_match is not None and not isinstance(if_match, str): raise HTTPException(status_code=400, detail="Jeton invalide: if_match") result = service_save_csv_cells(vault_name, path, cells, expected_revision=if_match) log_file_save( current_user["username"], vault_name, path, sum(len(str(v)) for v in cells.values()), current_user.get("_request_ip", "unknown"), ) return { "status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"], "revision": result.get("revision"), } @router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse) def api_file_xlsx_structure( vault_name: str, path: str = Query(..., description="Relative path to the .xlsx file"), body: dict = Body( ..., description=( '{"actions": [{"op": "sheet_add", "name": "X"}, ' '{"op": "row_insert", "sheet": "X", "at": 2, "count": 1}], ' '"force": false, "if_match": str}' ), ), current_user=Depends(require_auth), ): """Apply structural changes to an .xlsx workbook (#153 A14). ``actions`` is an ordered list applied in one locked, atomic rewrite: ``sheet_add`` (``name``, optional ``at`` 0-based), ``sheet_rename`` (``from``/``to``), ``sheet_delete`` (refused on the last sheet), ``sheet_duplicate`` (``name``/``as``) and ``row_insert``/``row_delete``/ ``col_insert``/``col_delete`` (``sheet``, 1-based ``at``, ``count``). Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the workbook carries features openpyxl cannot rewrite (same gate as the cell edits). A backup is created before the archive is replaced. The optional ``if_match`` revision (#156-A12) refuses a structural rewrite on a file that changed since it was read (**409** ``conflict``). Args: vault_name: Name of the vault. path: Relative path to the ``.xlsx`` file. body: JSON body with ``actions`` (1 to 50) and optional ``force``. Returns: ``FileSaveResponse`` confirming the write. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") actions = body.get("actions") if not isinstance(actions, list) or not actions or len(actions) > 50: raise HTTPException(status_code=400, detail="Actions invalides (1 à 50 par requête)") raw_force = body.get("force", False) if not isinstance(raw_force, bool): raise HTTPException(status_code=400, detail="Flag invalide: force") if_match = body.get("if_match") if if_match is not None and not isinstance(if_match, str): raise HTTPException(status_code=400, detail="Jeton invalide: if_match") result = service_mutate_xlsx_structure( vault_name, path, actions, force=raw_force, expected_revision=if_match ) log_file_save( current_user["username"], vault_name, path, len(actions), current_user.get("_request_ip", "unknown"), ) return { "status": "ok", "vault": result["vault"], "path": result["path"], "size": len(result["applied"]), "revision": result.get("revision"), } @router.put("/api/file/{vault_name}/xlsx/style", response_model=FileSaveResponse) def api_file_xlsx_style( vault_name: str, path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"), body: dict = Body( ..., description=( '{"ops": [{"op": "cell", "sheet": "X", "range": "A1:B2", ' '"style": {"bold": true, "fill_color": "#ffe08a"}}], ' '"force": false, "if_match": str}' ), ), current_user=Depends(require_auth), ): """Write formatting on an .xlsx/.xlsm workbook (#156-A8). ``ops`` is an ordered list applied in one locked, atomic rewrite: ``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``, ``italic``, ``underline``, ``strike``, ``font_size`` (6-72), ``font_color``/``fill_color`` as ``#rrggbb``, ``align`` (left/center/right/justify), ``valign`` (top/middle/bottom), ``wrap``, ``rotation`` (-90..90), ``border`` (all/outer/none) + ``border_style`` / ``border_color`` and ``number_format``), ``merge``/``unmerge`` (``range``), ``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``), ``comment_set`` (``ref``, ``text``) / ``comment_clear`` (``ref``) and ``freeze`` (``cell``, empty to release). Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the workbook carries features openpyxl cannot rewrite (same gate as the cell edits). A backup is created before the archive is replaced; the optional ``if_match`` revision (#156-A12) refuses a rewrite on a file that changed since it was read (**409** ``conflict``). Args: vault_name: Name of the vault. path: Relative path to the ``.xlsx``/``.xlsm`` file. body: JSON body with ``ops`` (1 to 50) and optional ``force``. Returns: ``FileSaveResponse`` confirming the write. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") ops = body.get("ops") if not isinstance(ops, list) or not ops or len(ops) > 50: raise HTTPException(status_code=400, detail="Ops invalides (1 à 50 par requête)") raw_force = body.get("force", False) if not isinstance(raw_force, bool): raise HTTPException(status_code=400, detail="Flag invalide: force") if_match = body.get("if_match") if if_match is not None and not isinstance(if_match, str): raise HTTPException(status_code=400, detail="Jeton invalide: if_match") result = service_mutate_xlsx_style( vault_name, path, ops, force=raw_force, expected_revision=if_match ) log_file_save( current_user["username"], vault_name, path, len(ops), current_user.get("_request_ip", "unknown"), ) return { "status": "ok", "vault": result["vault"], "path": result["path"], "size": len(result["applied"]), "revision": result.get("revision"), } @router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse) async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)): """Delete a file from the vault. The path is validated against traversal attacks before deletion. Args: vault_name: Name of the vault. path: Relative file path within the vault. Returns: ``FileDeleteResponse`` confirming the deletion. """ 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_delete_file(vault_name, path) # Audit log client_ip = current_user.get("_request_ip", "unknown") log_file_delete(current_user["username"], vault_name, path, client_ip) # Update index await remove_single_file(vault_name, path) # Broadcast SSE event await sse_manager.broadcast("file_deleted", { "vault": vault_name, "path": path, }) from backend.plugins import emit_file_deleted emit_file_deleted(vault_name, path) # Remove from recent files remove_recent(current_user["username"], vault_name, path) # Dispatch webhooks await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path}) return {"status": "ok", "vault": result["vault"], "path": result["path"]} @router.post("/api/directory/{vault_name}", response_model=DirectoryCreateResponse) async def api_directory_create( vault_name: str, body: DirectoryCreateRequest, current_user=Depends(require_auth), ): """Create a new directory in a vault. Args: vault_name: Name of the vault. body: Request body with directory path. Returns: DirectoryCreateResponse confirming creation. """ 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_create_directory(vault_name, body.path) # Update path_index with the new directory from backend.indexer import _index_lock from backend.indexer import path_index as _path_idx with _index_lock: if vault_name not in _path_idx: _path_idx[vault_name] = [] existing = {p["path"] for p in _path_idx[vault_name]} # Build all parent segments parts = body.path.split("/") for i in range(1, len(parts) + 1): seg_path = "/".join(parts[:i]) if seg_path and seg_path not in existing: existing.add(seg_path) _path_idx[vault_name].append({ "path": seg_path, "name": parts[i - 1], "type": "directory", }) # Broadcast SSE event await sse_manager.broadcast("directory_created", { "vault": vault_name, "path": result["path"], }) await dispatch_webhooks("directory_created", {"vault": vault_name, "path": result["path"]}) return {"success": True, "path": result["path"]} @router.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse) async def api_directory_rename( vault_name: str, body: DirectoryRenameRequest, current_user=Depends(require_auth), ): """Rename a directory in a vault. Args: vault_name: Name of the vault. body: Request body with current path and new name. Returns: DirectoryRenameResponse with old and new paths. """ 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_rename_directory(vault_name, body.path, body.new_name) old_path_str = result["old_path"] new_path_str = result["new_path"] # Update index for all files in the directory from backend.indexer import reload_single_vault await reload_single_vault(vault_name) # Broadcast SSE event await sse_manager.broadcast("directory_renamed", { "vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, }) await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str}) return {"success": True, "old_path": old_path_str, "new_path": new_path_str} @router.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse) async def api_directory_delete( vault_name: str, path: str = Query(..., description="Relative path to directory"), current_user=Depends(require_auth), ): """Delete a directory and all its contents from a vault. Args: vault_name: Name of the vault. path: Relative directory path within the vault. Returns: DirectoryDeleteResponse with count of deleted files. """ 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_delete_directory(vault_name, path, recursive=True) file_count = result["deleted_count"] # Update index — incremental subtree removal (no full vault re-scan, no # global inverted-index rebuild). from backend.indexer import remove_directory_from_index await remove_directory_from_index(vault_name, result["path"]) # Broadcast SSE event await sse_manager.broadcast("directory_deleted", { "vault": vault_name, "path": result["path"], "deleted_count": file_count, }) await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": result["path"]}) return {"success": True, "deleted_count": file_count} @router.post("/api/file/{vault_name}", response_model=FileCreateResponse) async def api_file_create( vault_name: str, body: FileCreateRequest, current_user=Depends(require_auth), ): """Create a new file in a vault. Args: vault_name: Name of the vault. body: Request body with file path and initial content. Returns: FileCreateResponse confirming creation. Note: A ``.xlsx`` path creates an empty workbook (one ``Feuille1`` sheet) built with openpyxl: the payload is binary, so ``content`` is ignored (#186). """ 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_create_file(vault_name, body.path, body.content) # Update index await update_single_file(vault_name, result["path"]) # Broadcast SSE event await sse_manager.broadcast("file_created", { "vault": vault_name, "path": result["path"], }) await dispatch_webhooks("file_created", {"vault": vault_name, "path": result["path"]}) from backend.plugins import emit_file_created emit_file_created(vault_name, result["path"]) return {"success": True, "path": result["path"]} @router.post("/api/vault/{vault_name}/batch-upload", response_model=BatchUploadResponse) async def api_batch_upload( vault_name: str, body: BatchUploadRequest, current_user=Depends(require_auth), ): """Upload multiple files and directories (recursively) into a vault. Accepts base64 encoded or plain text files with relative directory paths. Creates missing parent folders safely. Args: vault_name: Target vault name. body: BatchUploadRequest with target_dir and files list. Returns: BatchUploadResponse with summary of uploaded files and errors. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") import base64 items: list[dict[str, Any]] = [] for f in body.files: if f.is_dir: items.append({"path": f.path, "is_dir": True}) continue raw_bytes = b"" if f.content is not None: # Check if content is base64 encoded data URI or raw base64 content_str = f.content if content_str.startswith("data:") and ";base64," in content_str: content_str = content_str.split(";base64,", 1)[1] try: raw_bytes = base64.b64decode(content_str) except Exception: # Fallback to utf-8 text encoding raw_bytes = f.content.encode("utf-8") items.append({"path": f.path, "content": raw_bytes, "is_dir": False}) result = service_batch_upload_files( vault_name, body.target_dir, items, overwrite=body.overwrite, ) # Update index and SSE notifications for uploaded files for path in result["uploaded"]: try: await update_single_file(vault_name, path) await sse_manager.broadcast("file_created", { "vault": vault_name, "path": path, }) await dispatch_webhooks("file_created", {"vault": vault_name, "path": path}) except Exception as e: logger.warning(f"Failed to post-process upload of {path}: {e}") # SSE notification for tree refresh if result["uploaded"] or result["created_dirs"]: await sse_manager.broadcast("tree_updated", { "vault": vault_name, "target_dir": result["target_dir"], }) return result @router.patch("/api/file/{vault_name}", response_model=FileRenameResponse) async def api_file_rename( vault_name: str, body: FileRenameRequest, current_user=Depends(require_auth), ): """Rename a file in a vault. Args: vault_name: Name of the vault. body: Request body with current path and new name. Returns: FileRenameResponse with old and new paths. """ 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_rename_file(vault_name, body.path, body.new_name) old_path_str = result["old_path"] new_path_str = result["new_path"] # Update index await handle_file_move(vault_name, old_path_str, new_path_str) # Update bookmarks, history, and shares update_bookmarks_after_rename(vault_name, old_path_str, new_path_str) update_history_after_rename(vault_name, old_path_str, new_path_str) update_shares_after_rename(vault_name, old_path_str, new_path_str) # Broadcast SSE event await sse_manager.broadcast("file_renamed", { "vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, }) await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str}) return {"success": True, "old_path": old_path_str, "new_path": new_path_str} @router.post("/api/move/{vault_name}", response_model=FileMoveResponse) async def api_file_move( vault_name: str, body: FileMoveRequest, current_user=Depends(require_auth), ): """Move a file or directory to a different parent directory within the same vault. Supports both files and directories. The item keeps its original name; only the parent directory changes. Args: vault_name: Name of the vault. body: Request body with source_path and destination_dir. Returns: FileMoveResponse with old and new paths. """ 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_move_path(vault_name, body.source_path, body.destination_dir) old_path_str = result["old_path"] new_path_str = result["new_path"] item_type = result["item_type"] # Update index if item_type == "directory": from backend.indexer import reload_single_vault await reload_single_vault(vault_name) else: await handle_file_move(vault_name, old_path_str, new_path_str) # Broadcast SSE event await sse_manager.broadcast("item_moved", { "vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type, }) 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}