Files
Medios-Macina/cmdlets/delete_tag.py

382 lines
16 KiB
Python
Raw Normal View History

2025-11-25 20:09:33 -08:00
from __future__ import annotations
from typing import Any, Dict, Sequence
2025-12-06 00:10:19 -08:00
from pathlib import Path
2025-11-25 20:09:33 -08:00
import json
2025-12-05 03:42:57 -08:00
import sys
2025-11-25 20:09:33 -08:00
from . import register
import models
import pipeline as ctx
from helper import hydrus as hydrus_wrapper
2025-12-11 12:47:30 -08:00
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, parse_tag_arguments, fetch_hydrus_metadata, should_show_help, get_field
2025-11-27 10:59:01 -08:00
from helper.logger import debug, log
2025-11-25 20:09:33 -08:00
2025-12-06 00:10:19 -08:00
def _refresh_tag_view_if_current(hash_hex: str | None, file_path: str | None, config: Dict[str, Any]) -> None:
"""If the current subject matches the target, refresh tags via get-tag."""
try:
from cmdlets import get_tag as get_tag_cmd # type: ignore
except Exception:
return
try:
subject = ctx.get_last_result_subject()
if subject is None:
return
def norm(val: Any) -> str:
return str(val).lower()
target_hash = norm(hash_hex) if hash_hex else None
target_path = norm(file_path) if file_path else None
subj_hashes: list[str] = []
subj_paths: list[str] = []
if isinstance(subject, dict):
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v]
subj_paths = [norm(v) for v in [subject.get("file_path"), subject.get("path"), subject.get("target")] if v]
else:
2025-12-11 12:47:30 -08:00
subj_hashes = [norm(get_field(subject, f)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if get_field(subject, f)]
subj_paths = [norm(get_field(subject, f)) for f in ("file_path", "path", "target") if get_field(subject, f)]
2025-12-06 00:10:19 -08:00
is_match = False
if target_hash and target_hash in subj_hashes:
is_match = True
if target_path and target_path in subj_paths:
is_match = True
if not is_match:
return
refresh_args: list[str] = []
if hash_hex:
refresh_args.extend(["-hash", hash_hex])
get_tag_cmd._run(subject, refresh_args, config)
except Exception:
pass
2025-11-25 20:09:33 -08:00
CMDLET = Cmdlet(
name="delete-tags",
summary="Remove tags from a Hydrus file.",
usage="del-tags [-hash <sha256>] <tag>[,<tag>...]",
2025-12-11 12:47:30 -08:00
alias=["del-tag", "del-tags", "delete-tag"],
arg=[
SharedArgs.HASH,
2025-11-25 20:09:33 -08:00
CmdletArg("<tag>[,<tag>...]", required=True, description="One or more tags to remove. Comma- or space-separated."),
],
2025-12-11 12:47:30 -08:00
detail=[
2025-11-25 20:09:33 -08:00
"- Requires a Hydrus file (hash present) or explicit -hash override.",
"- Multiple tags can be comma-separated or space-separated.",
],
)
@register(["del-tag", "del-tags", "delete-tag", "delete-tags"]) # Still needed for backward compatibility
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Help
2025-12-11 12:47:30 -08:00
if should_show_help(args):
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
return 0
2025-11-25 20:09:33 -08:00
# Check if we have a piped TagItem with no args (i.e., from @1 | delete-tag)
has_piped_tag = (result and hasattr(result, '__class__') and
result.__class__.__name__ == 'TagItem' and
hasattr(result, 'tag_name'))
# Check if we have a piped list of TagItems (from @N selection)
has_piped_tag_list = (isinstance(result, list) and result and
hasattr(result[0], '__class__') and
result[0].__class__.__name__ == 'TagItem')
if not args and not has_piped_tag and not has_piped_tag_list:
log("Requires at least one tag argument")
return 1
# Parse -hash override and collect tags from remaining args
override_hash: str | None = None
rest: list[str] = []
i = 0
while i < len(args):
a = args[i]
low = str(a).lower()
if low in {"-hash", "--hash", "hash"} and i + 1 < len(args):
override_hash = str(args[i + 1]).strip()
i += 2
continue
rest.append(a)
i += 1
# Check if first argument is @ syntax (result table selection)
# @5 or @{2,5,8} to delete tags from ResultTable by index
tags_from_at_syntax = []
hash_from_at_syntax = None
2025-11-27 10:59:01 -08:00
file_path_from_at_syntax = None
2025-11-25 20:09:33 -08:00
if rest and str(rest[0]).startswith("@"):
selector_arg = str(rest[0])
pipe_selector = selector_arg[1:].strip()
# Parse @N or @{N,M,K} syntax
if pipe_selector.startswith("{") and pipe_selector.endswith("}"):
# @{2,5,8}
pipe_selector = pipe_selector[1:-1]
try:
indices = [int(tok.strip()) for tok in pipe_selector.split(',') if tok.strip()]
except ValueError:
log("Invalid selection syntax. Use @2 or @{2,5,8}")
return 1
# Get the last ResultTable from pipeline context
try:
last_table = ctx._LAST_RESULT_TABLE
if last_table:
# Extract tags from selected rows
for idx in indices:
if 1 <= idx <= len(last_table.rows):
# Look for a TagItem in _LAST_RESULT_ITEMS by index
if idx - 1 < len(ctx._LAST_RESULT_ITEMS):
item = ctx._LAST_RESULT_ITEMS[idx - 1]
if hasattr(item, '__class__') and item.__class__.__name__ == 'TagItem':
2025-12-11 12:47:30 -08:00
tag_name = get_field(item, 'tag_name')
2025-11-25 20:09:33 -08:00
if tag_name:
log(f"[delete_tag] Extracted tag from @{idx}: {tag_name}")
tags_from_at_syntax.append(tag_name)
# Also get hash from first item for consistency
if not hash_from_at_syntax:
2025-12-11 12:47:30 -08:00
hash_from_at_syntax = get_field(item, 'hash_hex')
2025-11-27 10:59:01 -08:00
if not file_path_from_at_syntax:
2025-12-11 12:47:30 -08:00
file_path_from_at_syntax = get_field(item, 'file_path')
2025-11-25 20:09:33 -08:00
if not tags_from_at_syntax:
log(f"No tags found at indices: {indices}")
return 1
else:
log("No ResultTable in pipeline (use @ after running get-tag)")
return 1
except Exception as exc:
log(f"Error processing @ selection: {exc}", file=__import__('sys').stderr)
return 1
# Handle @N selection which creates a list - extract the first item
2025-11-27 10:59:01 -08:00
# If we have a list of TagItems, we want to process ALL of them if no args provided
# This handles: delete-tag @1 (where @1 expands to a list containing one TagItem)
# Also handles: delete-tag @1,2 (where we want to delete tags from multiple files)
2025-11-25 20:09:33 -08:00
2025-11-27 10:59:01 -08:00
# Normalize result to a list for processing
items_to_process = []
if isinstance(result, list):
items_to_process = result
elif result:
items_to_process = [result]
# If we have TagItems and no args, we are deleting the tags themselves
# If we have Files (or other objects) and args, we are deleting tags FROM those files
# Check if we are in "delete selected tags" mode (TagItems)
is_tag_item_mode = (items_to_process and hasattr(items_to_process[0], '__class__') and
items_to_process[0].__class__.__name__ == 'TagItem')
if is_tag_item_mode:
# Collect all tags to delete from the TagItems
# Group by hash/file_path to batch operations if needed, or just process one by one
# For simplicity, we'll process one by one or group by file
pass
else:
# "Delete tags from files" mode
# We need args (tags to delete)
if not args and not tags_from_at_syntax:
log("Requires at least one tag argument when deleting from files")
return 1
# Process each item
success_count = 0
# If we have tags from @ syntax (e.g. delete-tag @{1,2}), we ignore the piped result for tag selection
# but we might need the piped result for the file context if @ selection was from a Tag table
# Actually, the @ selection logic above already extracted tags.
2025-11-25 20:09:33 -08:00
if tags_from_at_syntax:
2025-11-27 10:59:01 -08:00
# Special case: @ selection of tags.
# We already extracted tags and hash/path.
# Just run the deletion once using the extracted info.
# This preserves the existing logic for @ selection.
2025-11-25 20:09:33 -08:00
tags = tags_from_at_syntax
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(hash_from_at_syntax)
2025-11-27 10:59:01 -08:00
file_path = file_path_from_at_syntax
if _process_deletion(tags, hash_hex, file_path, config):
success_count += 1
2025-11-25 20:09:33 -08:00
else:
2025-11-27 10:59:01 -08:00
# Process items from pipe (or single result)
# If args are provided, they are the tags to delete from EACH item
# If items are TagItems and no args, the tag to delete is the item itself
tags_arg = parse_tag_arguments(rest)
for item in items_to_process:
tags_to_delete = []
2025-12-11 12:47:30 -08:00
item_hash = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(item, "hash_hex"))
item_path = (
get_field(item, "path")
or get_field(item, "file_path")
or get_field(item, "target")
)
item_source = get_field(item, "source")
2025-11-27 10:59:01 -08:00
if hasattr(item, '__class__') and item.__class__.__name__ == 'TagItem':
# It's a TagItem
if tags_arg:
# User provided tags to delete FROM this file (ignoring the tag name in the item?)
# Or maybe they want to delete the tag in the item AND the args?
# Usually if piping TagItems, we delete THOSE tags.
# If args are present, maybe we should warn?
# For now, if args are present, assume they override or add to the tag item?
# Let's assume if args are present, we use args. If not, we use the tag name.
tags_to_delete = tags_arg
else:
2025-12-11 12:47:30 -08:00
tag_name = get_field(item, 'tag_name')
2025-11-27 10:59:01 -08:00
if tag_name:
tags_to_delete = [tag_name]
else:
# It's a File or other object
if tags_arg:
tags_to_delete = tags_arg
else:
# No tags provided for a file object - skip or error?
# We already logged an error if no args and not TagItem mode globally,
# but inside the loop we might have mixed items? Unlikely.
continue
if tags_to_delete and (item_hash or item_path):
if _process_deletion(tags_to_delete, item_hash, item_path, config, source=item_source):
success_count += 1
if success_count > 0:
return 0
return 1
def _process_deletion(tags: list[str], hash_hex: str | None, file_path: str | None, config: Dict[str, Any], source: str | None = None) -> bool:
"""Helper to execute the deletion logic for a single target."""
2025-11-25 20:09:33 -08:00
if not tags:
2025-11-27 10:59:01 -08:00
return False
2025-12-06 00:10:19 -08:00
def _fetch_existing_tags() -> list[str]:
existing: list[str] = []
# Prefer local DB when we have a path and not explicitly hydrus
if file_path and (source == "local" or (source != "hydrus" and not hash_hex)):
try:
2025-12-11 12:47:30 -08:00
from helper.folder_store import FolderDB
2025-12-06 00:10:19 -08:00
from config import get_local_storage_path
path_obj = Path(file_path)
local_root = get_local_storage_path(config) or path_obj.parent
2025-12-11 12:47:30 -08:00
with FolderDB(local_root) as db:
file_hash = db.get_file_hash(path_obj)
existing = db.get_tags(file_hash) if file_hash else []
2025-12-06 00:10:19 -08:00
except Exception:
existing = []
elif hash_hex:
2025-12-11 12:47:30 -08:00
meta, _ = fetch_hydrus_metadata(
config, hash_hex,
include_service_keys_to_tags=True,
include_file_url=False,
)
if isinstance(meta, dict):
tags_payload = meta.get("tags")
if isinstance(tags_payload, dict):
seen: set[str] = set()
for svc_data in tags_payload.values():
if not isinstance(svc_data, dict):
continue
display = svc_data.get("display_tags")
if isinstance(display, list):
for t in display:
2025-12-06 00:10:19 -08:00
if isinstance(t, (str, bytes)):
val = str(t).strip()
if val and val not in seen:
seen.add(val)
existing.append(val)
storage = svc_data.get("storage_tags")
if isinstance(storage, dict):
current_list = storage.get("0") or storage.get(0)
if isinstance(current_list, list):
for t in current_list:
if isinstance(t, (str, bytes)):
val = str(t).strip()
if val and val not in seen:
seen.add(val)
existing.append(val)
return existing
2025-12-05 03:42:57 -08:00
2025-12-06 00:10:19 -08:00
# Safety: only block if this deletion would remove the final title tag
2025-12-05 03:42:57 -08:00
title_tags = [t for t in tags if isinstance(t, str) and t.lower().startswith("title:")]
if title_tags:
2025-12-06 00:10:19 -08:00
existing_tags = _fetch_existing_tags()
current_titles = [t for t in existing_tags if isinstance(t, str) and t.lower().startswith("title:")]
del_title_set = {t.lower() for t in title_tags}
remaining_titles = [t for t in current_titles if t.lower() not in del_title_set]
if current_titles and not remaining_titles:
log("Cannot delete the last title: tag. Add a replacement title first (add-tag \"title:new title\").", file=sys.stderr)
return False
2025-11-27 10:59:01 -08:00
if not hash_hex and not file_path:
log("Item does not include a hash or file path")
return False
# Handle local file tag deletion
if file_path and (source == "local" or (not hash_hex and source != "hydrus")):
try:
2025-12-11 12:47:30 -08:00
from helper.folder_store import FolderDB
2025-11-27 10:59:01 -08:00
from pathlib import Path
path_obj = Path(file_path)
if not path_obj.exists():
log(f"File not found: {file_path}")
return False
# Try to get local storage path from config
from config import get_local_storage_path
local_root = get_local_storage_path(config)
if not local_root:
# Fallback: assume file is in a library root or use its parent
local_root = path_obj.parent
2025-12-11 12:47:30 -08:00
with FolderDB(local_root) as db:
2025-12-03 15:18:57 -08:00
db.remove_tags(path_obj, tags)
2025-11-27 10:59:01 -08:00
debug(f"Removed {len(tags)} tag(s) from {path_obj.name} (local)")
2025-12-06 00:10:19 -08:00
_refresh_tag_view_if_current(hash_hex, file_path, config)
2025-11-27 10:59:01 -08:00
return True
except Exception as exc:
log(f"Failed to remove local tags: {exc}")
return False
# Hydrus deletion logic
2025-11-25 20:09:33 -08:00
if not hash_hex:
2025-11-27 10:59:01 -08:00
return False
2025-11-25 20:09:33 -08:00
try:
service_name = hydrus_wrapper.get_tag_service_name(config)
client = hydrus_wrapper.get_client(config)
2025-11-27 10:59:01 -08:00
if client is None:
log("Hydrus client unavailable")
return False
debug(f"Sending deletion request: hash={hash_hex}, tags={tags}, service={service_name}")
client.delete_tags(hash_hex, tags, service_name)
preview = hash_hex[:12] + ('' if len(hash_hex) > 12 else '')
debug(f"Removed {len(tags)} tag(s) from {preview} via '{service_name}'.")
2025-12-06 00:10:19 -08:00
_refresh_tag_view_if_current(hash_hex, None, config)
2025-11-27 10:59:01 -08:00
return True
2025-11-25 20:09:33 -08:00
except Exception as exc:
log(f"Hydrus del-tag failed: {exc}")
2025-11-27 10:59:01 -08:00
return False
2025-11-25 20:09:33 -08:00