"""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 import pipeline as ctx from API import HydrusNetwork as hydrus_wrapper from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, normalize_result_input, should_show_help, get_field from API.folder import read_sidecar, find_sidecar, API_folder_store from Store import Store 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.HASH, 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", ], ) 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 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 = _normalise_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 = _normalise_hash_hex(hashes[0]) if not king: return result result["king"] = [king] alts: list[str] = [] for h in hashes[1:]: normalized = _normalise_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 = _normalise_hash_hex(king) if not king_norm: continue for rel_type in ("alt", "related"): for other in rels.get(rel_type, []) or []: other_norm = _normalise_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 = _normalise_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 = _normalise_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: h = get_field(item, "hash_hex") or get_field(item, "hash") or get_field(item, "file_hash") s = get_field(item, "store") hash_norm = _normalise_hash_hex(str(h) if h is not None else None) store_norm: Optional[str] if s is None: store_norm = None else: store_norm = str(s).strip() if not store_norm: store_norm = None return hash_norm, store_norm 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 = _normalise_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 = _normalise_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(["-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_hash = parsed.get("hash") 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 (_normalise_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 -hash operation (store/hash-first) if (not items_to_process) and override_hash: # Support comma-separated hashes raw = str(override_hash) parts = [p.strip() for p in raw.replace(";", ",").split(",")] hashes = [h for h in (_normalise_hash_hex(p) for p in parts) if h] if not hashes: log("Invalid -hash value (expected 64-hex sha256)", file=sys.stderr) return 1 # Use the selected/override store; required in this mode if not override_store: log("-store is required when using -hash without piped items", file=sys.stderr) return 1 items_to_process = [{"hash": h, "store": str(override_store)} for h in 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: try: store = Store(config) backend = store[str(store_name)] loc = getattr(backend, "location", None) if callable(loc): is_folder_store = True store_root = Path(str(loc())) except Exception: 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 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 for -path sidecar workflows; store/hash mode above is preferred) from API.folder import LocalLibrarySearchOptimizer from 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 = _normalise_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 = _normalise_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(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: 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(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 # Register cmdlet (no legacy decorator) CMDLET.exec = _run CMDLET.alias = ["add-rel"] CMDLET.register()