feat: agent-side folder tags resolution and windows recycle bin ctypes integration
This commit is contained in:
@@ -23,6 +23,68 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("OnEverAgent")
|
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 = {
|
||||||
|
"<yyyy>": now.strftime("%Y"),
|
||||||
|
"<yy>": now.strftime("%y"),
|
||||||
|
"<year>": now.strftime("%Y"),
|
||||||
|
"<y>": now.strftime("%Y"),
|
||||||
|
"<month>": now.strftime("%b"),
|
||||||
|
"<mm>": now.strftime("%m"),
|
||||||
|
"<m>": str(now.month),
|
||||||
|
"<dd>": now.strftime("%d"),
|
||||||
|
"<d>": str(now.day),
|
||||||
|
"<dow>": str(now.weekday() + 1),
|
||||||
|
"<w>": str(now.weekday() + 1),
|
||||||
|
"<hour>": now.strftime("%H"),
|
||||||
|
"<hh>": now.strftime("%H"),
|
||||||
|
"<minute>": now.strftime("%M"),
|
||||||
|
"<min>": 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:
|
def is_job_due(schedule_str: str, last_run_str: Optional[str]) -> bool:
|
||||||
if not schedule_str:
|
if not schedule_str:
|
||||||
return True
|
return True
|
||||||
@@ -368,7 +430,7 @@ class AgentDaemon:
|
|||||||
|
|
||||||
# Track files successfully backed up in this run
|
# Track files successfully backed up in this run
|
||||||
active_relative_paths = []
|
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:
|
for filepath in files:
|
||||||
rel_p = None
|
rel_p = None
|
||||||
@@ -490,7 +552,10 @@ class AgentDaemon:
|
|||||||
try:
|
try:
|
||||||
rel_to_dest = str(full_path.relative_to(dest_root)).replace("\\", "/")
|
rel_to_dest = str(full_path.relative_to(dest_root)).replace("\\", "/")
|
||||||
if rel_to_dest not in active_relative_paths:
|
if rel_to_dest not in active_relative_paths:
|
||||||
logger.info(f"Removing local orphan file: {full_path}")
|
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()
|
full_path.unlink()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.error(f"Error removing local orphan file {full_path}: {ex}")
|
logger.error(f"Error removing local orphan file {full_path}: {ex}")
|
||||||
@@ -499,7 +564,10 @@ class AgentDaemon:
|
|||||||
dir_path = Path(root) / dir_name
|
dir_path = Path(root) / dir_name
|
||||||
try:
|
try:
|
||||||
if not os.listdir(dir_path):
|
if not os.listdir(dir_path):
|
||||||
logger.info(f"Removing empty local directory: {dir_path}")
|
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()
|
dir_path.rmdir()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -271,11 +271,16 @@ class AddFolderDialog(QDialog):
|
|||||||
|
|
||||||
dest_layout = QHBoxLayout()
|
dest_layout = QHBoxLayout()
|
||||||
self.txt_dest = QLineEdit()
|
self.txt_dest = QLineEdit()
|
||||||
self.txt_dest.setPlaceholderText("Ej: D:\\LocalBackup (Opcional)")
|
self.txt_dest.setPlaceholderText("Ej: D:\\LocalBackup\\<yyyy>-<mm>-<dd>")
|
||||||
btn_browse_dest = QPushButton("Explorar...")
|
btn_browse_dest = QPushButton("Explorar...")
|
||||||
btn_browse_dest.clicked.connect(self._browse_dest_folder)
|
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(self.txt_dest)
|
||||||
dest_layout.addWidget(btn_browse_dest)
|
dest_layout.addWidget(btn_browse_dest)
|
||||||
|
dest_layout.addWidget(btn_tags)
|
||||||
form.addRow("Copia Local Adicional:", dest_layout)
|
form.addRow("Copia Local Adicional:", dest_layout)
|
||||||
|
|
||||||
layout.addLayout(form)
|
layout.addLayout(form)
|
||||||
@@ -304,6 +309,33 @@ class AddFolderDialog(QDialog):
|
|||||||
if folder:
|
if folder:
|
||||||
self.txt_dest.setText(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 = [
|
||||||
|
("<yyyy>", "Año actual (4 dígitos)"),
|
||||||
|
("<yy>", "Año actual (2 dígitos)"),
|
||||||
|
("<month>", "Nombre del mes (ej: Jul)"),
|
||||||
|
("<mm>", "Mes actual (01-12)"),
|
||||||
|
("<m>", "Mes actual (1-12)"),
|
||||||
|
("<dd>", "Día actual (01-31)"),
|
||||||
|
("<d>", "Día actual (1-31)"),
|
||||||
|
("<dow>", "Día de la semana (1=Lunes...7=Domingo)"),
|
||||||
|
("<hour>", "Hora actual (00-23)"),
|
||||||
|
("<minute>", "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):
|
def _validate_and_accept(self):
|
||||||
if not self.txt_path.text() or not os.path.exists(self.txt_path.text()):
|
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.")
|
QMessageBox.warning(self, "Ruta Inválida", "Por favor selecciona una carpeta existente en Windows.")
|
||||||
|
|||||||
Reference in New Issue
Block a user