feat: outils gestion fichiers dans le VWB (📁 Fichiers)
- 5 actions : lister, créer dossier, déplacer, copier, classer par extension - Exécution sur Windows via agent port 5006 - Sécurité chemins (bloque C:\Windows, /etc, etc.) - Propriétés panel + preview canvas pour chaque action Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
"""
|
||||
Mini serveur HTTP sur l'agent Windows pour les captures d'ecran a la demande.
|
||||
Mini serveur HTTP sur l'agent Windows pour les captures d'ecran a la demande
|
||||
et les operations fichiers.
|
||||
|
||||
Ecoute sur le port 5006 (configurable via RPA_CAPTURE_PORT).
|
||||
Endpoints :
|
||||
GET /capture -> screenshot frais en base64 (JPEG)
|
||||
GET /health -> {"status": "ok"}
|
||||
POST /file-action -> operations fichiers (list, create, move, copy, sort)
|
||||
"""
|
||||
import threading
|
||||
import logging
|
||||
@@ -21,7 +23,10 @@ CAPTURE_PORT = int(os.environ.get("RPA_CAPTURE_PORT", "5006"))
|
||||
|
||||
|
||||
class CaptureHandler(BaseHTTPRequestHandler):
|
||||
"""Retourne un screenshot frais a chaque requete GET /capture."""
|
||||
"""Retourne un screenshot frais a chaque requete GET /capture.
|
||||
|
||||
Gere aussi les actions fichiers via POST /file-action.
|
||||
"""
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/capture":
|
||||
@@ -31,6 +36,12 @@ class CaptureHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/file-action":
|
||||
self._handle_file_action()
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
|
||||
def do_OPTIONS(self):
|
||||
"""Gestion CORS preflight."""
|
||||
self.send_response(200)
|
||||
@@ -40,6 +51,37 @@ class CaptureHandler(BaseHTTPRequestHandler):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_file_action(self):
|
||||
"""Execute une action fichier sur la machine Windows locale.
|
||||
|
||||
Body JSON attendu :
|
||||
{"action": "file_sort_by_ext", "params": {"source_dir": "C:\\..."}}
|
||||
"""
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length)
|
||||
data = json.loads(body.decode("utf-8"))
|
||||
|
||||
action = data.get("action", "")
|
||||
params = data.get("params", {})
|
||||
|
||||
if not action:
|
||||
self._send_json(400, {"error": "Parametre 'action' requis"})
|
||||
return
|
||||
|
||||
handler = _FileActionHandlerLocal()
|
||||
result = handler.execute(action, params)
|
||||
code = 500 if "error" in result else 200
|
||||
self._send_json(code, result)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self._send_json(400, {"error": "JSON invalide"})
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur file-action : {e}")
|
||||
self._send_json(500, {"error": str(e)})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_capture(self):
|
||||
"""Capture l'ecran principal et le renvoie en base64 JPEG."""
|
||||
t0 = time.perf_counter()
|
||||
@@ -86,7 +128,7 @@ class CaptureHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _cors_headers(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
def log_message(self, format, *args):
|
||||
@@ -94,6 +136,207 @@ class CaptureHandler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gestionnaire d'actions fichiers local (execute sur la machine Windows)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Repertoires autorises sur Windows (securite anti-traversal)
|
||||
_WIN_ALLOWED_ROOTS = [
|
||||
"C:\\Users",
|
||||
"D:\\",
|
||||
"E:\\",
|
||||
]
|
||||
|
||||
|
||||
def _normalize_win_path(path_str: str) -> str:
|
||||
"""Normalise un chemin Windows."""
|
||||
import ntpath
|
||||
return ntpath.normpath(path_str)
|
||||
|
||||
|
||||
def _is_safe_win_path(path_str: str) -> bool:
|
||||
"""Verifie qu'un chemin Windows est dans une zone autorisee."""
|
||||
if not path_str or not path_str.strip():
|
||||
return False
|
||||
norm = _normalize_win_path(path_str).upper()
|
||||
return any(norm.startswith(root.upper()) for root in _WIN_ALLOWED_ROOTS)
|
||||
|
||||
|
||||
class _FileActionHandlerLocal:
|
||||
"""Execute les operations fichiers sur la machine locale (Windows)."""
|
||||
|
||||
def execute(self, action_type: str, params: dict) -> dict:
|
||||
"""Dispatch vers la bonne methode selon le type d'action."""
|
||||
handlers = {
|
||||
"file_list_dir": self._list_dir,
|
||||
"file_create_dir": self._create_dir,
|
||||
"file_move": self._move_file,
|
||||
"file_copy": self._copy_file,
|
||||
"file_sort_by_ext": self._sort_by_extension,
|
||||
}
|
||||
handler = handlers.get(action_type)
|
||||
if not handler:
|
||||
return {"error": f"Action fichier inconnue : {action_type}"}
|
||||
try:
|
||||
return handler(params)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur action fichier '{action_type}' : {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _list_dir(self, params: dict) -> dict:
|
||||
"""Liste les fichiers d'un dossier."""
|
||||
import fnmatch as _fnmatch
|
||||
from pathlib import Path as _Path
|
||||
|
||||
path_str = params.get("path", "")
|
||||
pattern = params.get("pattern", "*")
|
||||
if not path_str:
|
||||
return {"error": "Parametre 'path' requis"}
|
||||
if not _is_safe_win_path(path_str):
|
||||
return {"error": f"Chemin non autorise : {path_str}"}
|
||||
|
||||
source = _Path(path_str)
|
||||
if not source.exists():
|
||||
return {"error": f"Dossier introuvable : {path_str}"}
|
||||
if not source.is_dir():
|
||||
return {"error": f"Pas un dossier : {path_str}"}
|
||||
|
||||
files = []
|
||||
extensions = {}
|
||||
for item in source.iterdir():
|
||||
if item.is_file() and _fnmatch.fnmatch(item.name, pattern):
|
||||
ext = item.suffix.lstrip(".").lower() or "sans_extension"
|
||||
files.append({
|
||||
"name": item.name,
|
||||
"extension": ext,
|
||||
"size": item.stat().st_size,
|
||||
"path": str(item),
|
||||
})
|
||||
extensions[ext] = extensions.get(ext, 0) + 1
|
||||
|
||||
logger.info(f"Liste dossier '{path_str}' : {len(files)} fichiers")
|
||||
return {"files": files, "count": len(files), "extensions": extensions, "path": path_str}
|
||||
|
||||
def _create_dir(self, params: dict) -> dict:
|
||||
"""Cree un dossier (parents inclus)."""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
path_str = params.get("path", "")
|
||||
if not path_str:
|
||||
return {"error": "Parametre 'path' requis"}
|
||||
if not _is_safe_win_path(path_str):
|
||||
return {"error": f"Chemin non autorise : {path_str}"}
|
||||
|
||||
target = _Path(path_str)
|
||||
existed = target.exists()
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Dossier '{path_str}' {'existait deja' if existed else 'cree'}")
|
||||
return {"created": not existed, "path": path_str, "already_existed": existed}
|
||||
|
||||
def _move_file(self, params: dict) -> dict:
|
||||
"""Deplace ou renomme un fichier."""
|
||||
import shutil as _shutil
|
||||
from pathlib import Path as _Path
|
||||
|
||||
src = params.get("source", "")
|
||||
dst = params.get("destination", "")
|
||||
if not src or not dst:
|
||||
return {"error": "Parametres 'source' et 'destination' requis"}
|
||||
if not _is_safe_win_path(src):
|
||||
return {"error": f"Source non autorisee : {src}"}
|
||||
if not _is_safe_win_path(dst):
|
||||
return {"error": f"Destination non autorisee : {dst}"}
|
||||
|
||||
if not _Path(src).exists():
|
||||
return {"error": f"Fichier source introuvable : {src}"}
|
||||
|
||||
_Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
||||
_shutil.move(src, dst)
|
||||
logger.info(f"Fichier deplace : '{src}' -> '{dst}'")
|
||||
return {"moved": True, "source": src, "destination": dst}
|
||||
|
||||
def _copy_file(self, params: dict) -> dict:
|
||||
"""Copie un fichier."""
|
||||
import shutil as _shutil
|
||||
from pathlib import Path as _Path
|
||||
|
||||
src = params.get("source", "")
|
||||
dst = params.get("destination", "")
|
||||
if not src or not dst:
|
||||
return {"error": "Parametres 'source' et 'destination' requis"}
|
||||
if not _is_safe_win_path(src):
|
||||
return {"error": f"Source non autorisee : {src}"}
|
||||
if not _is_safe_win_path(dst):
|
||||
return {"error": f"Destination non autorisee : {dst}"}
|
||||
|
||||
source = _Path(src)
|
||||
if not source.exists():
|
||||
return {"error": f"Fichier source introuvable : {src}"}
|
||||
|
||||
_Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.is_dir():
|
||||
_shutil.copytree(src, dst)
|
||||
else:
|
||||
_shutil.copy2(src, dst)
|
||||
logger.info(f"Fichier copie : '{src}' -> '{dst}'")
|
||||
return {"copied": True, "source": src, "destination": dst}
|
||||
|
||||
def _sort_by_extension(self, params: dict) -> dict:
|
||||
"""Classe les fichiers par extension dans des sous-dossiers."""
|
||||
import shutil as _shutil
|
||||
from pathlib import Path as _Path
|
||||
|
||||
source_dir_str = params.get("source_dir", "")
|
||||
create_subdirs = params.get("create_subdirs", True)
|
||||
|
||||
if not source_dir_str:
|
||||
return {"error": "Parametre 'source_dir' requis"}
|
||||
if not _is_safe_win_path(source_dir_str):
|
||||
return {"error": f"Chemin non autorise : {source_dir_str}"}
|
||||
|
||||
source = _Path(source_dir_str)
|
||||
if not source.exists():
|
||||
return {"error": f"Dossier introuvable : {source_dir_str}"}
|
||||
if not source.is_dir():
|
||||
return {"error": f"Pas un dossier : {source_dir_str}"}
|
||||
|
||||
moved = []
|
||||
extensions = {}
|
||||
|
||||
for f in source.iterdir():
|
||||
if f.is_file():
|
||||
ext = f.suffix.lstrip(".").lower() or "sans_extension"
|
||||
target_dir = source / ext
|
||||
|
||||
if create_subdirs:
|
||||
target_dir.mkdir(exist_ok=True)
|
||||
elif not target_dir.exists():
|
||||
continue
|
||||
|
||||
dest = target_dir / f.name
|
||||
# Eviter ecrasement
|
||||
if dest.exists():
|
||||
base = f.stem
|
||||
counter = 1
|
||||
while dest.exists():
|
||||
dest = target_dir / f"{base}_{counter}{f.suffix}"
|
||||
counter += 1
|
||||
|
||||
_shutil.move(str(f), str(dest))
|
||||
moved.append({"file": f.name, "to": ext, "destination": str(dest)})
|
||||
extensions[ext] = extensions.get(ext, 0) + 1
|
||||
|
||||
logger.info(
|
||||
f"Classement par extension '{source_dir_str}' : {len(moved)} fichiers"
|
||||
)
|
||||
return {
|
||||
"moved": moved,
|
||||
"count": len(moved),
|
||||
"extensions": extensions,
|
||||
"source_dir": source_dir_str,
|
||||
}
|
||||
|
||||
|
||||
class CaptureServer:
|
||||
"""Serveur de capture d'ecran en temps reel (thread daemon)."""
|
||||
|
||||
|
||||
16
visual_workflow_builder/backend/actions/files/__init__.py
Normal file
16
visual_workflow_builder/backend/actions/files/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Actions de gestion de fichiers pour le VWB.
|
||||
|
||||
Auteur : Dom, Claude — 18 mars 2026
|
||||
|
||||
Actions :
|
||||
- file_list_dir : Lister les fichiers d'un dossier
|
||||
- file_create_dir : Créer un dossier
|
||||
- file_move : Déplacer un fichier
|
||||
- file_copy : Copier un fichier
|
||||
- file_sort_by_ext : Classer les fichiers par extension
|
||||
"""
|
||||
|
||||
from .file_actions import FileActionHandler
|
||||
|
||||
__all__ = ['FileActionHandler']
|
||||
Binary file not shown.
Binary file not shown.
353
visual_workflow_builder/backend/actions/files/file_actions.py
Normal file
353
visual_workflow_builder/backend/actions/files/file_actions.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
Gestionnaire d'actions fichiers pour le VWB.
|
||||
|
||||
Auteur : Dom, Claude — 18 mars 2026
|
||||
|
||||
Opérations de gestion de fichiers : lister, créer dossier, déplacer,
|
||||
copier, classer par extension. Compatible chemins Windows (backslash)
|
||||
et Linux (slash).
|
||||
|
||||
Sécurité : validation des chemins pour empêcher le path traversal
|
||||
en dehors des répertoires autorisés.
|
||||
"""
|
||||
|
||||
import fnmatch
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Répertoires autorisés par défaut sur Windows (racines légitimes)
|
||||
# On autorise les chemins sous les profils utilisateur et les lecteurs standards
|
||||
_WINDOWS_ALLOWED_ROOTS = [
|
||||
"C:\\Users",
|
||||
"D:\\",
|
||||
"E:\\",
|
||||
]
|
||||
|
||||
# Répertoires autorisés sur Linux
|
||||
_LINUX_ALLOWED_ROOTS = [
|
||||
"/home",
|
||||
"/tmp",
|
||||
]
|
||||
|
||||
|
||||
def _normalize_path(path_str: str) -> str:
|
||||
"""Normalise un chemin en résolvant .. et en nettoyant les séparateurs."""
|
||||
# Déterminer si c'est un chemin Windows (contient : comme C:\ ou \)
|
||||
if ':' in path_str[:3] or path_str.startswith('\\\\'):
|
||||
# Chemin Windows : normaliser avec ntpath
|
||||
import ntpath
|
||||
return ntpath.normpath(path_str)
|
||||
else:
|
||||
return os.path.normpath(path_str)
|
||||
|
||||
|
||||
def _is_safe_path(path_str: str) -> bool:
|
||||
"""Vérifie qu'un chemin est sûr (pas de traversal hors zones autorisées).
|
||||
|
||||
Bloque les chemins contenant des séquences de traversal dangereuses
|
||||
et vérifie que le chemin résolu reste dans une zone autorisée.
|
||||
"""
|
||||
normalized = _normalize_path(path_str)
|
||||
|
||||
# Bloquer les chemins vides
|
||||
if not normalized or normalized.strip() in ('', '.'):
|
||||
return False
|
||||
|
||||
# Déterminer le système de fichiers cible
|
||||
is_windows = ':' in normalized[:3] or normalized.startswith('\\\\')
|
||||
|
||||
if is_windows:
|
||||
allowed_roots = _WINDOWS_ALLOWED_ROOTS
|
||||
# Vérifier que le chemin est sous une racine autorisée
|
||||
norm_upper = normalized.upper()
|
||||
return any(norm_upper.startswith(root.upper()) for root in allowed_roots)
|
||||
else:
|
||||
allowed_roots = _LINUX_ALLOWED_ROOTS
|
||||
return any(normalized.startswith(root) for root in allowed_roots)
|
||||
|
||||
|
||||
class FileActionHandler:
|
||||
"""Gère les opérations sur les fichiers.
|
||||
|
||||
Peut s'exécuter en local (serveur Linux) ou les paramètres
|
||||
peuvent être transmis à l'agent Windows via le proxy.
|
||||
"""
|
||||
|
||||
def __init__(self, allowed_roots: Optional[List[str]] = None):
|
||||
"""Initialise le gestionnaire.
|
||||
|
||||
Args:
|
||||
allowed_roots: Racines autorisées supplémentaires (optionnel).
|
||||
"""
|
||||
if allowed_roots:
|
||||
_WINDOWS_ALLOWED_ROOTS.extend(
|
||||
r for r in allowed_roots if r not in _WINDOWS_ALLOWED_ROOTS
|
||||
)
|
||||
|
||||
def execute(self, action_type: str, params: dict, target: str = 'local') -> dict:
|
||||
"""Exécute une action fichier.
|
||||
|
||||
Args:
|
||||
action_type: Type d'action (file_list_dir, file_create_dir, etc.)
|
||||
params: Paramètres de l'action
|
||||
target: 'local' pour exécution serveur, 'windows' pour l'agent
|
||||
|
||||
Returns:
|
||||
Dict avec le résultat ou une erreur
|
||||
"""
|
||||
handlers = {
|
||||
'file_list_dir': self._list_dir,
|
||||
'file_create_dir': self._create_dir,
|
||||
'file_move': self._move_file,
|
||||
'file_copy': self._copy_file,
|
||||
'file_sort_by_ext': self._sort_by_extension,
|
||||
}
|
||||
|
||||
handler = handlers.get(action_type)
|
||||
if not handler:
|
||||
return {'error': f"Type d'action fichier inconnu : {action_type}"}
|
||||
|
||||
try:
|
||||
return handler(params)
|
||||
except Exception as e:
|
||||
logger.error("Erreur action fichier '%s' : %s", action_type, e)
|
||||
return {'error': str(e)}
|
||||
|
||||
def _list_dir(self, params: dict) -> dict:
|
||||
"""Liste les fichiers d'un dossier.
|
||||
|
||||
Args:
|
||||
params: {'path': str, 'pattern': str (optionnel, défaut '*')}
|
||||
|
||||
Returns:
|
||||
{'files': [...], 'count': int, 'extensions': {...}}
|
||||
"""
|
||||
path_str = params.get('path', '')
|
||||
pattern = params.get('pattern', '*')
|
||||
|
||||
if not path_str:
|
||||
return {'error': "Paramètre 'path' requis"}
|
||||
if not _is_safe_path(path_str):
|
||||
return {'error': f"Chemin non autorisé : {path_str}"}
|
||||
|
||||
source = Path(path_str)
|
||||
if not source.exists():
|
||||
return {'error': f"Dossier introuvable : {path_str}"}
|
||||
if not source.is_dir():
|
||||
return {'error': f"Le chemin n'est pas un dossier : {path_str}"}
|
||||
|
||||
files = []
|
||||
extensions: Dict[str, int] = {}
|
||||
|
||||
for item in source.iterdir():
|
||||
if item.is_file():
|
||||
# Appliquer le filtre glob
|
||||
if not fnmatch.fnmatch(item.name, pattern):
|
||||
continue
|
||||
|
||||
ext = item.suffix.lstrip('.').lower() or 'sans_extension'
|
||||
size = item.stat().st_size
|
||||
files.append({
|
||||
'name': item.name,
|
||||
'extension': ext,
|
||||
'size': size,
|
||||
'path': str(item),
|
||||
})
|
||||
extensions[ext] = extensions.get(ext, 0) + 1
|
||||
|
||||
logger.info(
|
||||
"Listage dossier '%s' : %d fichiers, extensions : %s",
|
||||
path_str, len(files), extensions,
|
||||
)
|
||||
|
||||
return {
|
||||
'files': files,
|
||||
'count': len(files),
|
||||
'extensions': extensions,
|
||||
'path': path_str,
|
||||
}
|
||||
|
||||
def _create_dir(self, params: dict) -> dict:
|
||||
"""Crée un dossier (et les sous-dossiers si nécessaire).
|
||||
|
||||
Args:
|
||||
params: {'path': str}
|
||||
|
||||
Returns:
|
||||
{'created': bool, 'path': str}
|
||||
"""
|
||||
path_str = params.get('path', '')
|
||||
if not path_str:
|
||||
return {'error': "Paramètre 'path' requis"}
|
||||
if not _is_safe_path(path_str):
|
||||
return {'error': f"Chemin non autorisé : {path_str}"}
|
||||
|
||||
target = Path(path_str)
|
||||
already_exists = target.exists()
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(
|
||||
"Dossier '%s' %s",
|
||||
path_str,
|
||||
"existait déjà" if already_exists else "créé",
|
||||
)
|
||||
|
||||
return {
|
||||
'created': not already_exists,
|
||||
'path': path_str,
|
||||
'already_existed': already_exists,
|
||||
}
|
||||
|
||||
def _move_file(self, params: dict) -> dict:
|
||||
"""Déplace ou renomme un fichier.
|
||||
|
||||
Args:
|
||||
params: {'source': str, 'destination': str}
|
||||
|
||||
Returns:
|
||||
{'moved': bool, 'source': str, 'destination': str}
|
||||
"""
|
||||
source_str = params.get('source', '')
|
||||
dest_str = params.get('destination', '')
|
||||
|
||||
if not source_str or not dest_str:
|
||||
return {'error': "Paramètres 'source' et 'destination' requis"}
|
||||
if not _is_safe_path(source_str):
|
||||
return {'error': f"Chemin source non autorisé : {source_str}"}
|
||||
if not _is_safe_path(dest_str):
|
||||
return {'error': f"Chemin destination non autorisé : {dest_str}"}
|
||||
|
||||
source = Path(source_str)
|
||||
if not source.exists():
|
||||
return {'error': f"Fichier source introuvable : {source_str}"}
|
||||
|
||||
dest = Path(dest_str)
|
||||
# Créer le dossier parent si nécessaire
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
shutil.move(str(source), str(dest))
|
||||
|
||||
logger.info("Fichier déplacé : '%s' → '%s'", source_str, dest_str)
|
||||
|
||||
return {
|
||||
'moved': True,
|
||||
'source': source_str,
|
||||
'destination': dest_str,
|
||||
}
|
||||
|
||||
def _copy_file(self, params: dict) -> dict:
|
||||
"""Copie un fichier vers un autre emplacement.
|
||||
|
||||
Args:
|
||||
params: {'source': str, 'destination': str}
|
||||
|
||||
Returns:
|
||||
{'copied': bool, 'source': str, 'destination': str}
|
||||
"""
|
||||
source_str = params.get('source', '')
|
||||
dest_str = params.get('destination', '')
|
||||
|
||||
if not source_str or not dest_str:
|
||||
return {'error': "Paramètres 'source' et 'destination' requis"}
|
||||
if not _is_safe_path(source_str):
|
||||
return {'error': f"Chemin source non autorisé : {source_str}"}
|
||||
if not _is_safe_path(dest_str):
|
||||
return {'error': f"Chemin destination non autorisé : {dest_str}"}
|
||||
|
||||
source = Path(source_str)
|
||||
if not source.exists():
|
||||
return {'error': f"Fichier source introuvable : {source_str}"}
|
||||
|
||||
dest = Path(dest_str)
|
||||
# Créer le dossier parent si nécessaire
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if source.is_dir():
|
||||
shutil.copytree(str(source), str(dest))
|
||||
else:
|
||||
shutil.copy2(str(source), str(dest))
|
||||
|
||||
logger.info("Fichier copié : '%s' → '%s'", source_str, dest_str)
|
||||
|
||||
return {
|
||||
'copied': True,
|
||||
'source': source_str,
|
||||
'destination': dest_str,
|
||||
}
|
||||
|
||||
def _sort_by_extension(self, params: dict) -> dict:
|
||||
"""Classe les fichiers par extension dans des sous-dossiers.
|
||||
|
||||
Crée un sous-dossier par extension (pdf/, docx/, jpg/, etc.)
|
||||
et déplace chaque fichier dans le sous-dossier correspondant.
|
||||
|
||||
Args:
|
||||
params: {'source_dir': str, 'create_subdirs': bool (défaut True)}
|
||||
|
||||
Returns:
|
||||
{'moved': [...], 'count': int, 'extensions': {...}}
|
||||
"""
|
||||
source_dir_str = params.get('source_dir', '')
|
||||
create_subdirs = params.get('create_subdirs', True)
|
||||
|
||||
if not source_dir_str:
|
||||
return {'error': "Paramètre 'source_dir' requis"}
|
||||
if not _is_safe_path(source_dir_str):
|
||||
return {'error': f"Chemin non autorisé : {source_dir_str}"}
|
||||
|
||||
source = Path(source_dir_str)
|
||||
if not source.exists():
|
||||
return {'error': f"Dossier introuvable : {source_dir_str}"}
|
||||
if not source.is_dir():
|
||||
return {'error': f"Le chemin n'est pas un dossier : {source_dir_str}"}
|
||||
|
||||
moved = []
|
||||
extensions: Dict[str, int] = {}
|
||||
|
||||
for file in source.iterdir():
|
||||
if file.is_file():
|
||||
ext = file.suffix.lstrip('.').lower() or 'sans_extension'
|
||||
target_dir = source / ext
|
||||
|
||||
if create_subdirs:
|
||||
target_dir.mkdir(exist_ok=True)
|
||||
elif not target_dir.exists():
|
||||
logger.warning(
|
||||
"Sous-dossier '%s' inexistant et create_subdirs=False, fichier ignoré : %s",
|
||||
ext, file.name,
|
||||
)
|
||||
continue
|
||||
|
||||
dest = target_dir / file.name
|
||||
# Éviter d'écraser un fichier existant
|
||||
if dest.exists():
|
||||
base = file.stem
|
||||
counter = 1
|
||||
while dest.exists():
|
||||
dest = target_dir / f"{base}_{counter}{file.suffix}"
|
||||
counter += 1
|
||||
|
||||
shutil.move(str(file), str(dest))
|
||||
moved.append({
|
||||
'file': file.name,
|
||||
'to': ext,
|
||||
'destination': str(dest),
|
||||
})
|
||||
extensions[ext] = extensions.get(ext, 0) + 1
|
||||
|
||||
logger.info(
|
||||
"Classement par extension dans '%s' : %d fichiers déplacés, extensions : %s",
|
||||
source_dir_str, len(moved), extensions,
|
||||
)
|
||||
|
||||
return {
|
||||
'moved': moved,
|
||||
'count': len(moved),
|
||||
'extensions': extensions,
|
||||
'source_dir': source_dir_str,
|
||||
}
|
||||
@@ -77,6 +77,12 @@ _CONDITION_ACTION_TYPES = {"visual_condition"}
|
||||
# Actions VWB de type data loop
|
||||
_DATA_LOOP_ACTION_TYPES = {"import_excel", "db_foreach"}
|
||||
|
||||
# Actions VWB de gestion de fichiers
|
||||
_FILE_ACTION_TYPES = {
|
||||
"file_list_dir", "file_create_dir", "file_move",
|
||||
"file_copy", "file_sort_by_ext",
|
||||
}
|
||||
|
||||
|
||||
def _classify_step_type(action_type: str) -> StepType:
|
||||
"""Détermine le StepType DAG à partir du action_type VWB."""
|
||||
@@ -982,6 +988,43 @@ def execute_windows():
|
||||
if vwb_type in ('keyboard_shortcut', 'hotkey') and 'keys' in params:
|
||||
action['keys'] = params['keys']
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Actions fichiers → proxy vers /file-action de l'agent (port 5006)
|
||||
# ---------------------------------------------------------------
|
||||
if 'actions' in data:
|
||||
file_actions = [a for a in data['actions'] if a.get('type', '') in _FILE_ACTION_TYPES]
|
||||
if file_actions:
|
||||
# Exécuter les actions fichiers via l'agent Windows
|
||||
file_results = []
|
||||
for fa in file_actions:
|
||||
try:
|
||||
fa_resp = req.post(
|
||||
'http://192.168.1.11:5006/file-action',
|
||||
json={
|
||||
'action': fa['type'],
|
||||
'params': fa.get('parameters', {}),
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
file_results.append(fa_resp.json())
|
||||
except req.ConnectionError:
|
||||
file_results.append({
|
||||
'error': "Agent Windows (port 5006) non disponible pour l'action fichier"
|
||||
})
|
||||
except Exception as e:
|
||||
file_results.append({'error': str(e)})
|
||||
|
||||
# Si TOUTES les actions sont des actions fichiers, retourner directement
|
||||
non_file_actions = [a for a in data['actions'] if a.get('type', '') not in _FILE_ACTION_TYPES]
|
||||
if not non_file_actions:
|
||||
return jsonify({
|
||||
'success': all('error' not in r for r in file_results),
|
||||
'file_results': file_results,
|
||||
})
|
||||
|
||||
# Sinon, retirer les actions fichiers du flux principal
|
||||
data['actions'] = non_file_actions
|
||||
|
||||
# Injecter le machine_id pour le ciblage multi-machine
|
||||
# Chercher la première machine Windows connectée si pas spécifié
|
||||
if 'machine_id' not in data or not data.get('machine_id'):
|
||||
|
||||
@@ -360,6 +360,53 @@ VWB_ACTION_CONTRACTS: Dict[str, ActionContract] = {
|
||||
required_params=[],
|
||||
optional_params=["prompt", "context", "model", "temperature"],
|
||||
),
|
||||
|
||||
# --- ACTIONS GESTION DE FICHIERS ---
|
||||
"file_list_dir": ActionContract(
|
||||
action_type="file_list_dir",
|
||||
description="Lister les fichiers d'un dossier",
|
||||
required_params=["path"],
|
||||
optional_params=["pattern"],
|
||||
param_validators={"path": lambda p: bool(p and isinstance(p, str) and p.strip())}
|
||||
),
|
||||
|
||||
"file_create_dir": ActionContract(
|
||||
action_type="file_create_dir",
|
||||
description="Créer un dossier (et les sous-dossiers si nécessaire)",
|
||||
required_params=["path"],
|
||||
optional_params=[],
|
||||
param_validators={"path": lambda p: bool(p and isinstance(p, str) and p.strip())}
|
||||
),
|
||||
|
||||
"file_move": ActionContract(
|
||||
action_type="file_move",
|
||||
description="Déplacer ou renommer un fichier",
|
||||
required_params=["source", "destination"],
|
||||
optional_params=[],
|
||||
param_validators={
|
||||
"source": lambda p: bool(p and isinstance(p, str) and p.strip()),
|
||||
"destination": lambda p: bool(p and isinstance(p, str) and p.strip()),
|
||||
}
|
||||
),
|
||||
|
||||
"file_copy": ActionContract(
|
||||
action_type="file_copy",
|
||||
description="Copier un fichier vers un autre emplacement",
|
||||
required_params=["source", "destination"],
|
||||
optional_params=[],
|
||||
param_validators={
|
||||
"source": lambda p: bool(p and isinstance(p, str) and p.strip()),
|
||||
"destination": lambda p: bool(p and isinstance(p, str) and p.strip()),
|
||||
}
|
||||
),
|
||||
|
||||
"file_sort_by_ext": ActionContract(
|
||||
action_type="file_sort_by_ext",
|
||||
description="Classer les fichiers par extension dans des sous-dossiers",
|
||||
required_params=["source_dir"],
|
||||
optional_params=["create_subdirs"],
|
||||
param_validators={"source_dir": lambda p: bool(p and isinstance(p, str) and p.strip())}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1155,6 +1155,140 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
|
||||
</>
|
||||
);
|
||||
|
||||
// === GESTION DE FICHIERS ===
|
||||
case 'file_list_dir':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-section-title">
|
||||
<span className="icon">📂</span> Lister un dossier
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Chemin du dossier</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.path || '')}
|
||||
onChange={(e) => updateParam('path', e.target.value)}
|
||||
placeholder="C:\Users\dom\Downloads\anonymise"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Filtre (glob)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.pattern || '*')}
|
||||
onChange={(e) => updateParam('pattern', e.target.value)}
|
||||
placeholder="*.pdf, *.*, *.docx"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'file_create_dir':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-section-title">
|
||||
<span className="icon">📁</span> Créer un dossier
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Chemin du dossier</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.path || '')}
|
||||
onChange={(e) => updateParam('path', e.target.value)}
|
||||
placeholder="C:\Users\dom\Downloads\anonymise\pdf"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-info">
|
||||
Les dossiers parents seront créés automatiquement si nécessaire.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'file_move':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-section-title">
|
||||
<span className="icon">📎</span> Déplacer un fichier
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Chemin source</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.source || '')}
|
||||
onChange={(e) => updateParam('source', e.target.value)}
|
||||
placeholder="C:\Users\dom\Downloads\document.pdf"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Chemin destination</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.destination || '')}
|
||||
onChange={(e) => updateParam('destination', e.target.value)}
|
||||
placeholder="C:\Users\dom\Downloads\anonymise\pdf\document.pdf"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'file_copy':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-section-title">
|
||||
<span className="icon">📋</span> Copier un fichier
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Chemin source</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.source || '')}
|
||||
onChange={(e) => updateParam('source', e.target.value)}
|
||||
placeholder="C:\Users\dom\Downloads\document.pdf"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Chemin destination</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.destination || '')}
|
||||
onChange={(e) => updateParam('destination', e.target.value)}
|
||||
placeholder="C:\Users\dom\Archives\document.pdf"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'file_sort_by_ext':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-section-title">
|
||||
<span className="icon">🗂️</span> Classer par extension
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Dossier source</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.source_dir || '')}
|
||||
onChange={(e) => updateParam('source_dir', e.target.value)}
|
||||
placeholder="C:\Users\dom\Downloads\anonymise"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(params.create_subdirs !== false)}
|
||||
onChange={(e) => updateParam('create_subdirs', e.target.checked)}
|
||||
/>
|
||||
Créer les sous-dossiers automatiquement
|
||||
</label>
|
||||
</div>
|
||||
<div className="prop-info">
|
||||
Les fichiers seront déplacés dans des sous-dossiers nommés par extension (pdf/, docx/, jpg/, etc.)
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// === VALIDATION ===
|
||||
case 'verify_element_exists':
|
||||
return (
|
||||
|
||||
@@ -16,6 +16,7 @@ function StepNode({ data, selected }: StepNodeProps) {
|
||||
const isConditional = step.action_type === 'visual_condition' || step.action_type === 'loop_visual';
|
||||
const isDataLoop = step.action_type === 'db_foreach';
|
||||
const isImport = step.action_type === 'import_excel';
|
||||
const isFileAction = step.action_type.startsWith('file_');
|
||||
|
||||
// État du tooltip d'aide
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
@@ -34,7 +35,7 @@ function StepNode({ data, selected }: StepNodeProps) {
|
||||
}, [showHelp]);
|
||||
|
||||
return (
|
||||
<div className={`step-node ${selected ? 'selected' : ''} ${isConditional ? 'conditional' : ''} ${isDataLoop ? 'data-loop' : ''} ${isImport ? 'data-import' : ''}`}>
|
||||
<div className={`step-node ${selected ? 'selected' : ''} ${isConditional ? 'conditional' : ''} ${isDataLoop ? 'data-loop' : ''} ${isImport ? 'data-import' : ''} ${isFileAction ? 'file-action' : ''}`}>
|
||||
{/* Bouton aide (?) */}
|
||||
{action && (
|
||||
<button
|
||||
@@ -82,7 +83,8 @@ function StepNode({ data, selected }: StepNodeProps) {
|
||||
title="Supprimer (Suppr)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
data.onDelete?.(step.id);
|
||||
// Dispatch un custom event que App.tsx écoute (contourne le memo)
|
||||
window.dispatchEvent(new CustomEvent('rpa-delete-step', { detail: step.id }));
|
||||
}}
|
||||
>
|
||||
×
|
||||
@@ -152,6 +154,29 @@ function StepNode({ data, selected }: StepNodeProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aperçu actions fichiers */}
|
||||
{step.action_type === 'file_list_dir' && typeof step.parameters?.path === 'string' && step.parameters.path.length > 0 && (
|
||||
<div className="step-node-params">
|
||||
{`📂 ${String(step.parameters.path).split(/[\\/]/).pop() || String(step.parameters.path)}`}
|
||||
{step.parameters.pattern && step.parameters.pattern !== '*' ? ` (${String(step.parameters.pattern)})` : ''}
|
||||
</div>
|
||||
)}
|
||||
{step.action_type === 'file_create_dir' && typeof step.parameters?.path === 'string' && step.parameters.path.length > 0 && (
|
||||
<div className="step-node-params">
|
||||
{`📁 ${String(step.parameters.path).split(/[\\/]/).pop() || String(step.parameters.path)}`}
|
||||
</div>
|
||||
)}
|
||||
{(step.action_type === 'file_move' || step.action_type === 'file_copy') && typeof step.parameters?.source === 'string' && step.parameters.source.length > 0 && (
|
||||
<div className="step-node-params">
|
||||
{`${String(step.parameters.source).split(/[\\/]/).pop()} → ${String(step.parameters.destination || '?').split(/[\\/]/).pop()}`}
|
||||
</div>
|
||||
)}
|
||||
{step.action_type === 'file_sort_by_ext' && typeof step.parameters?.source_dir === 'string' && step.parameters.source_dir.length > 0 && (
|
||||
<div className="step-node-params">
|
||||
{`🗂️ ${String(step.parameters.source_dir).split(/[\\/]/).pop() || String(step.parameters.source_dir)}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!step.anchor_id && action?.needsAnchor && (
|
||||
<div className="step-node-warning">
|
||||
Ancre requise
|
||||
|
||||
@@ -55,14 +55,20 @@ export type ActionType =
|
||||
| 'llm_analyze'
|
||||
| 'llm_translate'
|
||||
| 'llm_extract_data'
|
||||
| 'llm_generate';
|
||||
| 'llm_generate'
|
||||
// === Gestion de fichiers ===
|
||||
| 'file_list_dir'
|
||||
| 'file_create_dir'
|
||||
| 'file_move'
|
||||
| 'file_copy'
|
||||
| 'file_sort_by_ext';
|
||||
|
||||
export interface ActionDefinition {
|
||||
type: ActionType;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
category: 'mouse' | 'keyboard' | 'wait' | 'data' | 'logic' | 'ai' | 'llm' | 'validation';
|
||||
category: 'mouse' | 'keyboard' | 'wait' | 'data' | 'logic' | 'ai' | 'llm' | 'validation' | 'files';
|
||||
needsAnchor: boolean;
|
||||
params: { name: string; type: string; description: string }[];
|
||||
}
|
||||
@@ -200,6 +206,27 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
{ name: 'model', type: 'string', description: 'Modèle Ollama' }
|
||||
] },
|
||||
|
||||
// === GESTION DE FICHIERS ===
|
||||
{ type: 'file_list_dir', label: 'Lister un dossier', icon: '📂', description: 'Liste les fichiers d\'un dossier et retourne leurs noms et extensions.', category: 'files', needsAnchor: false, params: [
|
||||
{ name: 'path', type: 'string', description: 'Chemin du dossier' },
|
||||
{ name: 'pattern', type: 'string', description: 'Filtre (ex: *.pdf, *.*)' },
|
||||
] },
|
||||
{ type: 'file_create_dir', label: 'Créer un dossier', icon: '📁', description: 'Crée un dossier (et les sous-dossiers si nécessaire).', category: 'files', needsAnchor: false, params: [
|
||||
{ name: 'path', type: 'string', description: 'Chemin du dossier à créer' },
|
||||
] },
|
||||
{ type: 'file_move', label: 'Déplacer un fichier', icon: '📎', description: 'Déplace ou renomme un fichier.', category: 'files', needsAnchor: false, params: [
|
||||
{ name: 'source', type: 'string', description: 'Chemin source' },
|
||||
{ name: 'destination', type: 'string', description: 'Chemin destination' },
|
||||
] },
|
||||
{ type: 'file_copy', label: 'Copier un fichier', icon: '📋', description: 'Copie un fichier vers un autre emplacement.', category: 'files', needsAnchor: false, params: [
|
||||
{ name: 'source', type: 'string', description: 'Chemin source' },
|
||||
{ name: 'destination', type: 'string', description: 'Chemin destination' },
|
||||
] },
|
||||
{ type: 'file_sort_by_ext', label: 'Classer par extension', icon: '🗂️', description: 'Crée des sous-dossiers par extension et déplace les fichiers.', category: 'files', needsAnchor: false, params: [
|
||||
{ name: 'source_dir', type: 'string', description: 'Dossier source' },
|
||||
{ name: 'create_subdirs', type: 'boolean', description: 'Créer les sous-dossiers automatiquement' },
|
||||
] },
|
||||
|
||||
// === VALIDATION ===
|
||||
{ type: 'verify_element_exists', label: 'Vérifier présence', icon: '✅', description: 'Vérifie qu\'un élément visuel est présent à l\'écran.', category: 'validation', needsAnchor: true, params: [
|
||||
{ name: 'timeout_ms', type: 'number', description: 'Délai max d\'attente en millisecondes' }
|
||||
@@ -217,6 +244,7 @@ export const ACTION_CATEGORIES = {
|
||||
logic: { label: 'Logique', icon: '🔀' },
|
||||
ai: { label: 'IA', icon: '🤖' },
|
||||
llm: { label: 'IA / LLM', icon: '🧪' },
|
||||
files: { label: 'Fichiers', icon: '📁' },
|
||||
validation: { label: 'Validation', icon: '✅' },
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user