"""Delete-file cmdlet: Delete files from local storage and/or Hydrus.""" from __future__ import annotations from typing import Any, Dict, Sequence import sys from pathlib import Path from helper.logger import debug, log from helper.store import Folder from ._shared import Cmdlet, CmdletArg, normalize_hash, looks_like_hash, get_origin, get_field, should_show_help from helper import hydrus as hydrus_wrapper import pipeline as ctx class Delete_File(Cmdlet): """Class-based delete-file cmdlet with self-registration.""" def __init__(self) -> None: super().__init__( name="delete-file", summary="Delete a file locally and/or from Hydrus, including database entries.", usage="delete-file [-hash ] [-conserve ] [-lib-root ] [reason]", alias=["del-file"], arg=[ CmdletArg("hash", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."), CmdletArg("conserve", description="Choose which copy to keep: 'local' or 'hydrus'."), CmdletArg("lib-root", description="Path to local library root for database cleanup."), CmdletArg("reason", description="Optional reason for deletion (free text)."), ], detail=[ "Default removes both the local file and Hydrus file.", "Use -conserve local to keep the local file, or -conserve hydrus to keep it in Hydrus.", "Database entries are automatically cleaned up for local files.", "Any remaining arguments are treated as the Hydrus reason text.", ], exec=self.run, ) self.register() def _process_single_item(self, item: Any, override_hash: str | None, conserve: str | None, lib_root: str | None, reason: str, config: Dict[str, Any]) -> bool: """Process deletion for a single item.""" # Handle item as either dict or object if isinstance(item, dict): hash_hex_raw = item.get("hash_hex") or item.get("hash") target = item.get("target") or item.get("file_path") or item.get("path") else: hash_hex_raw = get_field(item, "hash_hex") or get_field(item, "hash") target = get_field(item, "target") or get_field(item, "file_path") or get_field(item, "path") origin = get_origin(item) # Also check the store field explicitly from PipeObject store = None if isinstance(item, dict): store = item.get("store") else: store = get_field(item, "store") # For Hydrus files, the target IS the hash if origin and origin.lower() == "hydrus" and not hash_hex_raw: hash_hex_raw = target hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(hash_hex_raw) local_deleted = False local_target = isinstance(target, str) and target.strip() and not str(target).lower().startswith(("http://", "https://")) if conserve != "local" and local_target: path = Path(str(target)) # If lib_root is provided and this is from a folder store, use the Folder class if lib_root: try: folder = Folder(Path(lib_root), name=origin or "local") if folder.delete_file(str(path)): local_deleted = True ctx.emit(f"Removed file: {path.name}") log(f"Deleted: {path.name}", file=sys.stderr) except Exception as exc: debug(f"Folder.delete_file failed: {exc}", file=sys.stderr) # Fallback to manual deletion try: if path.exists() and path.is_file(): path.unlink() local_deleted = True ctx.emit(f"Removed local file: {path}") log(f"Deleted: {path.name}", file=sys.stderr) except Exception as exc: log(f"Local delete failed: {exc}", file=sys.stderr) else: # No lib_root, just delete the file try: if path.exists() and path.is_file(): path.unlink() local_deleted = True ctx.emit(f"Removed local file: {path}") log(f"Deleted: {path.name}", file=sys.stderr) except Exception as exc: log(f"Local delete failed: {exc}", file=sys.stderr) # Remove common sidecars regardless of file removal success for sidecar in (path.with_suffix(".tags"), path.with_suffix(".tags.txt"), path.with_suffix(".metadata"), path.with_suffix(".notes")): try: if sidecar.exists() and sidecar.is_file(): sidecar.unlink() except Exception: pass hydrus_deleted = False # Only attempt Hydrus deletion if store is explicitly Hydrus-related # Check both origin and store fields to determine if this is a Hydrus file should_try_hydrus = False # Check if store indicates this is a Hydrus backend if store and ("hydrus" in store.lower() or store.lower() == "home" or store.lower() == "work"): should_try_hydrus = True # Fallback to origin check if store not available elif origin and origin.lower() == "hydrus": should_try_hydrus = True # If conserve is set to hydrus, definitely don't delete if conserve == "hydrus": should_try_hydrus = False if should_try_hydrus and hash_hex: try: client = hydrus_wrapper.get_client(config) except Exception as exc: if not local_deleted: log(f"Hydrus client unavailable: {exc}", file=sys.stderr) return False else: if client is None: if not local_deleted: log("Hydrus client unavailable", file=sys.stderr) return False else: payload: Dict[str, Any] = {"hashes": [hash_hex]} if reason: payload["reason"] = reason try: client._post("/add_files/delete_files", data=payload) # type: ignore[attr-defined] hydrus_deleted = True preview = hash_hex[:12] + ('…' if len(hash_hex) > 12 else '') debug(f"Deleted from Hydrus: {preview}…", file=sys.stderr) except Exception as exc: # If it's not in Hydrus (e.g. 404 or similar), that's fine if not local_deleted: return False if hydrus_deleted and hash_hex: preview = hash_hex[:12] + ('…' if len(hash_hex) > 12 else '') if reason: ctx.emit(f"Deleted {preview} (reason: {reason}).") else: ctx.emit(f"Deleted {preview}.") if hydrus_deleted or local_deleted: return True log("Selected result has neither Hydrus hash nor local file target") return False def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: """Execute delete-file command.""" if should_show_help(args): log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}") return 0 # Parse arguments override_hash: str | None = None conserve: str | None = None lib_root: str | None = None reason_tokens: list[str] = [] i = 0 while i < len(args): token = args[i] low = str(token).lower() if low in {"-hash", "--hash", "hash"} and i + 1 < len(args): override_hash = str(args[i + 1]).strip() i += 2 continue if low in {"-conserve", "--conserve"} and i + 1 < len(args): value = str(args[i + 1]).strip().lower() if value in {"local", "hydrus"}: conserve = value i += 2 continue if low in {"-lib-root", "--lib-root", "lib-root"} and i + 1 < len(args): lib_root = str(args[i + 1]).strip() i += 2 continue reason_tokens.append(token) i += 1 # If no lib_root provided, try to get the first folder store from config if not lib_root: try: storage_config = config.get("storage", {}) folder_config = storage_config.get("folder", {}) if folder_config: # Get first folder store path for store_name, store_config in folder_config.items(): if isinstance(store_config, dict): path = store_config.get("path") if path: lib_root = path break except Exception: pass reason = " ".join(token for token in reason_tokens if str(token).strip()).strip() items = [] if isinstance(result, list): items = result elif result: items = [result] if not items: log("No items to delete", file=sys.stderr) return 1 success_count = 0 for item in items: if self._process_single_item(item, override_hash, conserve, lib_root, reason, config): success_count += 1 if success_count > 0: # Clear cached tables/items so deleted entries are not redisplayed try: ctx.set_last_result_table_overlay(None, None, None) ctx.set_last_result_table(None, []) ctx.set_last_result_items_only([]) ctx.set_current_stage_table(None) except Exception: pass return 0 if success_count > 0 else 1 # Instantiate and register the cmdlet Delete_File()