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>
78 lines
2.8 KiB
Python
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
|