Files
DemonGatanjieu-Anomalous_Mo…/recycle_bin.py
T
DemonGatanjieuandClaude Opus 5.5 a2e3077726 feat(files): deleting the user's files goes to the Recycle Bin
recycle_bin.move_to_trash is now the only way the plugin deletes the
user's files: SHFileOperationW with FOF_ALLOWUNDO on fixed Windows drives
(send2trash or gio elsewhere). Where there is no Recycle Bin (USB stick,
network share) it raises TrashUnavailable and nothing is deleted; the API
says so (api/trash.trash_failure), including "file in use".

Routed through it: model delete (model + sidecars in one operation, or
nothing), cover replacement and cover reset, output images, generated
audio, recipes with history and images, materials with images, prompt
notes, parameter notes, the duplicate copy a physical rename finds and the
covers a forced rescan replaces. Temp files, caches and the bounded recipe
history are still removed directly.

The unused POST /anomalous/clean_civitai_info, which erased every
.civitai.info in the library (with the names users set), is removed with
its activity-log label. Delete confirmations say where things go. Tests use
a fake Recycle Bin (none of their files reach the real one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:42:49 +08:00

78 lines
2.8 KiB
Python

"""Deleting the user's files: always to the system Recycle Bin, never permanently.
Shared by the API and the standalone scanner. Where no recycle bin exists (a USB stick,
a network share, a Linux box without send2trash or gio), nothing is deleted and
TrashUnavailable says so; the caller reports it instead of falling back to a permanent
delete. Temporary files the plugin made itself are not deleted through here.
"""
import os
import shutil
import subprocess
import sys
class TrashUnavailable(OSError):
"""There is no recycle bin for this location; the files were left in place."""
def _windows_fixed_drive(path):
import ctypes
root = os.path.splitdrive(path)[0]
if not root or root.startswith("\\\\"):
return False # a UNC share: no recycle bin
return ctypes.windll.kernel32.GetDriveTypeW(root + "\\") == 3 # DRIVE_FIXED
def _windows_recycle(paths):
import ctypes
from ctypes import wintypes
class SHFILEOPSTRUCTW(ctypes.Structure):
_fields_ = [
("hwnd", wintypes.HWND), ("wFunc", wintypes.UINT),
("pFrom", wintypes.LPCWSTR), ("pTo", wintypes.LPCWSTR),
("fFlags", ctypes.c_ushort), ("fAnyOperationsAborted", wintypes.BOOL),
("hNameMappings", ctypes.c_void_p), ("lpszProgressTitle", wintypes.LPCWSTR),
]
if not all(_windows_fixed_drive(path) for path in paths):
raise TrashUnavailable("No Recycle Bin on this drive (removable or network)")
# FO_DELETE with FOF_ALLOWUNDO recycles; no dialogs. FOF_WANTNUKEWARNING makes Windows
# ask instead of silently deleting if this drive's Recycle Bin is switched off.
flags = 0x0040 | 0x0010 | 0x0004 | 0x0400 | 0x4000
op = SHFILEOPSTRUCTW(None, 3, "\0".join(paths) + "\0", None, flags, False, None, None)
result = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
if result != 0 or op.fAnyOperationsAborted:
raise OSError(result, f"Could not move to the Recycle Bin (code {result}); the file may be in use")
def _posix_recycle(paths):
try:
from send2trash import send2trash
except ImportError:
send2trash = None
if send2trash:
for path in paths:
send2trash(path)
return
gio = shutil.which("gio")
if not gio:
raise TrashUnavailable("No trash available (install send2trash)")
for path in paths:
subprocess.run([gio, "trash", path], check=True, capture_output=True)
def move_to_trash(*paths):
"""Moves the existing files and folders among ``paths`` to the Recycle Bin, together.
Returns the paths moved. Raises TrashUnavailable (nothing moved) or OSError."""
targets = [os.path.abspath(path) for path in paths if path and os.path.lexists(path)]
if not targets:
return []
if sys.platform == "win32":
_windows_recycle(targets)
else:
_posix_recycle(targets)
return targets