Files
Medios-Macina/cmdlets/delete_file.py
2025-12-01 01:10:16 -08:00

367 lines
14 KiB
Python

from __future__ import annotations
from typing import Any, Dict, Sequence
import json
import sys
from helper.logger import debug, log
import sqlite3
from pathlib import Path
import models
import pipeline as ctx
from helper import hydrus as hydrus_wrapper
from ._shared import Cmdlet, CmdletArg, normalize_hash, looks_like_hash
from config import get_local_storage_path
from helper.local_library import LocalLibraryDB
def _cleanup_relationships(db_path: Path, file_hash: str) -> int:
"""Remove references to file_hash from other files' relationships."""
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Find all metadata entries that contain this hash in relationships
cursor.execute("SELECT file_id, relationships FROM metadata WHERE relationships LIKE ?", (f'%{file_hash}%',))
rows = cursor.fetchall()
rel_update_count = 0
for row_fid, rel_json in rows:
try:
rels = json.loads(rel_json)
changed = False
if isinstance(rels, dict):
for r_type, hashes in rels.items():
if isinstance(hashes, list) and file_hash in hashes:
hashes.remove(file_hash)
changed = True
if changed:
cursor.execute("UPDATE metadata SET relationships = ? WHERE file_id = ?", (json.dumps(rels), row_fid))
rel_update_count += 1
except Exception:
pass
conn.commit()
conn.close()
if rel_update_count > 0:
debug(f"Removed relationship references from {rel_update_count} other files", file=sys.stderr)
return rel_update_count
except Exception as e:
debug(f"Error cleaning up relationships: {e}", file=sys.stderr)
return 0
def _delete_database_entry(db_path: Path, file_path: str) -> bool:
"""Delete file and related entries from local library database.
Args:
db_path: Path to the library.db file
file_path: Exact file path string as stored in database
Returns:
True if successful, False otherwise
"""
try:
if not db_path.exists():
debug(f"Database not found at {db_path}", file=sys.stderr)
return False
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
debug(f"Searching database for file_path: {file_path}", file=sys.stderr)
# Find the file_id using the exact file_path
cursor.execute('SELECT id FROM files WHERE file_path = ?', (file_path,))
result = cursor.fetchone()
if not result:
debug(f"File path not found in database: {file_path}", file=sys.stderr)
conn.close()
return False
file_id = result[0]
# Get file hash before deletion to clean up relationships
cursor.execute('SELECT file_hash FROM files WHERE id = ?', (file_id,))
hash_result = cursor.fetchone()
file_hash = hash_result[0] if hash_result else None
debug(f"Found file_id={file_id}, deleting all related records", file=sys.stderr)
# Delete related records
cursor.execute('DELETE FROM metadata WHERE file_id = ?', (file_id,))
meta_count = cursor.rowcount
cursor.execute('DELETE FROM tags WHERE file_id = ?', (file_id,))
tags_count = cursor.rowcount
cursor.execute('DELETE FROM notes WHERE file_id = ?', (file_id,))
notes_count = cursor.rowcount
cursor.execute('DELETE FROM files WHERE id = ?', (file_id,))
files_count = cursor.rowcount
conn.commit()
conn.close()
# Clean up relationships in other files
if file_hash:
_cleanup_relationships(db_path, file_hash)
debug(f"Deleted: metadata={meta_count}, tags={tags_count}, notes={notes_count}, files={files_count}", file=sys.stderr)
return True
except Exception as exc:
log(f"Database cleanup failed: {exc}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
return False
def _process_single_item(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")
origin = item.get("origin")
else:
hash_hex_raw = getattr(item, "hash_hex", None) or getattr(item, "hash", None)
target = getattr(item, "target", None)
origin = getattr(item, "origin", None)
# 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://"))
# Try to resolve local path if target looks like a hash and we have a library root
if local_target and looks_like_hash(str(target)) and lib_root:
try:
db_path = Path(lib_root) / ".downlow_library.db"
if db_path.exists():
# We can't use LocalLibraryDB context manager easily here without importing it,
# but we can use a quick sqlite connection or just use the class if imported.
# We imported LocalLibraryDB, so let's use it.
with LocalLibraryDB(Path(lib_root)) as db:
resolved = db.search_by_hash(str(target))
if resolved:
target = str(resolved)
# Also ensure we have the hash set for Hydrus deletion if needed
if not hash_hex:
hash_hex = normalize_hash(str(target))
except Exception as e:
debug(f"Failed to resolve hash to local path: {e}", file=sys.stderr)
if conserve != "local" and local_target:
path = Path(str(target))
file_path_str = str(target) # Keep the original string for DB matching
try:
if path.exists() and path.is_file():
path.unlink()
local_deleted = True
if ctx._PIPE_ACTIVE:
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
# Clean up database entry if library root provided - do this regardless of file deletion success
if lib_root:
lib_root_path = Path(lib_root)
db_path = lib_root_path / ".downlow_library.db"
# If file_path_str is a hash (because file was already deleted or target was hash),
# we need to find the path by hash in the DB first
if looks_like_hash(file_path_str):
try:
with LocalLibraryDB(lib_root_path) as db:
resolved = db.search_by_hash(file_path_str)
if resolved:
file_path_str = str(resolved)
except Exception:
pass
db_success = _delete_database_entry(db_path, file_path_str)
if not db_success:
# If deletion failed (e.g. not found), but we have a hash, try to clean up relationships anyway
effective_hash = None
if looks_like_hash(file_path_str):
effective_hash = file_path_str
elif hash_hex:
effective_hash = hash_hex
if effective_hash:
debug(f"Entry not found, but attempting to clean up relationships for hash: {effective_hash}", file=sys.stderr)
if _cleanup_relationships(db_path, effective_hash) > 0:
db_success = True
if db_success:
if ctx._PIPE_ACTIVE:
ctx.emit(f"Removed database entry: {path.name}")
debug(f"Database entry cleaned up", file=sys.stderr)
local_deleted = True
else:
debug(f"Database entry not found or cleanup failed for {file_path_str}", file=sys.stderr)
else:
debug(f"No lib_root provided, skipping database cleanup", file=sys.stderr)
hydrus_deleted = False
# Only attempt Hydrus deletion if origin is explicitly Hydrus or if we failed to delete locally
# and we suspect it might be in Hydrus.
# If origin is local, we should default to NOT deleting from Hydrus unless requested?
# Or maybe we should check if it exists in Hydrus first?
# The user complaint is "its still trying to delete hydrus, this is a local file".
should_try_hydrus = True
if origin and origin.lower() == "local":
should_try_hydrus = False
# 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:
# If we deleted locally, we don't care if Hydrus is unavailable
pass
else:
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
# log(f"Hydrus delete failed: {exc}", file=sys.stderr)
if not local_deleted:
return False
if hydrus_deleted and hash_hex:
preview = hash_hex[:12] + ('' if len(hash_hex) > 12 else '')
if ctx._PIPE_ACTIVE:
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(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Help
try:
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
return 0
except Exception:
pass
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 not lib_root:
# Try to get from config
p = get_local_storage_path(config)
if p:
lib_root = str(p)
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 _process_single_item(item, override_hash, conserve, lib_root, reason, config):
success_count += 1
return 0 if success_count > 0 else 1
CMDLET = Cmdlet(
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]",
aliases=["del-file"],
args=[
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)."),
],
details=[
"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.",
],
)