diff --git a/windows-agent/agent/service.py b/windows-agent/agent/service.py index fdde81b..22d26b2 100644 --- a/windows-agent/agent/service.py +++ b/windows-agent/agent/service.py @@ -23,6 +23,68 @@ logging.basicConfig( ) logger = logging.getLogger("OnEverAgent") +def send_to_recycle_bin(path_str: str) -> bool: + """ + Sends a file or folder to the Windows Recycle Bin using native shell32 API. + """ + try: + import ctypes + from ctypes import wintypes + + class SHFILEOPSTRUCTW(ctypes.Structure): + _fields_ = [ + ("hwnd", wintypes.HWND), + ("wFunc", wintypes.UINT), + ("pFrom", ctypes.c_wchar_p), + ("pTo", ctypes.c_wchar_p), + ("fFlags", ctypes.c_ushort), + ("fAnyOperationsAborted", wintypes.BOOL), + ("hNameMappings", wintypes.LPVOID), + ("lpszProgressTitle", ctypes.c_wchar_p), + ] + + path_null = os.path.abspath(path_str) + "\0\0" + fileop = SHFILEOPSTRUCTW() + fileop.hwnd = None + fileop.wFunc = 3 # FO_DELETE + fileop.pFrom = path_null + fileop.pTo = None + # FOF_ALLOWUNDO (0x0040) sends to Recycle Bin. FOF_NOCONFIRMATION (0x0010) + FOF_NOERRORUI (0x0400) + FOF_SILENT (0x0004) + fileop.fFlags = 0x0040 | 0x0010 | 0x0400 | 0x0004 + + res = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(fileop)) + return res == 0 + except Exception as e: + logger.error(f"Failed to recycle bin file {path_str} via ctypes: {e}") + return False + +def resolve_path_tags(path: str) -> str: + """ + Resolves Karen-style datetime tags inside file/folder paths. + """ + now = datetime.now() + replacements = { + "": now.strftime("%Y"), + "": now.strftime("%y"), + "": now.strftime("%Y"), + "": now.strftime("%Y"), + "": now.strftime("%b"), + "": now.strftime("%m"), + "": str(now.month), + "
": now.strftime("%d"), + "": str(now.day), + "": str(now.weekday() + 1), + "": str(now.weekday() + 1), + "": now.strftime("%H"), + "": now.strftime("%H"), + "": now.strftime("%M"), + "": now.strftime("%M"), + } + resolved = path + for tag, val in replacements.items(): + resolved = re.sub(re.escape(tag), val, resolved, flags=re.IGNORECASE) + return resolved + def is_job_due(schedule_str: str, last_run_str: Optional[str]) -> bool: if not schedule_str: return True @@ -368,7 +430,7 @@ class AgentDaemon: # Track files successfully backed up in this run active_relative_paths = [] - dest_dir = getattr(job, "local_destination_path", None) + dest_dir = resolve_path_tags(getattr(job, "local_destination_path", None)) if getattr(job, "local_destination_path", None) else None for filepath in files: rel_p = None @@ -490,8 +552,11 @@ class AgentDaemon: try: rel_to_dest = str(full_path.relative_to(dest_root)).replace("\\", "/") if rel_to_dest not in active_relative_paths: - logger.info(f"Removing local orphan file: {full_path}") - full_path.unlink() + logger.info(f"Recycling local orphan file: {full_path}") + recycled = send_to_recycle_bin(str(full_path)) + if not recycled: + logger.info(f"Recycle bin failed. Permanently deleting local orphan file: {full_path}") + full_path.unlink() except Exception as ex: logger.error(f"Error removing local orphan file {full_path}: {ex}") for root, dirs, files_in_dir in os.walk(dest_root, topdown=False): @@ -499,8 +564,11 @@ class AgentDaemon: dir_path = Path(root) / dir_name try: if not os.listdir(dir_path): - logger.info(f"Removing empty local directory: {dir_path}") - dir_path.rmdir() + logger.info(f"Recycling empty local directory: {dir_path}") + recycled = send_to_recycle_bin(str(dir_path)) + if not recycled: + logger.info(f"Recycle bin failed. Permanently removing empty local directory: {dir_path}") + dir_path.rmdir() except Exception: pass except Exception as e: diff --git a/windows-agent/agent_app_pyqt.py b/windows-agent/agent_app_pyqt.py index 18ac1b5..b83fb59 100644 --- a/windows-agent/agent_app_pyqt.py +++ b/windows-agent/agent_app_pyqt.py @@ -271,11 +271,16 @@ class AddFolderDialog(QDialog): dest_layout = QHBoxLayout() self.txt_dest = QLineEdit() - self.txt_dest.setPlaceholderText("Ej: D:\\LocalBackup (Opcional)") + self.txt_dest.setPlaceholderText("Ej: D:\\LocalBackup\\--
") btn_browse_dest = QPushButton("Explorar...") btn_browse_dest.clicked.connect(self._browse_dest_folder) + + btn_tags = QPushButton("Tags...") + btn_tags.clicked.connect(self._show_tags_menu) + dest_layout.addWidget(self.txt_dest) dest_layout.addWidget(btn_browse_dest) + dest_layout.addWidget(btn_tags) form.addRow("Copia Local Adicional:", dest_layout) layout.addLayout(form) @@ -304,6 +309,33 @@ class AddFolderDialog(QDialog): if folder: self.txt_dest.setText(folder) + def _show_tags_menu(self): + from PyQt6.QtWidgets import QMenu + from PyQt6.QtGui import QAction + + menu = QMenu(self) + tags = [ + ("", "Año actual (4 dígitos)"), + ("", "Año actual (2 dígitos)"), + ("", "Nombre del mes (ej: Jul)"), + ("", "Mes actual (01-12)"), + ("", "Mes actual (1-12)"), + ("
", "Día actual (01-31)"), + ("", "Día actual (1-31)"), + ("", "Día de la semana (1=Lunes...7=Domingo)"), + ("", "Hora actual (00-23)"), + ("", "Minuto actual (00-59)") + ] + + for tag, desc in tags: + action = QAction(f"{tag} - {desc}", self) + action.triggered.connect(lambda checked=False, t=tag: self.txt_dest.insert(t)) + menu.addAction(action) + + btn = self.sender() + if btn: + menu.exec(btn.mapToGlobal(btn.rect().bottomLeft())) + def _validate_and_accept(self): if not self.txt_path.text() or not os.path.exists(self.txt_path.text()): QMessageBox.warning(self, "Ruta Inválida", "Por favor selecciona una carpeta existente en Windows.")