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 ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, parse_tag_arguments, should_show_help, get_field from SYS.logger import debug, log from Store import Store def _refresh_tag_view_if_current(file_hash: str | None, store_name: str | None, 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(file_hash) if file_hash else None target_path = norm(path) if path else None subj_hashes: list[str] = [] subj_paths: list[str] = [] if isinstance(subject, dict): subj_hashes = [norm(v) for v in [subject.get("hash")] if v] subj_paths = [norm(v) for v in [subject.get("path"), subject.get("target")] if v] else: subj_hashes = [norm(get_field(subject, f)) for f in ("hash",) if get_field(subject, f)] subj_paths = [norm(get_field(subject, f)) for f in ("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 file_hash: refresh_args.extend(["-hash", file_hash]) get_tag_cmd._run(subject, refresh_args, config) except Exception: pass CMDLET = Cmdlet( name="delete-tag", summary="Remove tags from a file in a store.", usage="delete-tag -store [-hash ] [,...]", arg=[ SharedArgs.HASH, SharedArgs.STORE, 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(["delete-tag"]) 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 override_store: 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 if low in {"-store", "--store", "store"} and i + 1 < len(args): override_store = 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 path_from_at_syntax = None store_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') if not path_from_at_syntax: path_from_at_syntax = get_field(item, 'path') if not store_from_at_syntax: store_from_at_syntax = get_field(item, 'store') 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 file_hash = normalize_hash(override_hash) if override_hash else normalize_hash(hash_from_at_syntax) path = path_from_at_syntax store_name = override_store or store_from_at_syntax if _process_deletion(tags, file_hash, path, store_name, 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")) item_path = ( get_field(item, "path") or get_field(item, "target") ) item_store = override_store or get_field(item, "store") 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: if _process_deletion(tags_to_delete, item_hash, item_path, item_store, config): success_count += 1 if success_count > 0: return 0 return 1 def _process_deletion(tags: list[str], file_hash: str | None, path: str | None, store_name: str | None, config: Dict[str, Any]) -> bool: """Helper to execute the deletion logic for a single target.""" if not tags: return False if not store_name: log("Store is required (use -store or pipe a result with store)", file=sys.stderr) return False resolved_hash = normalize_hash(file_hash) if file_hash else None if not resolved_hash and path: try: from SYS.utils import sha256_file resolved_hash = sha256_file(Path(path)) except Exception: resolved_hash = None if not resolved_hash: log("Item does not include a usable hash (and hash could not be derived from path)", file=sys.stderr) return False def _fetch_existing_tags() -> list[str]: try: backend = Store(config)[store_name] existing, _src = backend.get_tag(resolved_hash, config=config) return list(existing or []) except Exception: return [] # 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-tags \"title:new title\").", file=sys.stderr) return False try: backend = Store(config)[store_name] ok = backend.delete_tag(resolved_hash, list(tags), config=config) if ok: preview = resolved_hash[:12] + ('…' if len(resolved_hash) > 12 else '') debug(f"Removed {len(tags)} tag(s) from {preview} via store '{store_name}'.") _refresh_tag_view_if_current(resolved_hash, store_name, path, config) return True return False except Exception as exc: log(f"del-tag failed: {exc}") return False