feat: agent-side folder tags resolution and windows recycle bin ctypes integration

This commit is contained in:
Carlos Tello
2026-08-17 00:27:02 -03:00
parent 8b2260bf14
commit 2cb23591ee
2 changed files with 106 additions and 6 deletions
+73 -5
View File
@@ -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 = {
"<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:
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: