"""Add file relationships in Hydrus based on relationship tags in sidecar.""" from __future__ import annotations from typing import Any, Dict, Optional, Sequence import json import re from pathlib import Path import sys from helper.logger import log from . import register import models import pipeline as ctx from helper import hydrus as hydrus_wrapper from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args from helper.local_library import read_sidecar, find_sidecar CMDLET = Cmdlet( name="add-relationship", summary="Associate file relationships (king/alt/related) in Hydrus based on relationship tags in sidecar.", usage="add-relationship OR add-relationship -path ", args=[ CmdletArg("path", type="string", description="Specify the local file path (if not piping a result)."), ], details=[ "- Reads relationship tags from sidecar (format: 'relationship: hash(king),hash(alt),hash(related)')", "- Calls Hydrus API to associate the hashes as relationships", "- Supports three relationship types: king (primary), alt (alternative), related (other versions)", "- Works with piped file results or -path argument for direct invocation", ], ) def _normalise_hash_hex(value: Optional[str]) -> Optional[str]: """Normalize a hash hex string to lowercase 64-char format.""" if not value or not isinstance(value, str): return None normalized = value.strip().lower() if len(normalized) == 64 and all(c in '0123456789abcdef' for c in normalized): return normalized return None def _extract_relationships_from_tag(tag_value: str) -> Dict[str, list[str]]: """Parse relationship tag like 'relationship: hash(king),hash(alt)'. Returns a dict like {"king": ["HASH1"], "alt": ["HASH2"], ...} """ result: Dict[str, list[str]] = {} if not isinstance(tag_value, str): return result # Match patterns like hash(king)HASH or hash(type)HASH (no angle brackets) pattern = r'hash\((\w+)\)([a-fA-F0-9]{64})' matches = re.findall(pattern, tag_value) for rel_type, hash_value in matches: normalized = _normalise_hash_hex(hash_value) if normalized: if rel_type not in result: result[rel_type] = [] result[rel_type].append(normalized) return result @register(["add-relationship", "add-rel"]) # primary name and alias def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int: """Associate file relationships in Hydrus. Two modes of operation: 1. Read from sidecar: Looks for relationship tags in the file's sidecar (format: "relationship: hash(king),hash(alt)") 2. Pipeline mode: When piping multiple results, the first becomes "king" and subsequent items become "alt" Returns 0 on success, non-zero on failure. """ # 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 # Parse arguments using CMDLET spec parsed = parse_cmdlet_args(_args, CMDLET) arg_path: Optional[Path] = None if parsed: # Get the first arg value (e.g., -path) first_arg_name = CMDLET.get("args", [{}])[0].get("name") if CMDLET.get("args") else None if first_arg_name and first_arg_name in parsed: arg_value = parsed[first_arg_name] try: arg_path = Path(str(arg_value)).expanduser() except Exception: arg_path = Path(str(arg_value)) # Get Hydrus client try: client = hydrus_wrapper.get_client(config) except Exception as exc: log(f"Hydrus client unavailable: {exc}", file=sys.stderr) return 1 if client is None: log("Hydrus client unavailable", file=sys.stderr) return 1 # Handle @N selection which creates a list - extract the first item if isinstance(result, list) and len(result) > 0: result = result[0] # Check if we're in pipeline mode (have a hash) or file mode file_hash = getattr(result, "hash_hex", None) # PIPELINE MODE: Track relationships across multiple items if file_hash: file_hash = _normalise_hash_hex(file_hash) if not file_hash: log("Invalid file hash format", file=sys.stderr) return 1 # Load or initialize king hash from pipeline context try: king_hash = ctx.load_value("relationship_king") except Exception: king_hash = None # If this is the first item, make it the king if not king_hash: try: ctx.store_value("relationship_king", file_hash) log(f"Established king hash: {file_hash}", file=sys.stderr) return 0 # First item just becomes the king, no relationships yet except Exception: pass # If we already have a king and this is a different hash, link them if king_hash and king_hash != file_hash: try: client.set_relationship(file_hash, king_hash, "alt") log( f"[add-relationship] Set alt relationship: {file_hash} <-> {king_hash}", file=sys.stderr ) return 0 except Exception as exc: log(f"Failed to set relationship: {exc}", file=sys.stderr) return 1 return 0 # FILE MODE: Read relationships from sidecar log("Note: Use piping mode for easier relationships. Example: 1,2,3 | add-relationship", file=sys.stderr) # Resolve media path from -path arg or result target target = getattr(result, "target", None) or getattr(result, "path", None) media_path = arg_path if arg_path is not None else Path(str(target)) if isinstance(target, str) else None if media_path is None: log("Provide -path or pipe a local file result", file=sys.stderr) return 1 # Validate local file if str(media_path).lower().startswith(("http://", "https://")): log("This cmdlet requires a local file path, not a URL", file=sys.stderr) return 1 if not media_path.exists() or not media_path.is_file(): log(f"File not found: {media_path}", file=sys.stderr) return 1 # Build Hydrus client try: client = hydrus_wrapper.get_client(config) except Exception as exc: log(f"Hydrus client unavailable: {exc}", file=sys.stderr) return 1 if client is None: log("Hydrus client unavailable", file=sys.stderr) return 1 # Read sidecar to find relationship tags sidecar_path = find_sidecar(media_path) if sidecar_path is None: log(f"No sidecar found for {media_path.name}", file=sys.stderr) return 1 try: _, tags, _ = read_sidecar(sidecar_path) except Exception as exc: log(f"Failed to read sidecar: {exc}", file=sys.stderr) return 1 # Find relationship tags (format: "relationship: hash(king),hash(alt),hash(related)") relationship_tags = [t for t in tags if isinstance(t, str) and t.lower().startswith("relationship:")] if not relationship_tags: log(f"No relationship tags found in sidecar", file=sys.stderr) return 0 # Not an error, just nothing to do # Get the file hash from result (should have been set by add-file) file_hash = getattr(result, "hash_hex", None) if not file_hash: log("File hash not available (run add-file first)", file=sys.stderr) return 1 file_hash = _normalise_hash_hex(file_hash) if not file_hash: log("Invalid file hash format", file=sys.stderr) return 1 # Parse relationships from tags and apply them success_count = 0 error_count = 0 for rel_tag in relationship_tags: try: # Parse: "relationship: hash(king),hash(alt),hash(related)" rel_str = rel_tag.split(":", 1)[1].strip() # Get part after "relationship:" # Parse relationships rels = _extract_relationships_from_tag(f"relationship: {rel_str}") # Set the relationships in Hydrus for rel_type, related_hashes in rels.items(): if not related_hashes: continue for related_hash in related_hashes: # Don't set relationship between hash and itself if file_hash == related_hash: continue try: client.set_relationship(file_hash, related_hash, rel_type) log( f"[add-relationship] Set {rel_type} relationship: " f"{file_hash} <-> {related_hash}", file=sys.stderr ) success_count += 1 except Exception as exc: log(f"Failed to set {rel_type} relationship: {exc}", file=sys.stderr) error_count += 1 except Exception as exc: log(f"Failed to parse relationship tag: {exc}", file=sys.stderr) error_count += 1 if success_count > 0: log(f"Successfully set {success_count} relationship(s) for {media_path.name}", file=sys.stderr) ctx.emit(f"add-relationship: {media_path.name} ({success_count} relationships set)") return 0 elif error_count == 0: log(f"No relationships to set", file=sys.stderr) return 0 # Success with nothing to do else: log(f"Failed with {error_count} error(s)", file=sys.stderr) return 1