Files
Medios-Macina/cmdlet/delete_file.py

618 lines
25 KiB
Python
Raw Normal View History

2025-12-11 12:47:30 -08:00
"""Delete-file cmdlet: Delete files from local storage and/or Hydrus."""
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
from __future__ import annotations
2025-12-20 23:57:44 -08:00
from typing import Any, Dict, List, Sequence
2025-11-25 20:09:33 -08:00
import sys
from pathlib import Path
2025-12-11 19:04:02 -08:00
from SYS.logger import debug, log
2025-12-20 23:57:44 -08:00
from SYS.utils import format_bytes
2025-12-11 19:04:02 -08:00
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-20 23:57:44 -08:00
from result_table import ResultTable, _format_size
from rich_display import stdout_console
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 [-query "hash:<sha256>"] [-conserve <local|hydrus>] [-lib-root <path>] [reason]',
2025-12-11 12:47:30 -08:00
alias=["del-file"],
arg=[
2025-12-20 02:12:45 -08:00
sh.SharedArgs.QUERY,
2025-12-29 17:05:03 -08:00
sh.CmdletArg(
"conserve",
description="Choose which copy to keep: 'local' or 'hydrus'."
2025-12-29 17:05:03 -08:00
),
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-29 17:05:03 -08:00
),
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()
2025-12-20 23:57:44 -08:00
def _process_single_item(
self,
item: Any,
override_hash: str | None,
conserve: str | None,
lib_root: str | None,
reason: str,
config: Dict[str,
Any],
) -> List[Dict[str,
Any]]:
2025-12-20 23:57:44 -08:00
"""Process deletion for a single item.
Returns display rows (for the final Rich table). Returning an empty list
indicates no delete occurred.
"""
2025-12-11 12:47:30 -08:00
# 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")
2025-12-29 17:05:03 -08:00
target = (
sh.get_field(item,
"target") or sh.get_field(item,
"file_path")
or sh.get_field(item,
"path")
2025-12-29 17:05:03 -08:00
)
2025-12-16 23:23:43 -08:00
title_val = sh.get_field(item, "title") or sh.get_field(item, "name")
2025-12-20 23:57:44 -08:00
def _get_ext_from_item() -> str:
try:
if isinstance(item, dict):
ext_val = item.get("ext")
if ext_val:
return str(ext_val)
extra = item.get("extra")
if isinstance(extra, dict) and extra.get("ext"):
return str(extra.get("ext"))
else:
ext_val = sh.get_field(item, "ext")
if ext_val:
return str(ext_val)
extra = sh.get_field(item, "extra")
if isinstance(extra, dict) and extra.get("ext"):
return str(extra.get("ext"))
except Exception:
pass
# Fallback: infer from target path or title if it looks like a filename
try:
if isinstance(target, str) and target:
suffix = Path(target).suffix
if suffix:
return suffix.lstrip(".")
except Exception:
pass
try:
if title_val:
suffix = Path(str(title_val)).suffix
if suffix:
return suffix.lstrip(".")
except Exception:
pass
return ""
2025-12-29 17:05:03 -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 ""
2025-12-26 18:58:48 -08:00
backend = None
try:
if store:
registry = Store(config)
if registry.is_available(str(store)):
backend = registry[str(store)]
except Exception:
backend = None
# Determine whether the store backend is HydrusNetwork.
# IMPORTANT: Hydrus instances are named by the user (e.g. 'home', 'rpi'),
# so checking only the store name is unreliable.
is_hydrus_store = False
try:
if backend is not None:
from Store.HydrusNetwork import HydrusNetwork as HydrusStore
is_hydrus_store = isinstance(backend, HydrusStore)
except Exception:
is_hydrus_store = False
# Backwards-compatible fallback heuristic (older items might only carry a name).
if ((not is_hydrus_store) and bool(store_lower)
and ("hydrus" in store_lower or store_lower in {"home",
"work"})):
2025-12-26 18:58:48 -08:00
is_hydrus_store = True
2025-12-16 23:23:43 -08:00
store_label = str(store) if store else "default"
hydrus_prefix = f"[hydrusnetwork:{store_label}]"
2025-12-29 17:05:03 -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-29 17:05:03 -08:00
hash_hex = (
sh.normalize_hash(override_hash)
if override_hash else sh.normalize_hash(hash_hex_raw)
2025-12-29 17:05:03 -08:00
)
2025-11-25 20:09:33 -08:00
2025-12-11 12:47:30 -08:00
local_deleted = False
2025-12-29 17:05:03 -08:00
local_target = (
isinstance(target,
str) and target.strip()
and not str(target).lower().startswith(("http://",
"https://"))
2025-12-29 17:05:03 -08:00
)
2025-12-20 23:57:44 -08:00
deleted_rows: List[Dict[str, Any]] = []
2025-12-23 16:36:39 -08:00
# If this item references a configured non-Hydrus store backend, prefer deleting
# via the backend API. This supports store items where `path`/`target` is the hash.
if conserve != "local" and store and (not is_hydrus_store):
try:
2025-12-26 18:58:48 -08:00
# Re-use an already resolved backend when available.
if backend is None:
registry = Store(config)
if registry.is_available(str(store)):
backend = registry[str(store)]
if backend is not None:
2025-12-23 16:36:39 -08:00
# Prefer hash when available.
hash_candidate = sh.normalize_hash(
hash_hex_raw
) if hash_hex_raw else None
2025-12-23 16:36:39 -08:00
if not hash_candidate and isinstance(target, str):
hash_candidate = sh.normalize_hash(target)
resolved_path = None
try:
if hash_candidate and hasattr(backend, "get_file"):
2025-12-26 18:58:48 -08:00
candidate_path = backend.get_file(hash_candidate)
2025-12-29 17:05:03 -08:00
resolved_path = (
candidate_path if isinstance(candidate_path,
Path) else None
2025-12-29 17:05:03 -08:00
)
2025-12-23 16:36:39 -08:00
except Exception:
resolved_path = None
2025-12-29 17:05:03 -08:00
identifier = hash_candidate or (
str(target).strip() if isinstance(target,
str) else ""
2025-12-29 17:05:03 -08:00
)
2025-12-23 16:36:39 -08:00
if identifier:
deleter = getattr(backend, "delete_file", None)
if callable(deleter) and bool(deleter(identifier)):
local_deleted = True
size_bytes: int | None = None
try:
if (resolved_path is not None
and isinstance(resolved_path,
Path)
and resolved_path.exists()):
2025-12-23 16:36:39 -08:00
size_bytes = int(resolved_path.stat().st_size)
except Exception:
size_bytes = None
deleted_rows.append(
{
2025-12-29 17:05:03 -08:00
"title": (
str(title_val).strip() if title_val else (
resolved_path.name
if resolved_path else identifier
)
),
"store":
store_label,
"hash":
hash_candidate or (hash_hex or ""),
"size_bytes":
size_bytes,
"ext":
_get_ext_from_item() or (
resolved_path.suffix.lstrip(".")
if resolved_path else ""
2025-12-29 17:05:03 -08:00
),
2025-12-23 16:36:39 -08:00
}
)
# Best-effort remove sidecars if we know the resolved path.
try:
if resolved_path is not None and isinstance(
resolved_path,
Path):
2025-12-23 16:36:39 -08:00
for sidecar in (
resolved_path.with_suffix(".tag"),
resolved_path.with_suffix(".metadata"),
resolved_path.with_suffix(".notes"),
2025-12-23 16:36:39 -08:00
):
try:
if sidecar.exists() and sidecar.is_file():
sidecar.unlink()
except Exception:
pass
except Exception:
pass
# Skip legacy local-path deletion below.
local_target = False
except Exception:
pass
2025-12-29 17:05:03 -08:00
2025-12-11 12:47:30 -08:00
if conserve != "local" and local_target:
path = Path(str(target))
2025-12-20 23:57:44 -08:00
size_bytes: int | None = None
try:
if path.exists() and path.is_file():
size_bytes = int(path.stat().st_size)
except Exception:
size_bytes = None
2025-12-29 17:05:03 -08:00
2025-12-11 12:47:30 -08:00
# 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
2025-12-20 23:57:44 -08:00
deleted_rows.append(
{
"title":
str(title_val).strip() if title_val else path.name,
2025-12-20 23:57:44 -08:00
"store": store_label,
"hash": hash_hex or sh.normalize_hash(path.stem) or "",
"size_bytes": size_bytes,
"ext": _get_ext_from_item() or path.suffix.lstrip("."),
}
)
2025-12-11 12:47:30 -08:00
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
2025-12-20 23:57:44 -08:00
deleted_rows.append(
{
"title":
str(title_val).strip() if title_val else path.name,
2025-12-20 23:57:44 -08:00
"store": store_label,
"hash": hash_hex or sh.normalize_hash(path.stem)
or "",
2025-12-20 23:57:44 -08:00
"size_bytes": size_bytes,
"ext": _get_ext_from_item()
or path.suffix.lstrip("."),
2025-12-20 23:57:44 -08:00
}
)
2025-12-11 12:47:30 -08:00
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
2025-12-20 23:57:44 -08:00
deleted_rows.append(
{
"title":
str(title_val).strip() if title_val else path.name,
2025-12-20 23:57:44 -08:00
"store": store_label,
"hash": hash_hex or sh.normalize_hash(path.stem) or "",
"size_bytes": size_bytes,
"ext": _get_ext_from_item() or path.suffix.lstrip("."),
}
)
2025-12-11 12:47:30 -08:00
except Exception as exc:
log(f"Local delete failed: {exc}", file=sys.stderr)
2025-12-29 17:05:03 -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-11 23:21:45 -08:00
):
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-29 17:05:03 -08:00
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
2025-12-29 17:05:03 -08:00
2025-12-11 12:47:30 -08:00
if should_try_hydrus and hash_hex:
2025-12-27 14:50:59 -08:00
# Prefer deleting via the resolved store backend when it is a HydrusNetwork store.
# This ensures store-specific post-delete hooks run (e.g., clearing Hydrus deletion records).
did_backend_delete = False
2025-12-16 01:45:01 -08:00
try:
2025-12-27 14:50:59 -08:00
if backend is not None:
deleter = getattr(backend, "delete_file", None)
if callable(deleter):
did_backend_delete = bool(deleter(hash_hex, reason=reason))
except Exception:
did_backend_delete = False
if did_backend_delete:
2025-12-16 01:45:01 -08:00
hydrus_deleted = True
2025-12-16 23:23:43 -08:00
title_str = str(title_val).strip() if title_val else ""
if title_str:
2025-12-29 17:05:03 -08:00
debug(
f"{hydrus_prefix} Deleted title:{title_str} hash:{hash_hex}",
file=sys.stderr,
)
2025-12-16 23:23:43 -08:00
else:
debug(f"{hydrus_prefix} Deleted hash:{hash_hex}", file=sys.stderr)
2025-12-27 14:50:59 -08:00
else:
# Fallback to direct client calls.
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:
2025-12-29 17:05:03 -08:00
log(
f"Hydrus client unavailable for store '{store}': {exc}",
file=sys.stderr,
)
2025-12-27 14:50:59 -08:00
return False
if client is None:
if not local_deleted:
log(
f"Hydrus client unavailable for store '{store}'",
file=sys.stderr
)
2025-12-27 14:50:59 -08:00
return False
else:
# 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
if client is None:
if not local_deleted:
log("Hydrus client unavailable", file=sys.stderr)
return False
payload: Dict[str,
Any] = {
"hashes": [hash_hex]
}
2025-12-27 14:50:59 -08:00
if reason:
payload["reason"] = reason
try:
client._post(
"/add_files/delete_files",
data=payload
) # type: ignore[attr-defined]
2025-12-27 14:50:59 -08:00
# Best-effort clear deletion record if supported by this client.
try:
clearer = getattr(client, "clear_file_deletion_record", None)
if callable(clearer):
clearer([hash_hex])
else:
client._post(
"/add_files/clear_file_deletion_record",
data={
"hashes": [hash_hex]
}
) # type: ignore[attr-defined]
2025-12-27 14:50:59 -08:00
except Exception:
pass
hydrus_deleted = True
title_str = str(title_val).strip() if title_val else ""
if title_str:
2025-12-29 17:05:03 -08:00
debug(
f"{hydrus_prefix} Deleted title:{title_str} hash:{hash_hex}",
file=sys.stderr,
)
2025-12-27 14:50:59 -08:00
else:
debug(
f"{hydrus_prefix} Deleted hash:{hash_hex}",
file=sys.stderr
)
2025-12-27 14:50:59 -08:00
except Exception:
# If it's not in Hydrus (e.g. 404 or similar), that's fine
if not local_deleted:
return []
2025-12-11 12:47:30 -08:00
if hydrus_deleted and hash_hex:
2025-12-20 23:57:44 -08:00
size_hint = None
try:
if isinstance(item, dict):
size_hint = item.get("size_bytes") or item.get("size")
2025-12-16 23:23:43 -08:00
else:
size_hint = sh.get_field(item,
"size_bytes"
) or sh.get_field(item,
"size")
2025-12-20 23:57:44 -08:00
except Exception:
size_hint = None
deleted_rows.append(
{
"title": str(title_val).strip() if title_val else "",
"store": store_label,
"hash": hash_hex,
"size_bytes": size_hint,
"ext": _get_ext_from_item(),
}
)
2025-11-25 20:09:33 -08:00
2025-12-11 12:47:30 -08:00
if hydrus_deleted or local_deleted:
2025-12-20 23:57:44 -08:00
return deleted_rows
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")
2025-12-20 23:57:44 -08:00
return []
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
2025-12-20 02:12:45 -08:00
override_query: str | None = None
2025-12-11 12:47:30 -08:00
override_hash: str | None = None
conserve: str | None = None
lib_root: str | None = None
reason_tokens: list[str] = []
i = 0
2025-12-29 17:05:03 -08:00
2025-12-11 12:47:30 -08:00
while i < len(args):
token = args[i]
low = str(token).lower()
if low in {"-query",
"--query",
"query"} and i + 1 < len(args):
2025-12-20 02:12:45 -08:00
override_query = str(args[i + 1]).strip()
2025-12-11 12:47:30 -08:00
i += 2
continue
if low in {"-conserve",
"--conserve"} and i + 1 < len(args):
2025-12-11 12:47:30 -08:00
value = str(args[i + 1]).strip().lower()
if value in {"local",
"hydrus"}:
2025-12-11 12:47:30 -08:00
conserve = value
i += 2
continue
if low in {"-lib-root",
"--lib-root",
"lib-root"} and i + 1 < len(args):
2025-12-11 12:47:30 -08:00
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
override_hash = sh.parse_single_hash_query(
override_query
) if override_query else None
2025-12-20 02:12:45 -08:00
if override_query and not override_hash:
log("Invalid -query value (expected hash:<sha256>)", file=sys.stderr)
return 1
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",
{})
2025-12-11 12:47:30 -08:00
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
reason = " ".join(token for token in reason_tokens
if str(token).strip()).strip()
2025-12-11 12:47:30 -08:00
items = []
if isinstance(result, list):
items = result
elif result:
items = [result]
2025-12-29 17:05:03 -08:00
2025-12-11 12:47:30 -08:00
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
2025-12-20 23:57:44 -08:00
deleted_rows: List[Dict[str, Any]] = []
2025-12-11 12:47:30 -08:00
for item in items:
2025-12-29 17:05:03 -08:00
rows = self._process_single_item(
item,
override_hash,
conserve,
lib_root,
reason,
config
2025-12-29 17:05:03 -08:00
)
2025-12-20 23:57:44 -08:00
if rows:
2025-12-11 12:47:30 -08:00
success_count += 1
2025-12-20 23:57:44 -08:00
deleted_rows.extend(rows)
if deleted_rows:
table = ResultTable("Deleted")
table.set_no_choice(True).set_preserve_order(True)
for row in deleted_rows:
result_row = table.add_row()
result_row.add_column("Title", row.get("title", ""))
result_row.add_column("Store", row.get("store", ""))
result_row.add_column("Hash", row.get("hash", ""))
2025-12-29 17:05:03 -08:00
result_row.add_column(
"Size",
_format_size(row.get("size_bytes"),
integer_only=False)
2025-12-29 17:05:03 -08:00
)
2025-12-20 23:57:44 -08:00
result_row.add_column("Ext", row.get("ext", ""))
# Display-only: print directly and do not affect selection/history.
try:
stdout_console().print()
stdout_console().print(table)
setattr(table, "_rendered_by_cmdlet", True)
except Exception:
pass
2025-12-11 12:47:30 -08:00
2025-12-20 23:57:44 -08:00
# Ensure no stale overlay/selection carries forward.
2025-12-11 12:47:30 -08:00
try:
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()