"""Add file relationships in Hydrus based on relationship tags in sidecar.""" from __future__ import annotations from typing import Any, Dict, Optional, Sequence import re from pathlib import Path import sys from SYS.logger import log from SYS.item_accessors import get_sha256_hex, get_store_name from SYS import pipeline as ctx from API import HydrusNetwork as hydrus_wrapper from . import _shared as sh Cmdlet = sh.Cmdlet CmdletArg = sh.CmdletArg SharedArgs = sh.SharedArgs parse_cmdlet_args = sh.parse_cmdlet_args normalize_result_input = sh.normalize_result_input should_show_help = sh.should_show_help get_field = sh.get_field CMDLET = Cmdlet( name="add-relationship", summary= "Associate file relationships (king/alt/related) in Hydrus based on relationship tags in sidecar.", usage= "@1-3 | add-relationship -king @4 OR add-relationship -path OR @1,@2,@3 | add-relationship", arg=[ CmdletArg( "path", type="string", description="Specify the local file path (if not piping a result).", ), SharedArgs.STORE, SharedArgs.QUERY, CmdletArg( "-king", type="string", description= "Explicitly set the king hash/file for relationships (e.g., -king @4 or -king hash)", ), CmdletArg( "-alt", type="string", description= "Explicitly select alt item(s) by @ selection or hash list (e.g., -alt @3-5 or -alt ,)", ), CmdletArg( "-type", type="string", description= "Relationship type for piped items (default: 'alt', options: 'king', 'alt', 'related')", ), ], detail=[ "- Mode 1: Pipe multiple items, first becomes king, rest become alts (default)", "- Mode 2: Use -king to explicitly set which item/hash is the king: @1-3 | add-relationship -king @4", "- Mode 2b: Use -king and -alt to select both sides from the last table: add-relationship -king @1 -alt @3-5", "- Mode 3: Read relationships from sidecar tags:", " - New format: 'relationship: ,,' (first hash is king)", " - Legacy: 'relationship: hash(king),hash(alt)...'", "- Supports three relationship types: king (primary), alt (alternative), related (other versions)", "- When using -king, all piped items become the specified relationship type to the king", ], ) _normalize_hash_hex = sh.normalize_hash def _extract_relationships_from_tag(tag_value: str) -> Dict[str, list[str]]: """Parse relationship tags. Supported formats: - New: relationship: ,, - Old: 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) pattern = r"hash\((\w+)\)?" matches = re.findall(pattern, tag_value) if matches: for rel_type, hash_value in matches: normalized = _normalize_hash_hex(hash_value) if normalized: if rel_type not in result: result[rel_type] = [] result[rel_type].append(normalized) return result # New format: extract hashes, first is king hashes = re.findall(r"\b[a-fA-F0-9]{64}\b", tag_value) hashes = [h.strip().lower() for h in hashes if isinstance(h, str)] if not hashes: return result king = _normalize_hash_hex(hashes[0]) if not king: return result result["king"] = [king] alts: list[str] = [] for h in hashes[1:]: normalized = _normalize_hash_hex(h) if normalized and normalized != king: alts.append(normalized) if alts: result["alt"] = alts return result def _apply_relationships_from_tags( relationship_tags: Sequence[str], *, hydrus_client: Any, use_local_storage: bool, local_storage_path: Optional[Path], config: Dict[str, Any], ) -> int: """Persist relationship tags into Hydrus or local DB. Local DB semantics: - Treat the first hash (king) as the king. - Store directional alt -> king relationships (no reverse edge). """ rel_tags = [ t for t in relationship_tags if isinstance(t, str) and t.strip().lower().startswith("relationship:") ] if not rel_tags: return 0 # Prefer Hydrus if available (hash-based relationships map naturally). if hydrus_client is not None and hasattr(hydrus_client, "set_relationship"): processed: set[tuple[str, str, str]] = set() for tag in rel_tags: rels = _extract_relationships_from_tag(tag) king = (rels.get("king") or [None])[0] if not king: continue king_norm = _normalize_hash_hex(king) if not king_norm: continue for rel_type in ("alt", "related"): for other in rels.get(rel_type, []) or []: other_norm = _normalize_hash_hex(other) if not other_norm or other_norm == king_norm: continue key = (other_norm, king_norm, rel_type) if key in processed: continue try: hydrus_client.set_relationship(other_norm, king_norm, rel_type) processed.add(key) except Exception: pass return 0 # Local DB fallback (store/hash-first) if use_local_storage and local_storage_path is not None: try: with API_folder_store(local_storage_path) as db: processed_pairs: set[tuple[str, str]] = set() for tag in rel_tags: rels = _extract_relationships_from_tag(tag) king = (rels.get("king") or [None])[0] if not king: continue king_norm = _normalize_hash_hex(king) if not king_norm: continue # For local DB we treat all non-king hashes as alts. alt_hashes: list[str] = [] for bucket in ("alt", "related"): alt_hashes.extend( [h for h in (rels.get(bucket) or []) if isinstance(h, str)] ) for alt in alt_hashes: alt_norm = _normalize_hash_hex(alt) if not alt_norm or alt_norm == king_norm: continue if (alt_norm, king_norm) in processed_pairs: continue db.set_relationship_by_hash( alt_norm, king_norm, "alt", bidirectional=False ) processed_pairs.add((alt_norm, king_norm)) except Exception: return 1 return 0 return 0 def _parse_at_selection(token: str) -> Optional[list[int]]: """Parse standard @ selection syntax into a list of 0-based indices. Supports: @2, @2-5, @{1,3,5}, @3,5,7, @3-6,8, @* """ if not isinstance(token, str): return None t = token.strip() if not t.startswith("@"): return None if t == "@*": return [] # special sentinel: caller interprets as "all" selector = t[1:].strip() if not selector: return None if selector.startswith("{") and selector.endswith("}"): selector = selector[1:-1].strip() parts = [p.strip() for p in selector.split(",") if p.strip()] if not parts: return None indices_1based: set[int] = set() for part in parts: try: if "-" in part: start_s, end_s = part.split("-", 1) start = int(start_s.strip()) end = int(end_s.strip()) if start <= 0 or end <= 0 or start > end: return None for i in range(start, end + 1): indices_1based.add(i) else: num = int(part) if num <= 0: return None indices_1based.add(num) except Exception: return None return sorted(i - 1 for i in indices_1based) def _resolve_items_from_at(token: str) -> Optional[list[Any]]: """Resolve @ selection token into actual items from the current result context.""" items = ctx.get_last_result_items() if not items: return None parsed = _parse_at_selection(token) if parsed is None: return None if token.strip() == "@*": return list(items) selected: list[Any] = [] for idx in parsed: if 0 <= idx < len(items): selected.append(items[idx]) return selected def _extract_hash_and_store(item: Any) -> tuple[Optional[str], Optional[str]]: """Extract (hash_hex, store) from a result item (dict/object).""" try: return ( get_sha256_hex(item, "hash_hex", "hash", "file_hash"), get_store_name(item, "store"), ) except Exception: return None, None def _hydrus_hash_exists(hydrus_client: Any, hash_hex: str) -> bool: """Best-effort check whether a hash exists in the connected Hydrus backend.""" try: if hydrus_client is None or not hasattr(hydrus_client, "fetch_file_metadata"): return False payload = hydrus_client.fetch_file_metadata( hashes=[hash_hex], include_service_keys_to_tags=False, include_file_url=False, include_duration=False, include_size=False, include_mime=False, include_notes=False, ) meta = payload.get("metadata") if isinstance(payload, dict) else None return bool(isinstance(meta, list) and meta) except Exception: return False def _resolve_king_reference(king_arg: str) -> Optional[str]: """Resolve a king reference like '@4' to its actual hash. Store/hash mode intentionally avoids file-path dependency. """ if not king_arg: return None # Check if it's already a valid hash normalized = _normalize_hash_hex(king_arg) if normalized: return normalized # Try to resolve as @ selection from pipeline context if king_arg.startswith("@"): selected = _resolve_items_from_at(king_arg) if not selected: log(f"Cannot resolve {king_arg}: no selection context", file=sys.stderr) return None if len(selected) != 1: log( f"{king_arg} selects {len(selected)} items; -king requires exactly 1", file=sys.stderr, ) return None item = selected[0] item_hash = ( get_field(item, "hash_hex") or get_field(item, "hash") or get_field(item, "file_hash") ) if item_hash: normalized = _normalize_hash_hex(str(item_hash)) if normalized: return normalized log(f"Item {king_arg} has no hash information", file=sys.stderr) return None return None def _refresh_relationship_view_if_current( target_hash: Optional[str], target_path: Optional[str], other: Optional[str], config: Dict[str, Any], ) -> None: """If the current subject matches the target, refresh relationships via get-relationship.""" try: from cmdlet import get as get_cmdlet # type: ignore except Exception: return get_relationship = None try: get_relationship = get_cmdlet("get-relationship") except Exception: get_relationship = None if not callable(get_relationship): return try: subject = ctx.get_last_result_subject() if subject is None: return def norm(val: Any) -> str: return str(val).lower() target_hashes = [norm(v) for v in [target_hash, other] if v] target_paths = [norm(v) for v in [target_path, other] if v] 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(getattr(subject, f, None)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if getattr(subject, f, None) ] subj_paths = [ norm(getattr(subject, f, None)) for f in ("file_path", "path", "target") if getattr(subject, f, None) ] is_match = False if target_hashes and any(h in subj_hashes for h in target_hashes): is_match = True if target_paths and any(p in subj_paths for p in target_paths): is_match = True if not is_match: return refresh_args: list[str] = [] if target_hash: refresh_args.extend(["-query", f"hash:{target_hash}"]) get_relationship(subject, refresh_args, config) except Exception: pass 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 if should_show_help(_args): log(f"Cmdlet: {CMDLET.name}\nSummary: {CMDLET.summary}\nUsage: {CMDLET.usage}") return 0 # Parse arguments using CMDLET spec parsed = parse_cmdlet_args(_args, CMDLET) arg_path: Optional[Path] = None override_store = parsed.get("store") override_hashes, query_valid = sh.require_hash_query( parsed.get("query"), "Invalid -query value (expected hash:)", log_file=sys.stderr, ) if not query_valid: return 1 king_arg = parsed.get("king") alt_arg = parsed.get("alt") rel_type = parsed.get("type", "alt") raw_path = parsed.get("path") if raw_path: try: arg_path = Path(str(raw_path)).expanduser() except Exception: arg_path = Path(str(raw_path)) # Handle @N selection which creates a list # Use normalize_result_input to handle both single items and lists items_to_process = normalize_result_input(result) # Allow selecting alt items directly from the last table via -alt @... # This enables: add-relationship -king @1 -alt @3-5 if alt_arg: alt_text = str(alt_arg).strip() resolved_alt_items: list[Any] = [] if alt_text.startswith("@"): selected = _resolve_items_from_at(alt_text) if not selected: log( f"Failed to resolve -alt {alt_text}: no selection context", file=sys.stderr ) return 1 resolved_alt_items = selected else: # Treat as comma/semicolon-separated list of hashes parts = [ p.strip() for p in alt_text.replace(";", ",").split(",") if p.strip() ] hashes = [h for h in (_normalize_hash_hex(p) for p in parts) if h] if not hashes: log( "Invalid -alt value (expected @ selection or 64-hex sha256 hash list)", file=sys.stderr, ) return 1 if not override_store: log( "-store is required when using -alt with a raw hash list", file=sys.stderr ) return 1 resolved_alt_items = [ { "hash": h, "store": str(override_store) } for h in hashes ] items_to_process = normalize_result_input(resolved_alt_items) # Allow explicit store/hash-first operation via -query "hash:" (supports multiple hash: tokens) if (not items_to_process) and override_hashes: if not override_store: log( "-store is required when using -query without piped items", file=sys.stderr ) return 1 items_to_process = [ { "hash": h, "store": str(override_store) } for h in override_hashes ] if not items_to_process and not arg_path: log( "No items provided to add-relationship (no piped result and no -path)", file=sys.stderr ) return 1 # If no items from pipeline, just process the -path arg if not items_to_process and arg_path: items_to_process = [{ "file_path": arg_path }] # Resolve the king reference once (if provided) king_hash: Optional[str] = None king_store: Optional[str] = None if king_arg: king_text = str(king_arg).strip() if king_text.startswith("@"): selected = _resolve_items_from_at(king_text) if not selected: log( f"Cannot resolve {king_text}: no selection context", file=sys.stderr ) return 1 if len(selected) != 1: log( f"{king_text} selects {len(selected)} items; -king requires exactly 1", file=sys.stderr, ) return 1 king_hash, king_store = _extract_hash_and_store(selected[0]) if not king_hash: log(f"Item {king_text} has no hash information", file=sys.stderr) return 1 else: king_hash = _resolve_king_reference(king_text) if not king_hash: log(f"Failed to resolve king argument: {king_text}", file=sys.stderr) return 1 # Decide target store: override_store > (king store + piped item stores) (must be consistent) store_name: Optional[str] = str(override_store).strip() if override_store else None if not store_name: stores = set() if king_store: stores.add(str(king_store)) for item in items_to_process: s = get_field(item, "store") if s: stores.add(str(s)) if len(stores) == 1: store_name = next(iter(stores)) elif len(stores) > 1: log( "Multiple stores detected (king/alt across stores); use -store and ensure all selections are from the same store", file=sys.stderr, ) return 1 # Enforce same-store relationships when store context is available. if king_store and store_name and str(king_store) != str(store_name): log( f"Cross-store relationship blocked: king is in store '{king_store}' but -store is '{store_name}'", file=sys.stderr, ) return 1 if store_name: for item in items_to_process: s = get_field(item, "store") if s and str(s) != str(store_name): log( f"Cross-store relationship blocked: alt item store '{s}' != '{store_name}'", file=sys.stderr, ) return 1 # Resolve backend for store/hash operations backend = None is_folder_store = False store_root: Optional[Path] = None if store_name: backend, _store_registry, _exc = sh.get_store_backend(config, str(store_name)) if backend is not None: loc = getattr(backend, "location", None) if callable(loc): is_folder_store = True store_root = Path(str(loc())) else: backend = None is_folder_store = False store_root = None # Select Hydrus client: # - If a store is specified and maps to a HydrusNetwork backend, use that backend's client. # - If no store is specified, use the default Hydrus client. # NOTE: When a store is specified, we do not fall back to a global/default Hydrus client. hydrus_client = None if store_name and (not is_folder_store) and backend is not None: try: candidate = getattr(backend, "_client", None) if candidate is not None and hasattr(candidate, "set_relationship"): hydrus_client = candidate except Exception: hydrus_client = None elif not store_name: try: hydrus_client = hydrus_wrapper.get_client(config) except Exception: hydrus_client = None # Sidecar/tag import fallback DB root (legacy): if a folder store is selected, use it; # otherwise fall back to configured local storage path. from SYS.config import get_local_storage_path local_storage_root: Optional[Path] = None if store_root is not None: local_storage_root = store_root else: try: p = get_local_storage_path(config) if config else None local_storage_root = Path(p) if p else None except Exception: local_storage_root = None use_local_storage = local_storage_root is not None if king_hash: log(f"Using king hash: {king_hash}", file=sys.stderr) # If -path is provided, try reading relationship tags from its sidecar and persisting them. if arg_path is not None and arg_path.exists() and arg_path.is_file(): try: sidecar_path = find_sidecar(arg_path) if sidecar_path is not None and sidecar_path.exists(): _, tags, _ = read_sidecar(sidecar_path) relationship_tags = [ t for t in (tags or []) if isinstance(t, str) and t.lower().startswith("relationship:") ] if relationship_tags: code = _apply_relationships_from_tags( relationship_tags, hydrus_client=hydrus_client, use_local_storage=use_local_storage, local_storage_path=local_storage_root, config=config, ) return 0 if code == 0 else 1 except Exception: pass # If piped items include relationship tags, persist them (one pass) then exit. try: rel_tags_from_pipe: list[str] = [] for item in items_to_process: tags_val = None if isinstance(item, dict): tags_val = item.get("tag") or item.get("tags") else: tags_val = getattr(item, "tag", None) if isinstance(tags_val, list): rel_tags_from_pipe.extend( [ t for t in tags_val if isinstance(t, str) and t.lower().startswith("relationship:") ] ) elif isinstance(tags_val, str) and tags_val.lower().startswith("relationship:"): rel_tags_from_pipe.append(tags_val) if rel_tags_from_pipe: code = _apply_relationships_from_tags( rel_tags_from_pipe, hydrus_client=hydrus_client, use_local_storage=use_local_storage, local_storage_path=local_storage_root, config=config, ) return 0 if code == 0 else 1 except Exception: pass # STORE/HASH MODE (preferred): use -store and hashes; do not require file paths. if store_name and is_folder_store and store_root is not None: try: with API_folder_store(store_root) as db: # Mode 1: no explicit king -> first is king, rest are alts if not king_hash: first_hash = None for item in items_to_process: h, item_store = _extract_hash_and_store(item) if item_store and store_name and str(item_store) != str( store_name): log( f"Cross-store relationship blocked: item store '{item_store}' != '{store_name}'", file=sys.stderr, ) return 1 if not h: continue if not first_hash: first_hash = h continue # directional alt -> king by default for local DB bidirectional = str(rel_type).lower() != "alt" db.set_relationship_by_hash( h, first_hash, str(rel_type), bidirectional=bidirectional ) return 0 # Mode 2: explicit king for item in items_to_process: h, item_store = _extract_hash_and_store(item) if item_store and store_name and str(item_store) != str(store_name): log( f"Cross-store relationship blocked: item store '{item_store}' != '{store_name}'", file=sys.stderr, ) return 1 if not h or h == king_hash: continue bidirectional = str(rel_type).lower() != "alt" db.set_relationship_by_hash( h, king_hash, str(rel_type), bidirectional=bidirectional ) return 0 except Exception as exc: log(f"Failed to set store relationships: {exc}", file=sys.stderr) return 1 if store_name and (not is_folder_store): # Hydrus store/hash mode if hydrus_client is None: log("Hydrus client unavailable for this store", file=sys.stderr) return 1 # Verify hashes exist in this Hydrus backend to prevent cross-store edges. if king_hash and (not _hydrus_hash_exists(hydrus_client, king_hash)): log( f"Cross-store relationship blocked: king hash not found in store '{store_name}'", file=sys.stderr, ) return 1 # Mode 1: first is king if not king_hash: first_hash = None for item in items_to_process: h, item_store = _extract_hash_and_store(item) if item_store and store_name and str(item_store) != str(store_name): log( f"Cross-store relationship blocked: item store '{item_store}' != '{store_name}'", file=sys.stderr, ) return 1 if not h: continue if not first_hash: first_hash = h if not _hydrus_hash_exists(hydrus_client, first_hash): log( f"Cross-store relationship blocked: hash not found in store '{store_name}'", file=sys.stderr, ) return 1 continue if h != first_hash: if not _hydrus_hash_exists(hydrus_client, h): log( f"Cross-store relationship blocked: hash not found in store '{store_name}'", file=sys.stderr, ) return 1 hydrus_client.set_relationship(h, first_hash, str(rel_type)) return 0 # Mode 2: explicit king for item in items_to_process: h, item_store = _extract_hash_and_store(item) if item_store and store_name and str(item_store) != str(store_name): log( f"Cross-store relationship blocked: item store '{item_store}' != '{store_name}'", file=sys.stderr, ) return 1 if not h or h == king_hash: continue if not _hydrus_hash_exists(hydrus_client, h): log( f"Cross-store relationship blocked: hash not found in store '{store_name}'", file=sys.stderr, ) return 1 hydrus_client.set_relationship(h, king_hash, str(rel_type)) return 0 # Process each item in the list (legacy path-based mode) for item in items_to_process: # Extract hash and path from current item file_hash = None file_path_from_result = None if isinstance(item, dict): file_hash = item.get("hash_hex") or item.get("hash") file_path_from_result = item.get("file_path") or item.get( "path" ) or item.get("target") else: file_hash = getattr(item, "hash_hex", None) or getattr(item, "hash", None) file_path_from_result = getattr(item, "file_path", None) or getattr(item, "path", None) # Legacy LOCAL STORAGE MODE: Handle relationships for local files # (kept as stub - folder store removed) from SYS.config import get_local_storage_path local_storage_path = get_local_storage_path(config) if config else None use_local_storage = bool(local_storage_path) local_storage_root: Optional[Path] = None if local_storage_path: try: local_storage_root = Path(local_storage_path) except Exception: local_storage_root = None if use_local_storage and file_path_from_result: try: file_path_obj = Path(str(file_path_from_result)) except Exception as exc: log(f"Local storage error: {exc}", file=sys.stderr) return 1 if not file_path_obj.exists(): # Not a local file; fall through to Hydrus if possible. file_path_obj = None if file_path_obj is not None: try: if local_storage_root is None: log("Local storage path unavailable", file=sys.stderr) return 1 with LocalLibrarySearchOptimizer(local_storage_root) as opt: if opt.db is None: log("Local storage DB unavailable", file=sys.stderr) return 1 if king_hash: normalized_king = _normalize_hash_hex(str(king_hash)) if not normalized_king: log(f"King hash invalid: {king_hash}", file=sys.stderr) return 1 king_file_path = opt.db.search_hash(normalized_king) if not king_file_path: log( f"King hash not found in local DB: {king_hash}", file=sys.stderr ) return 1 bidirectional = str(rel_type).lower() != "alt" opt.db.set_relationship( file_path_obj, king_file_path, rel_type, bidirectional=bidirectional ) log( f"Set {rel_type} relationship: {file_path_obj.name} -> {king_file_path.name}", file=sys.stderr, ) _refresh_relationship_view_if_current( None, str(file_path_obj), str(king_file_path), config ) else: # Original behavior: first becomes king, rest become alts try: king_path = ctx.load_value("relationship_king_path") except Exception: king_path = None if not king_path: try: ctx.store_value( "relationship_king_path", str(file_path_obj) ) log( f"Established king file: {file_path_obj.name}", file=sys.stderr, ) continue except Exception: pass if king_path and king_path != str(file_path_obj): bidirectional = str(rel_type).lower() != "alt" opt.db.set_relationship( file_path_obj, Path(king_path), rel_type, bidirectional=bidirectional, ) log( f"Set {rel_type} relationship: {file_path_obj.name} -> {Path(king_path).name}", file=sys.stderr, ) _refresh_relationship_view_if_current( None, str(file_path_obj), str(king_path), config ) except Exception as exc: log(f"Local storage error: {exc}", file=sys.stderr) return 1 continue # PIPELINE MODE with Hydrus: Track relationships using hash if file_hash and hydrus_client: file_hash = _normalize_hash_hex( str(file_hash) if file_hash is not None else None ) if not file_hash: log("Invalid file hash format", file=sys.stderr) return 1 # If explicit -king provided, use it if king_hash: try: hydrus_client.set_relationship(file_hash, king_hash, rel_type) log( f"[add-relationship] Set {rel_type} relationship: {file_hash} <-> {king_hash}", file=sys.stderr, ) _refresh_relationship_view_if_current( file_hash, str(file_path_from_result) if file_path_from_result is not None else None, king_hash, config, ) except Exception as exc: log(f"Failed to set relationship: {exc}", file=sys.stderr) return 1 else: # Original behavior: no explicit king, first becomes king, rest become alts try: existing_king = ctx.load_value("relationship_king") except Exception: existing_king = None # If this is the first item, make it the king if not existing_king: try: ctx.store_value("relationship_king", file_hash) log(f"Established king hash: {file_hash}", file=sys.stderr) continue # Move to next item except Exception: pass # If we already have a king and this is a different hash, link them if existing_king and existing_king != file_hash: try: hydrus_client.set_relationship( file_hash, existing_king, rel_type ) log( f"[add-relationship] Set {rel_type} relationship: {file_hash} <-> {existing_king}", file=sys.stderr, ) _refresh_relationship_view_if_current( file_hash, ( str(file_path_from_result) if file_path_from_result is not None else None ), existing_king, config, ) except Exception as exc: log(f"Failed to set relationship: {exc}", file=sys.stderr) return 1 # If we get here, we didn't have a usable local path and Hydrus isn't available/usable. return 0 # FILE MODE: Read relationships from sidecar (legacy mode - for -path arg only) 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: hydrus_client = hydrus_wrapper.get_client(config) except Exception as exc: log(f"Hydrus client unavailable: {exc}", file=sys.stderr) return 1 if hydrus_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("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 = _normalize_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: hydrus_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("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 # Register cmdlet (no legacy decorator) CMDLET.exec = _run CMDLET.alias = ["add-rel"] CMDLET.register()