2025-12-11 12:47:30 -08:00
|
|
|
"""Delete-file cmdlet: Delete files from local storage and/or Hydrus."""
|
2025-11-25 20:09:33 -08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import Any, Dict, Sequence
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
2025-12-11 19:04:02 -08:00
|
|
|
from SYS.logger import debug, log
|
|
|
|
|
from Store.Folder import Folder
|
2025-12-16 01:45:01 -08:00
|
|
|
from Store import Store
|
2025-12-16 23:23:43 -08:00
|
|
|
from . import _shared as sh
|
2025-12-11 19:04:02 -08:00
|
|
|
from API import HydrusNetwork as hydrus_wrapper
|
2025-12-11 12:47:30 -08:00
|
|
|
import pipeline as ctx
|
2025-12-01 01:10:16 -08:00
|
|
|
|
|
|
|
|
|
2025-12-16 23:23:43 -08:00
|
|
|
class Delete_File(sh.Cmdlet):
|
2025-12-11 12:47:30 -08:00
|
|
|
"""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 <sha256>] [-conserve <local|hydrus>] [-lib-root <path>] [reason]",
|
|
|
|
|
alias=["del-file"],
|
|
|
|
|
arg=[
|
2025-12-16 23:23:43 -08:00
|
|
|
sh.CmdletArg("hash", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
|
|
|
|
|
sh.CmdletArg("conserve", description="Choose which copy to keep: 'local' or 'hydrus'."),
|
|
|
|
|
sh.CmdletArg("lib-root", description="Path to local library root for database cleanup."),
|
|
|
|
|
sh.CmdletArg("reason", description="Optional reason for deletion (free text)."),
|
2025-12-11 12:47:30 -08:00
|
|
|
],
|
|
|
|
|
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")
|
2025-12-16 23:23:43 -08:00
|
|
|
title_val = item.get("title") or item.get("name")
|
2025-12-11 12:47:30 -08:00
|
|
|
else:
|
2025-12-16 23:23:43 -08:00
|
|
|
hash_hex_raw = sh.get_field(item, "hash_hex") or sh.get_field(item, "hash")
|
|
|
|
|
target = sh.get_field(item, "target") or sh.get_field(item, "file_path") or sh.get_field(item, "path")
|
|
|
|
|
title_val = sh.get_field(item, "title") or sh.get_field(item, "name")
|
2025-11-25 20:09:33 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
store = None
|
|
|
|
|
if isinstance(item, dict):
|
|
|
|
|
store = item.get("store")
|
|
|
|
|
else:
|
2025-12-16 23:23:43 -08:00
|
|
|
store = sh.get_field(item, "store")
|
2025-12-11 19:04:02 -08:00
|
|
|
|
|
|
|
|
store_lower = str(store).lower() if store else ""
|
|
|
|
|
is_hydrus_store = bool(store_lower) and ("hydrus" in store_lower or store_lower in {"home", "work"})
|
2025-12-16 23:23:43 -08:00
|
|
|
store_label = str(store) if store else "default"
|
|
|
|
|
hydrus_prefix = f"[hydrusnetwork:{store_label}]"
|
2025-12-01 01:10:16 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
# For Hydrus files, the target IS the hash
|
2025-12-11 19:04:02 -08:00
|
|
|
if is_hydrus_store and not hash_hex_raw:
|
2025-12-11 12:47:30 -08:00
|
|
|
hash_hex_raw = target
|
2025-11-25 20:09:33 -08:00
|
|
|
|
2025-12-16 23:23:43 -08:00
|
|
|
hash_hex = sh.normalize_hash(override_hash) if override_hash else sh.normalize_hash(hash_hex_raw)
|
2025-11-25 20:09:33 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
local_deleted = False
|
|
|
|
|
local_target = isinstance(target, str) and target.strip() and not str(target).lower().startswith(("http://", "https://"))
|
2025-11-25 20:09:33 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
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:
|
2025-12-11 19:04:02 -08:00
|
|
|
folder = Folder(Path(lib_root), name=store or "local")
|
2025-12-11 12:47:30 -08:00
|
|
|
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)
|
2025-12-01 01:10:16 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
# Remove common sidecars regardless of file removal success
|
2025-12-11 23:21:45 -08:00
|
|
|
for sidecar in (
|
|
|
|
|
path.with_suffix(".tag"),
|
|
|
|
|
path.with_suffix(".metadata"),
|
|
|
|
|
path.with_suffix(".notes"),
|
|
|
|
|
):
|
2025-12-01 01:10:16 -08:00
|
|
|
try:
|
2025-12-11 12:47:30 -08:00
|
|
|
if sidecar.exists() and sidecar.is_file():
|
|
|
|
|
sidecar.unlink()
|
2025-12-01 01:10:16 -08:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
hydrus_deleted = False
|
2025-12-11 19:04:02 -08:00
|
|
|
should_try_hydrus = is_hydrus_store
|
2025-12-11 12:47:30 -08:00
|
|
|
|
|
|
|
|
# If conserve is set to hydrus, definitely don't delete
|
|
|
|
|
if conserve == "hydrus":
|
|
|
|
|
should_try_hydrus = False
|
|
|
|
|
|
|
|
|
|
if should_try_hydrus and hash_hex:
|
2025-12-16 01:45:01 -08:00
|
|
|
client = None
|
|
|
|
|
if store:
|
|
|
|
|
# Store specified: do not fall back to a global/default Hydrus client.
|
|
|
|
|
try:
|
|
|
|
|
registry = Store(config)
|
|
|
|
|
backend = registry[str(store)]
|
|
|
|
|
candidate = getattr(backend, "_client", None)
|
|
|
|
|
if candidate is not None and hasattr(candidate, "_post"):
|
|
|
|
|
client = candidate
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
if not local_deleted:
|
|
|
|
|
log(f"Hydrus client unavailable for store '{store}': {exc}", file=sys.stderr)
|
|
|
|
|
return False
|
|
|
|
|
if client is None:
|
|
|
|
|
if not local_deleted:
|
|
|
|
|
log(f"Hydrus client unavailable for store '{store}'", file=sys.stderr)
|
|
|
|
|
return False
|
2025-11-25 20:09:33 -08:00
|
|
|
else:
|
2025-12-16 01:45:01 -08:00
|
|
|
# No store context; use default Hydrus client.
|
|
|
|
|
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
|
2025-12-11 12:47:30 -08:00
|
|
|
if client is None:
|
2025-11-25 20:09:33 -08:00
|
|
|
if not local_deleted:
|
2025-12-11 12:47:30 -08:00
|
|
|
log("Hydrus client unavailable", file=sys.stderr)
|
2025-11-27 10:59:01 -08:00
|
|
|
return False
|
2025-12-16 01:45:01 -08:00
|
|
|
|
|
|
|
|
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
|
2025-12-16 23:23:43 -08:00
|
|
|
title_str = str(title_val).strip() if title_val else ""
|
|
|
|
|
if title_str:
|
|
|
|
|
debug(f"{hydrus_prefix} Deleted title:{title_str} hash:{hash_hex}", file=sys.stderr)
|
|
|
|
|
else:
|
|
|
|
|
debug(f"{hydrus_prefix} Deleted hash:{hash_hex}", file=sys.stderr)
|
2025-12-16 01:45:01 -08:00
|
|
|
except Exception:
|
|
|
|
|
# If it's not in Hydrus (e.g. 404 or similar), that's fine
|
|
|
|
|
if not local_deleted:
|
|
|
|
|
return False
|
2025-12-11 12:47:30 -08:00
|
|
|
|
|
|
|
|
if hydrus_deleted and hash_hex:
|
2025-12-16 23:23:43 -08:00
|
|
|
title_str = str(title_val).strip() if title_val else ""
|
2025-11-25 20:09:33 -08:00
|
|
|
if reason:
|
2025-12-16 23:23:43 -08:00
|
|
|
if title_str:
|
|
|
|
|
ctx.emit(f"{hydrus_prefix} Deleted title:{title_str} hash:{hash_hex} (reason: {reason}).")
|
|
|
|
|
else:
|
|
|
|
|
ctx.emit(f"{hydrus_prefix} Deleted hash:{hash_hex} (reason: {reason}).")
|
2025-11-25 20:09:33 -08:00
|
|
|
else:
|
2025-12-16 23:23:43 -08:00
|
|
|
if title_str:
|
|
|
|
|
ctx.emit(f"{hydrus_prefix} Deleted title:{title_str} hash:{hash_hex}.")
|
|
|
|
|
else:
|
|
|
|
|
ctx.emit(f"{hydrus_prefix} Deleted hash:{hash_hex}.")
|
2025-11-25 20:09:33 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
if hydrus_deleted or local_deleted:
|
|
|
|
|
return True
|
2025-11-27 10:59:01 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
log("Selected result has neither Hydrus hash nor local file target")
|
|
|
|
|
return False
|
2025-11-27 10:59:01 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|
|
|
|
"""Execute delete-file command."""
|
2025-12-16 23:23:43 -08:00
|
|
|
if sh.should_show_help(args):
|
2025-12-11 12:47:30 -08:00
|
|
|
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
|
2025-11-27 10:59:01 -08:00
|
|
|
return 0
|
|
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
# 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()
|
2025-11-27 10:59:01 -08:00
|
|
|
i += 2
|
|
|
|
|
continue
|
2025-12-11 12:47:30 -08:00
|
|
|
reason_tokens.append(token)
|
|
|
|
|
i += 1
|
2025-11-27 10:59:01 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
# 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
|
2025-12-01 01:10:16 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
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
|
2025-11-27 10:59:01 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
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
|
2025-11-27 10:59:01 -08:00
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
return 0 if success_count > 0 else 1
|
2025-12-07 00:21:30 -08:00
|
|
|
|
|
|
|
|
|
2025-12-11 12:47:30 -08:00
|
|
|
# Instantiate and register the cmdlet
|
|
|
|
|
Delete_File()
|
2025-11-25 20:09:33 -08:00
|
|
|
|
|
|
|
|
|