L'editeur lisait les styles mais ne les ecrivait pas, exportait la feuille entiere et laissait le dernier-ecrivain gagner entre processus. - A8 : bouton Mise en forme (gras/italique/souligne, alignements, couleurs via selecteur natif, formats de nombre, fusion, volets figes, largeur/hauteur) et nouvelle route PUT .../xlsx/style (verrou, backup, swap atomique, garde de perte, If-Match) ; A9 : decision "pas de moteur de formule" annoncee dans l'UI ; A10 : undo/redo unifie, piles par fichier conservees au re-rendu - A11 : export de la selection + Markdown/HTML/impression et recherche sur toutes les feuilles ; A12 : concurrence optimiste (If-Match -> 409 reparable, retry qui relit) ; A13 : cache des metadonnees par (chemin, mtime, taille) - A14 : outils IA .xlsm/.csv + search_workbook, analyze_range, edit_xlsx_structure - tests : test_xlsx_styles.py (17), test_spreadsheet_tools.py (34), TestOptimisticConcurrency/TestMetaCache, JSDOM xlsx-viewer 107/107
728 lines
27 KiB
Python
728 lines
27 KiB
Python
"""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,
|
|
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 (
|
|
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)
|
|
|
|
# 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``, ``font_color``/``fill_color`` as ``#rrggbb``,
|
|
``align`` and ``number_format``), ``merge``/``unmerge`` (``range``),
|
|
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``)
|
|
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
|
|
from backend.indexer import reload_single_vault
|
|
await reload_single_vault(vault_name)
|
|
|
|
# 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.
|
|
"""
|
|
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}
|