from __future__ import annotations from typing import Any, Dict, Sequence from pathlib import Path import json import sys from . import register import models import pipeline as ctx from helper import hydrus as hydrus_wrapper from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, parse_tag_arguments, fetch_hydrus_metadata, should_show_help, get_field from helper.logger import debug, log 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: 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)] 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 CMDLET = Cmdlet( name="delete-tags", summary="Remove tags from a Hydrus file.", usage="del-tags [-hash ] [,...]", alias=["del-tag", "del-tags", "delete-tag"], arg=[ SharedArgs.HASH, CmdletArg("[,...]", required=True, description="One or more tags to remove. Comma- or space-separated."), ], detail=[ "- 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 if should_show_help(args): log(json.dumps(CMDLET, ensure_ascii=False, indent=2)) return 0 # 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 file_path_from_at_syntax = None 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': tag_name = get_field(item, 'tag_name') 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: hash_from_at_syntax = get_field(item, 'hash_hex') if not file_path_from_at_syntax: file_path_from_at_syntax = get_field(item, 'file_path') 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 # 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) # 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. if tags_from_at_syntax: # 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. tags = tags_from_at_syntax hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(hash_from_at_syntax) file_path = file_path_from_at_syntax if _process_deletion(tags, hash_hex, file_path, config): success_count += 1 else: # 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 = [] 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") 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: tag_name = get_field(item, 'tag_name') 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.""" if not tags: return False 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: from helper.folder_store import FolderDB from config import get_local_storage_path path_obj = Path(file_path) local_root = get_local_storage_path(config) or path_obj.parent with FolderDB(local_root) as db: file_hash = db.get_file_hash(path_obj) existing = db.get_tags(file_hash) if file_hash else [] except Exception: existing = [] elif hash_hex: 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: 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 # Safety: only block if this deletion would remove the final title tag title_tags = [t for t in tags if isinstance(t, str) and t.lower().startswith("title:")] if title_tags: 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 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: from helper.folder_store import FolderDB 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 with FolderDB(local_root) as db: db.remove_tags(path_obj, tags) debug(f"Removed {len(tags)} tag(s) from {path_obj.name} (local)") _refresh_tag_view_if_current(hash_hex, file_path, config) return True except Exception as exc: log(f"Failed to remove local tags: {exc}") return False # Hydrus deletion logic if not hash_hex: return False try: service_name = hydrus_wrapper.get_tag_service_name(config) client = hydrus_wrapper.get_client(config) 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}'.") _refresh_tag_view_if_current(hash_hex, None, config) return True except Exception as exc: log(f"Hydrus del-tag failed: {exc}") return False