From 9f0eb29289444740e0be15d02bb9e19850ffead8 Mon Sep 17 00:00:00 2001 From: Nose Date: Sat, 9 May 2026 11:53:27 -0700 Subject: [PATCH] refactor still on going --- cmdlet/__init__.py | 10 +- cmdlet/file/add_note.py | 369 +-------- cmdlet/file/add_relationship.py | 1171 +-------------------------- cmdlet/file/add_url.py | 203 +---- cmdlet/file/get.py | 16 +- cmdlet/metadata/note.py | 2 +- cmdlet/metadata/note_add.py | 370 +++++++++ cmdlet/metadata/relationship.py | 2 +- cmdlet/metadata/relationship_add.py | 1170 ++++++++++++++++++++++++++ cmdlet/metadata/url.py | 2 +- cmdlet/metadata/url_add.py | 204 +++++ 11 files changed, 1772 insertions(+), 1747 deletions(-) create mode 100644 cmdlet/metadata/note_add.py create mode 100644 cmdlet/metadata/relationship_add.py create mode 100644 cmdlet/metadata/url_add.py diff --git a/cmdlet/__init__.py b/cmdlet/__init__.py index 5c896b2..af80931 100644 --- a/cmdlet/__init__.py +++ b/cmdlet/__init__.py @@ -90,12 +90,12 @@ def _load_helper_modules() -> None: # Provider-specific module pre-loading removed; providers are loaded lazily # through ProviderCore.registry when first referenced. # - # Keep explicit imports for cmdlets that were moved under cmdlet/file so they - # remain registered under their legacy command names (add-note/add-url/add-relationship). + # Keep explicit imports for cmdlets moved into subpackages so they remain + # registered under their legacy command names. for mod in ( - ".file.add_note", - ".file.add_url", - ".file.add_relationship", + ".metadata.note_add", + ".metadata.url_add", + ".metadata.relationship_add", ".metadata.get_note", ".metadata.get_relationship", ): diff --git a/cmdlet/file/add_note.py b/cmdlet/file/add_note.py index 54da53a..09129c9 100644 --- a/cmdlet/file/add_note.py +++ b/cmdlet/file/add_note.py @@ -1,370 +1,5 @@ from __future__ import annotations -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple -import sys -import re - -from SYS.logger import log - -from SYS import pipeline as ctx -from .. import _shared as sh - -Cmdlet = sh.Cmdlet -CmdletArg = sh.CmdletArg -QueryArg = sh.QueryArg -SharedArgs = sh.SharedArgs -normalize_hash = sh.normalize_hash -parse_cmdlet_args = sh.parse_cmdlet_args -normalize_result_input = sh.normalize_result_input -should_show_help = sh.should_show_help - - -class Add_Note(Cmdlet): - DEFAULT_QUERY_HINTS = ( - "title:", - "text:", - "hash:", - "caption:", - "sub:", - "subtitle:", - ) - - def __init__(self) -> None: - super().__init__( - name="add-note", - summary="Add file store note", - usage= - 'add-note (-query "title:,text:<text>[,instance:<instance>][,hash:<sha256>]") [ -instance <store> | <piped> ]', - alias=[""], - arg=[ - SharedArgs.INSTANCE, - QueryArg( - "hash", - key="hash", - aliases=["sha256"], - type="string", - required=False, - handler=normalize_hash, - description= - "(Optional) Specific file hash target, provided via -query as hash:<sha256>. When omitted, uses piped item hash.", - query_only=True, - ), - SharedArgs.QUERY, - ], - detail=[""" - dde - """], - exec=self.run, - ) - # Populate dynamic store choices for autocomplete - try: - SharedArgs.INSTANCE.choices = SharedArgs.get_store_choices(None) - except Exception: - pass - self.register() - - @staticmethod - def _commas_to_spaces_outside_quotes(text: str) -> str: - buf: List[str] = [] - quote: Optional[str] = None - escaped = False - for ch in str(text or ""): - if escaped: - buf.append(ch) - escaped = False - continue - if ch == "\\" and quote is not None: - buf.append(ch) - escaped = True - continue - if ch in ('"', "'"): - if quote is None: - quote = ch - elif quote == ch: - quote = None - buf.append(ch) - continue - if ch == "," and quote is None: - buf.append(" ") - continue - buf.append(ch) - return "".join(buf) - - @staticmethod - def _parse_note_query(query: str) -> Tuple[Optional[str], Optional[str]]: - """Parse note payload from -query. - - Expected: - title:<title>,text:<text> - Commas are treated as separators when not inside quotes. - """ - raw = str(query or "").strip() - if not raw: - return None, None - - try: - from SYS.cli_syntax import parse_query, get_field - except Exception: - parse_query = None # type: ignore - get_field = None # type: ignore - - normalized = Add_Note._commas_to_spaces_outside_quotes(raw) - - if callable(parse_query) and callable(get_field): - parsed = parse_query(normalized) - name = get_field(parsed, "title") - text = get_field(parsed, "text") - name_s = str(name or "").strip() if name is not None else "" - text_s = str(text or "").strip() if text is not None else "" - return (name_s or None, text_s or None) - - # Fallback: best-effort regex. - name_match = re.search( - r"\btitle\s*:\s*([^,\s]+)", - normalized, - flags=re.IGNORECASE - ) - text_match = re.search(r"\btext\s*:\s*(.+)$", normalized, flags=re.IGNORECASE) - note_name = name_match.group(1).strip() if name_match else "" - note_text = text_match.group(1).strip() if text_match else "" - return (note_name or None, note_text or None) - - @classmethod - def _looks_like_note_query_token(cls, token: Any) -> bool: - text = str(token or "").strip().lower() - if not text: - return False - return any(hint in text for hint in cls.DEFAULT_QUERY_HINTS) - - @classmethod - def _default_query_args(cls, args: Sequence[str]) -> List[str]: - tokens: List[str] = list(args or []) - lower_tokens = {str(tok).lower() for tok in tokens if tok is not None} - if "-query" in lower_tokens or "--query" in lower_tokens: - return tokens - - for idx, tok in enumerate(tokens): - token_text = str(tok or "") - if not token_text or token_text.startswith("-"): - continue - if not cls._looks_like_note_query_token(token_text): - continue - - combined_parts = [token_text] - end = idx + 1 - while end < len(tokens): - next_text = str(tokens[end] or "") - if not next_text or next_text.startswith("-"): - break - if not cls._looks_like_note_query_token(next_text): - break - combined_parts.append(next_text) - end += 1 - - combined_query = " ".join(combined_parts) - tokens[idx:end] = [combined_query] - tokens.insert(idx, "-query") - return tokens - - return tokens - - def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: - if should_show_help(args): - log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}") - return 0 - - parsed_args = self._default_query_args(args) - parsed = parse_cmdlet_args(parsed_args, self) - - store_override = parsed.get("instance") - hash_override = normalize_hash(parsed.get("hash")) - note_name, note_text = self._parse_note_query(str(parsed.get("query") or "")) - note_name = str(note_name or "").strip() - note_text = str(note_text or "").strip() - if not note_name or not note_text: - pass # We now support implicit pipeline notes if -query is missing - # But if explicit targeting (store+hash) is used, we still demand args below. - - if hash_override and not store_override: - log( - "[add_note] Error: hash:<sha256> requires instance:<instance> in -query or -instance <store>", - file=sys.stderr, - ) - return 1 - - explicit_target = bool(hash_override and store_override) - results = normalize_result_input(result) - - if explicit_target and (not note_name or not note_text): - log( - "[add_note] Error: Explicit target (store+hash) requires -query with title/text", - file=sys.stderr, - ) - return 1 - - if results and explicit_target: - # Direct targeting mode: apply note once to the explicit target and - # pass through any piped items unchanged. - try: - backend, _store_registry, exc = sh.get_store_backend( - config, - str(store_override), - ) - if backend is None: - raise exc or KeyError(store_override) - if not bool(getattr(backend, "supports_note_association", False)): - log( - f"[add_note] Error: Store '{store_override}' does not support notes", - file=sys.stderr, - ) - return 1 - ok = bool( - backend.set_note( - str(hash_override), - note_name, - note_text, - config=config - ) - ) - if ok: - ctx.print_if_visible( - f"✓ add-note: 1 item in '{store_override}'", - file=sys.stderr - ) - log( - "[add_note] Updated 1/1 item(s)", - file=sys.stderr - ) - for res in results: - ctx.emit(res) - return 0 - log( - "[add_note] Warning: Note write reported failure", - file=sys.stderr - ) - return 1 - except Exception as exc: - log(f"[add_note] Error: Failed to set note: {exc}", file=sys.stderr) - return 1 - - if not results: - if explicit_target: - # Allow standalone use (no piped input) and enable piping the target forward. - results = [{ - "store": str(store_override), - "hash": hash_override - }] - else: - log( - '[add_note] Error: Requires piped item(s) from add-file, or explicit targeting via store/hash (e.g., -query "instance:<instance> hash:<sha256> ...")', - file=sys.stderr, - ) - return 1 - - store_registry = None - planned_ops = 0 - - # Batch write plan: store -> [(hash, name, text), ...] - note_ops: Dict[str, - List[Tuple[str, - str, - str]]] = {} - - for res in results: - if not isinstance(res, dict): - ctx.emit(res) - continue - - # Determine notes to write for this item - notes_to_write: List[Tuple[str, str]] = [] - - # 1. Explicit arguments always take precedence - if note_name and note_text: - notes_to_write.append((note_name, note_text)) - - # 2. Pipeline notes auto-ingestion - # Look for 'notes' dictionary in the item (propagated by pipeline/download-file) - # Structure: {'notes': {'lyric': '...', 'sub': '...'}} - # Check both root and nested 'extra' - - # Check root 'notes' (dict or extra.notes) - pipeline_notes = res.get("notes") - if not isinstance(pipeline_notes, dict): - extra = res.get("extra") - if isinstance(extra, dict): - pipeline_notes = extra.get("notes") - - if isinstance(pipeline_notes, dict): - for k, v in pipeline_notes.items(): - # If arg-provided note conflicts effectively with pipeline note? - # We just append both. - if v and str(v).strip(): - notes_to_write.append((str(k), str(v))) - - if not notes_to_write: - # Pass through items that have nothing to add - ctx.emit(res) - continue - - store_name, resolved_hash = sh.resolve_item_store_hash( - res, - override_store=str(store_override) if store_override else None, - override_hash=str(hash_override) if hash_override else None, - path_fields=("path",), - ) - - if not store_name: - log( - "[add_note] Error: Missing -instance and item has no store field", - file=sys.stderr - ) - continue - - if not resolved_hash: - log( - "[add_note] Warning: Item missing usable hash; skipping", - file=sys.stderr - ) - ctx.emit(res) - continue - - # Queue operations - if store_name not in note_ops: - note_ops[store_name] = [] - - for (n_name, n_text) in notes_to_write: - note_ops[store_name].append((resolved_hash, n_name, n_text)) - planned_ops += 1 - - ctx.emit(res) - - - # Execute batch operations - def _on_store_error(store_name: str, exc: Exception) -> None: - log(f"[add_note] Store access failed '{store_name}': {exc}", file=sys.stderr) - - def _on_unsupported_store(store_name: str) -> None: - log(f"[add_note] Store '{store_name}' does not support notes", file=sys.stderr) - - def _on_item_error(store_name: str, hash_value: str, note_name_value: str, exc: Exception) -> None: - log(f"[add_note] Write failed {store_name}:{hash_value} ({note_name_value}): {exc}", file=sys.stderr) - - store_registry, success_count = sh.run_store_note_batches( - config, - note_ops, - store_registry=store_registry, - on_store_error=_on_store_error, - on_unsupported_store=_on_unsupported_store, - on_item_error=_on_item_error, - ) - - if planned_ops > 0: - msg = f"✓ add-note: Updated {success_count}/{planned_ops} notes across {len(note_ops)} stores" - ctx.print_if_visible(msg, file=sys.stderr) - - return 0 - - -CMDLET = Add_Note() +"""Compatibility wrapper for moved metadata note add cmdlet.""" +from cmdlet.metadata.note_add import * # noqa: F401,F403 diff --git a/cmdlet/file/add_relationship.py b/cmdlet/file/add_relationship.py index 93ac2db..9e01d63 100644 --- a/cmdlet/file/add_relationship.py +++ b/cmdlet/file/add_relationship.py @@ -1,1170 +1,9 @@ -"""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 +"""Compatibility wrapper for moved metadata relationship add cmdlet.""" -from SYS.logger import log -from SYS.item_accessors import get_sha256_hex, get_store_name -from ProviderCore.registry import get_plugin +from cmdlet.metadata import relationship_add as _relationship_add +from cmdlet.metadata.relationship_add import * # noqa: F401,F403 -from SYS import pipeline as ctx -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 <file> OR @1,@2,@3 | add-relationship", - arg=[ - CmdletArg( - "path", - type="string", - description="Specify the local file path (if not piping a result).", - ), - SharedArgs.INSTANCE, - 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 <hash>,<hash>)", - ), - 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: <KING_HASH>,<ALT_HASH>,<ALT_HASH>' (first hash is king)", - " - Legacy: 'relationship: hash(king)<HASH>,hash(alt)<HASH>...'", - "- 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: <KING_HASH>,<ALT_HASH>,<ALT_HASH> - - Old: relationship: hash(king)<HASH>,hash(alt)<HASH>... - - 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> - pattern = r"hash\((\w+)\)<?([a-fA-F0-9]{64})>?" - 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>,hash(alt)<HASH>") - 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("instance") - override_hashes, query_valid = sh.require_hash_query( - parsed.get("query"), - "Invalid -query value (expected hash:<sha256>)", - 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( - "-instance 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:<sha256>" (supports multiple hash: tokens) - if (not items_to_process) and override_hashes: - if not override_store: - log( - "-instance 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 instance: 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 -instance and ensure all selections are from the same store", - file=sys.stderr, - ) - return 1 - - # Enforce same-instance relationships when store context is available. - if king_store and store_name and str(king_store) != str(store_name): - log( - f"Cross-instance relationship blocked: king is in store '{king_store}' but -instance 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-instance 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: - if not bool(getattr(backend, "supports_relationship_association", False)): - log( - f"Store '{store_name}' does not support relationships", - file=sys.stderr, - ) - return 1 - 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 - hydrus_provider = get_plugin("hydrusnetwork", config) - if store_name and (not is_folder_store) and backend is not None: - try: - if hydrus_provider is not None: - hydrus_client = hydrus_provider.get_client( - store_name=str(store_name), - allow_default=False, - ) - except Exception: - hydrus_client = None - elif not store_name: - try: - if hydrus_provider is not None: - hydrus_client = hydrus_provider.get_client() - 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 -instance 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-instance 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-instance 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-instance edges. - if king_hash and (not _hydrus_hash_exists(hydrus_client, king_hash)): - log( - f"Cross-instance 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-instance 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-instance 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-instance 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-instance 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-instance 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 <file> 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 - hydrus_provider = get_plugin("hydrusnetwork", config) - try: - hydrus_client = hydrus_provider.get_client() if hydrus_provider is not None else None - 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>,hash(alt)<HASH>,hash(related)<HASH>") - 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>,hash(alt)<HASH>,hash(related)<HASH>" - 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() +# Preserve direct private helper imports used by tests and legacy callers. +_extract_hash_and_store = _relationship_add._extract_hash_and_store diff --git a/cmdlet/file/add_url.py b/cmdlet/file/add_url.py index 5986766..e04195d 100644 --- a/cmdlet/file/add_url.py +++ b/cmdlet/file/add_url.py @@ -1,204 +1,5 @@ from __future__ import annotations -from typing import Any, Dict, List, Sequence, Tuple -import sys +"""Compatibility wrapper for moved metadata URL add cmdlet.""" -from SYS import pipeline as ctx -from .. import _shared as sh -from SYS.logger import log -from Store import Store - - -class Add_Url(sh.Cmdlet): - """Add URL associations to files via hash+store.""" - - def __init__(self) -> None: - super().__init__( - name="add-url", - summary="Associate a URL with a file", - usage="@1 | add-url <url>", - arg=[ - sh.SharedArgs.QUERY, - sh.SharedArgs.INSTANCE, - sh.CmdletArg("url", - required=True, - description="URL to associate"), - ], - detail=[ - "- Associates URL with file identified by hash+store", - "- Multiple url can be comma-separated", - ], - exec=self.run, - ) - self.register() - - def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: - """Add URL to file via hash+store backend.""" - parsed = sh.parse_cmdlet_args(args, self) - - # Compatibility/piping fix: - # `SharedArgs.QUERY` is positional in the shared parser, so `add-url <url>` - # (and `@N | add-url <url>`) can mistakenly parse the URL into `query`. - # If `url` is missing and `query` looks like an http(s) URL, treat it as `url`. - try: - if (not parsed.get("url")) and isinstance(parsed.get("query"), str): - q = str(parsed.get("query") or "").strip() - if q.startswith(("http://", "https://")): - parsed["url"] = q - parsed.pop("query", None) - except Exception: - pass - - query_hash, query_valid = sh.require_single_hash_query( - parsed.get("query"), - "Error: -query must be of the form hash:<sha256>", - ) - if not query_valid: - return 1 - - # Bulk input is common in pipelines; treat a list of PipeObjects as a batch. - results: List[Any] = ( - result if isinstance(result, - list) else ([result] if result is not None else []) - ) - - if query_hash and len(results) > 1: - log("Error: -query hash:<sha256> cannot be used with multiple piped items") - return 1 - - # Extract hash and store from result or args - file_hash = query_hash or ( - sh.get_field(result, - "hash") if result is not None else None - ) - store_name = parsed.get("instance") or ( - sh.get_field(result, - "store") if result is not None else None - ) - url_arg = parsed.get("url") - if not url_arg: - try: - inferred = sh.extract_url_from_result(result) - if inferred: - candidate = inferred[0] - if isinstance(candidate, str) and candidate.strip(): - url_arg = candidate.strip() - parsed["url"] = url_arg - except Exception: - pass - - # If we have multiple piped items, we will resolve hash/store per item below. - if not results: - if not file_hash: - log( - 'Error: No file hash provided (pipe an item or use -query "hash:<sha256>")' - ) - return 1 - if not store_name: - log("Error: No store name provided") - return 1 - - if not url_arg: - log("Error: No URL provided") - return 1 - - # Normalize hash (single-item mode) - if not results and file_hash: - file_hash = sh.normalize_hash(file_hash) - if not file_hash: - log("Error: Invalid hash format") - return 1 - - # Parse url (comma-separated) - urls = [u.strip() for u in str(url_arg).split(",") if u.strip()] - if not urls: - log("Error: No valid url provided") - return 1 - - # Get backend and add url - try: - storage = Store(config) - - # Build batches per store. - store_override = parsed.get("instance") - - if results: - def _warn(message: str) -> None: - ctx.print_if_visible(f"[add-url] Warning: {message}", file=sys.stderr) - - batch, pass_through = sh.collect_store_hash_value_batch( - results, - store_registry=storage, - value_resolver=lambda _item: list(urls), - override_hash=query_hash, - override_store=store_override, - on_warning=_warn, - ) - - supported_batch: Dict[str, List[Tuple[str, Sequence[str]]]] = {} - for store_text, store_pairs in batch.items(): - backend, storage, _exc = sh.get_store_backend( - config, - store_text, - store_registry=storage, - ) - if backend is None: - _warn(f"Store '{store_text}' not configured; skipping") - continue - if not bool(getattr(backend, "supports_url_association", False)): - _warn(f"Store '{store_text}' does not support URLs; skipping") - continue - supported_batch[store_text] = store_pairs - - # Execute per-instance batches. - storage, batch_stats = sh.run_store_hash_value_batches( - config, - supported_batch, - bulk_method_name="add_url_bulk", - single_method_name="add_url", - store_registry=storage, - ) - for store_text, item_count, _value_count in batch_stats: - ctx.print_if_visible( - f"✓ add-url: {len(urls)} url(s) for {item_count} item(s) in '{store_text}'", - file=sys.stderr, - ) - - # Pass items through unchanged (but update url field for convenience). - for item in pass_through: - existing = sh.get_field(item, "url") - merged = sh.merge_urls(existing, list(urls)) - sh.set_item_urls(item, merged) - ctx.emit(item) - return 0 - - # Single-item mode - backend, storage, exc = sh.get_store_backend( - config, - str(store_name), - store_registry=storage, - ) - if backend is None: - log(f"Error: Storage backend '{store_name}' not configured") - return 1 - if not bool(getattr(backend, "supports_url_association", False)): - log(f"Error: Store '{store_name}' does not support URL associations") - return 1 - backend.add_url(str(file_hash), urls, config=config) - ctx.print_if_visible( - f"✓ add-url: {len(urls)} url(s) added", - file=sys.stderr - ) - if result is not None: - existing = sh.get_field(result, "url") - merged = sh.merge_urls(existing, list(urls)) - sh.set_item_urls(result, merged) - ctx.emit(result) - return 0 - - except Exception as exc: - log(f"Error adding URL: {exc}", file=sys.stderr) - return 1 - - -CMDLET = Add_Url() +from cmdlet.metadata.url_add import * # noqa: F401,F403 diff --git a/cmdlet/file/get.py b/cmdlet/file/get.py index 37fe254..028eb32 100644 --- a/cmdlet/file/get.py +++ b/cmdlet/file/get.py @@ -41,6 +41,11 @@ class Get_File(sh.Cmdlet): "name", description="Output filename (default: from metadata title)" ), + sh.CmdletArg( + "browser", + flag=True, + description="Open file in browser instead of saving to disk" + ), ], detail=[ "- Exports file from storage backend to local path", @@ -78,6 +83,7 @@ class Get_File(sh.Cmdlet): store_name = parsed.get("instance") or sh.get_field(result, "store") output_path = parsed.get("path") output_name = parsed.get("name") + browser_flag = bool(parsed.get("browser")) if not file_hash: log( @@ -150,9 +156,9 @@ class Get_File(sh.Cmdlet): return "" # Get file from backend (may return Path or URL string depending on backend). - # We pass url=True if no explicit path was provided, which hints the backend - # (specifically Hydrus) to return a browser-friendly URL instead of a local path. - want_url = (output_path is None) + # If -browser is given, request a URL (for Hydrus viewer). If -path is given, + # always retrieve a local file. Otherwise default to local export. + want_url = browser_flag source_path = backend.get_file(file_hash, url=want_url) download_url = None @@ -175,8 +181,8 @@ class Get_File(sh.Cmdlet): except Exception: pass - if download_url and output_path is None: - # Hydrus backend returns a URL; open it only when no output path + if download_url and (browser_flag or output_path is None): + # Open in browser: explicit -browser flag, or Hydrus returned a URL with no output path try: webbrowser.open(download_url) except Exception as exc: diff --git a/cmdlet/metadata/note.py b/cmdlet/metadata/note.py index 6ae4db3..83362ca 100644 --- a/cmdlet/metadata/note.py +++ b/cmdlet/metadata/note.py @@ -8,7 +8,7 @@ def run_note_action(action: str, result: Any, args: Sequence[str], config: Dict[ act = str(action or "").strip().lower() if act == "add": - from cmdlet.file.add_note import CMDLET as ADD_NOTE_CMDLET + from cmdlet.metadata.note_add import CMDLET as ADD_NOTE_CMDLET return int(ADD_NOTE_CMDLET.run(result, args, config)) diff --git a/cmdlet/metadata/note_add.py b/cmdlet/metadata/note_add.py new file mode 100644 index 0000000..54da53a --- /dev/null +++ b/cmdlet/metadata/note_add.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple +import sys +import re + +from SYS.logger import log + +from SYS import pipeline as ctx +from .. import _shared as sh + +Cmdlet = sh.Cmdlet +CmdletArg = sh.CmdletArg +QueryArg = sh.QueryArg +SharedArgs = sh.SharedArgs +normalize_hash = sh.normalize_hash +parse_cmdlet_args = sh.parse_cmdlet_args +normalize_result_input = sh.normalize_result_input +should_show_help = sh.should_show_help + + +class Add_Note(Cmdlet): + DEFAULT_QUERY_HINTS = ( + "title:", + "text:", + "hash:", + "caption:", + "sub:", + "subtitle:", + ) + + def __init__(self) -> None: + super().__init__( + name="add-note", + summary="Add file store note", + usage= + 'add-note (-query "title:<title>,text:<text>[,instance:<instance>][,hash:<sha256>]") [ -instance <store> | <piped> ]', + alias=[""], + arg=[ + SharedArgs.INSTANCE, + QueryArg( + "hash", + key="hash", + aliases=["sha256"], + type="string", + required=False, + handler=normalize_hash, + description= + "(Optional) Specific file hash target, provided via -query as hash:<sha256>. When omitted, uses piped item hash.", + query_only=True, + ), + SharedArgs.QUERY, + ], + detail=[""" + dde + """], + exec=self.run, + ) + # Populate dynamic store choices for autocomplete + try: + SharedArgs.INSTANCE.choices = SharedArgs.get_store_choices(None) + except Exception: + pass + self.register() + + @staticmethod + def _commas_to_spaces_outside_quotes(text: str) -> str: + buf: List[str] = [] + quote: Optional[str] = None + escaped = False + for ch in str(text or ""): + if escaped: + buf.append(ch) + escaped = False + continue + if ch == "\\" and quote is not None: + buf.append(ch) + escaped = True + continue + if ch in ('"', "'"): + if quote is None: + quote = ch + elif quote == ch: + quote = None + buf.append(ch) + continue + if ch == "," and quote is None: + buf.append(" ") + continue + buf.append(ch) + return "".join(buf) + + @staticmethod + def _parse_note_query(query: str) -> Tuple[Optional[str], Optional[str]]: + """Parse note payload from -query. + + Expected: + title:<title>,text:<text> + Commas are treated as separators when not inside quotes. + """ + raw = str(query or "").strip() + if not raw: + return None, None + + try: + from SYS.cli_syntax import parse_query, get_field + except Exception: + parse_query = None # type: ignore + get_field = None # type: ignore + + normalized = Add_Note._commas_to_spaces_outside_quotes(raw) + + if callable(parse_query) and callable(get_field): + parsed = parse_query(normalized) + name = get_field(parsed, "title") + text = get_field(parsed, "text") + name_s = str(name or "").strip() if name is not None else "" + text_s = str(text or "").strip() if text is not None else "" + return (name_s or None, text_s or None) + + # Fallback: best-effort regex. + name_match = re.search( + r"\btitle\s*:\s*([^,\s]+)", + normalized, + flags=re.IGNORECASE + ) + text_match = re.search(r"\btext\s*:\s*(.+)$", normalized, flags=re.IGNORECASE) + note_name = name_match.group(1).strip() if name_match else "" + note_text = text_match.group(1).strip() if text_match else "" + return (note_name or None, note_text or None) + + @classmethod + def _looks_like_note_query_token(cls, token: Any) -> bool: + text = str(token or "").strip().lower() + if not text: + return False + return any(hint in text for hint in cls.DEFAULT_QUERY_HINTS) + + @classmethod + def _default_query_args(cls, args: Sequence[str]) -> List[str]: + tokens: List[str] = list(args or []) + lower_tokens = {str(tok).lower() for tok in tokens if tok is not None} + if "-query" in lower_tokens or "--query" in lower_tokens: + return tokens + + for idx, tok in enumerate(tokens): + token_text = str(tok or "") + if not token_text or token_text.startswith("-"): + continue + if not cls._looks_like_note_query_token(token_text): + continue + + combined_parts = [token_text] + end = idx + 1 + while end < len(tokens): + next_text = str(tokens[end] or "") + if not next_text or next_text.startswith("-"): + break + if not cls._looks_like_note_query_token(next_text): + break + combined_parts.append(next_text) + end += 1 + + combined_query = " ".join(combined_parts) + tokens[idx:end] = [combined_query] + tokens.insert(idx, "-query") + return tokens + + return tokens + + def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: + if should_show_help(args): + log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}") + return 0 + + parsed_args = self._default_query_args(args) + parsed = parse_cmdlet_args(parsed_args, self) + + store_override = parsed.get("instance") + hash_override = normalize_hash(parsed.get("hash")) + note_name, note_text = self._parse_note_query(str(parsed.get("query") or "")) + note_name = str(note_name or "").strip() + note_text = str(note_text or "").strip() + if not note_name or not note_text: + pass # We now support implicit pipeline notes if -query is missing + # But if explicit targeting (store+hash) is used, we still demand args below. + + if hash_override and not store_override: + log( + "[add_note] Error: hash:<sha256> requires instance:<instance> in -query or -instance <store>", + file=sys.stderr, + ) + return 1 + + explicit_target = bool(hash_override and store_override) + results = normalize_result_input(result) + + if explicit_target and (not note_name or not note_text): + log( + "[add_note] Error: Explicit target (store+hash) requires -query with title/text", + file=sys.stderr, + ) + return 1 + + if results and explicit_target: + # Direct targeting mode: apply note once to the explicit target and + # pass through any piped items unchanged. + try: + backend, _store_registry, exc = sh.get_store_backend( + config, + str(store_override), + ) + if backend is None: + raise exc or KeyError(store_override) + if not bool(getattr(backend, "supports_note_association", False)): + log( + f"[add_note] Error: Store '{store_override}' does not support notes", + file=sys.stderr, + ) + return 1 + ok = bool( + backend.set_note( + str(hash_override), + note_name, + note_text, + config=config + ) + ) + if ok: + ctx.print_if_visible( + f"✓ add-note: 1 item in '{store_override}'", + file=sys.stderr + ) + log( + "[add_note] Updated 1/1 item(s)", + file=sys.stderr + ) + for res in results: + ctx.emit(res) + return 0 + log( + "[add_note] Warning: Note write reported failure", + file=sys.stderr + ) + return 1 + except Exception as exc: + log(f"[add_note] Error: Failed to set note: {exc}", file=sys.stderr) + return 1 + + if not results: + if explicit_target: + # Allow standalone use (no piped input) and enable piping the target forward. + results = [{ + "store": str(store_override), + "hash": hash_override + }] + else: + log( + '[add_note] Error: Requires piped item(s) from add-file, or explicit targeting via store/hash (e.g., -query "instance:<instance> hash:<sha256> ...")', + file=sys.stderr, + ) + return 1 + + store_registry = None + planned_ops = 0 + + # Batch write plan: store -> [(hash, name, text), ...] + note_ops: Dict[str, + List[Tuple[str, + str, + str]]] = {} + + for res in results: + if not isinstance(res, dict): + ctx.emit(res) + continue + + # Determine notes to write for this item + notes_to_write: List[Tuple[str, str]] = [] + + # 1. Explicit arguments always take precedence + if note_name and note_text: + notes_to_write.append((note_name, note_text)) + + # 2. Pipeline notes auto-ingestion + # Look for 'notes' dictionary in the item (propagated by pipeline/download-file) + # Structure: {'notes': {'lyric': '...', 'sub': '...'}} + # Check both root and nested 'extra' + + # Check root 'notes' (dict or extra.notes) + pipeline_notes = res.get("notes") + if not isinstance(pipeline_notes, dict): + extra = res.get("extra") + if isinstance(extra, dict): + pipeline_notes = extra.get("notes") + + if isinstance(pipeline_notes, dict): + for k, v in pipeline_notes.items(): + # If arg-provided note conflicts effectively with pipeline note? + # We just append both. + if v and str(v).strip(): + notes_to_write.append((str(k), str(v))) + + if not notes_to_write: + # Pass through items that have nothing to add + ctx.emit(res) + continue + + store_name, resolved_hash = sh.resolve_item_store_hash( + res, + override_store=str(store_override) if store_override else None, + override_hash=str(hash_override) if hash_override else None, + path_fields=("path",), + ) + + if not store_name: + log( + "[add_note] Error: Missing -instance and item has no store field", + file=sys.stderr + ) + continue + + if not resolved_hash: + log( + "[add_note] Warning: Item missing usable hash; skipping", + file=sys.stderr + ) + ctx.emit(res) + continue + + # Queue operations + if store_name not in note_ops: + note_ops[store_name] = [] + + for (n_name, n_text) in notes_to_write: + note_ops[store_name].append((resolved_hash, n_name, n_text)) + planned_ops += 1 + + ctx.emit(res) + + + # Execute batch operations + def _on_store_error(store_name: str, exc: Exception) -> None: + log(f"[add_note] Store access failed '{store_name}': {exc}", file=sys.stderr) + + def _on_unsupported_store(store_name: str) -> None: + log(f"[add_note] Store '{store_name}' does not support notes", file=sys.stderr) + + def _on_item_error(store_name: str, hash_value: str, note_name_value: str, exc: Exception) -> None: + log(f"[add_note] Write failed {store_name}:{hash_value} ({note_name_value}): {exc}", file=sys.stderr) + + store_registry, success_count = sh.run_store_note_batches( + config, + note_ops, + store_registry=store_registry, + on_store_error=_on_store_error, + on_unsupported_store=_on_unsupported_store, + on_item_error=_on_item_error, + ) + + if planned_ops > 0: + msg = f"✓ add-note: Updated {success_count}/{planned_ops} notes across {len(note_ops)} stores" + ctx.print_if_visible(msg, file=sys.stderr) + + return 0 + + +CMDLET = Add_Note() + diff --git a/cmdlet/metadata/relationship.py b/cmdlet/metadata/relationship.py index bfb6678..7dc8112 100644 --- a/cmdlet/metadata/relationship.py +++ b/cmdlet/metadata/relationship.py @@ -8,7 +8,7 @@ def run_relationship_action(action: str, result: Any, args: Sequence[str], confi act = str(action or "").strip().lower() if act == "add": - from cmdlet.file.add_relationship import _run as run_add_relationship + from cmdlet.metadata.relationship_add import _run as run_add_relationship return int(run_add_relationship(result, args, config)) diff --git a/cmdlet/metadata/relationship_add.py b/cmdlet/metadata/relationship_add.py new file mode 100644 index 0000000..93ac2db --- /dev/null +++ b/cmdlet/metadata/relationship_add.py @@ -0,0 +1,1170 @@ +"""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 ProviderCore.registry import get_plugin + +from SYS import pipeline as ctx +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 <file> OR @1,@2,@3 | add-relationship", + arg=[ + CmdletArg( + "path", + type="string", + description="Specify the local file path (if not piping a result).", + ), + SharedArgs.INSTANCE, + 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 <hash>,<hash>)", + ), + 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: <KING_HASH>,<ALT_HASH>,<ALT_HASH>' (first hash is king)", + " - Legacy: 'relationship: hash(king)<HASH>,hash(alt)<HASH>...'", + "- 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: <KING_HASH>,<ALT_HASH>,<ALT_HASH> + - Old: relationship: hash(king)<HASH>,hash(alt)<HASH>... + + 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> + pattern = r"hash\((\w+)\)<?([a-fA-F0-9]{64})>?" + 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>,hash(alt)<HASH>") + 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("instance") + override_hashes, query_valid = sh.require_hash_query( + parsed.get("query"), + "Invalid -query value (expected hash:<sha256>)", + 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( + "-instance 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:<sha256>" (supports multiple hash: tokens) + if (not items_to_process) and override_hashes: + if not override_store: + log( + "-instance 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 instance: 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 -instance and ensure all selections are from the same store", + file=sys.stderr, + ) + return 1 + + # Enforce same-instance relationships when store context is available. + if king_store and store_name and str(king_store) != str(store_name): + log( + f"Cross-instance relationship blocked: king is in store '{king_store}' but -instance 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-instance 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: + if not bool(getattr(backend, "supports_relationship_association", False)): + log( + f"Store '{store_name}' does not support relationships", + file=sys.stderr, + ) + return 1 + 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 + hydrus_provider = get_plugin("hydrusnetwork", config) + if store_name and (not is_folder_store) and backend is not None: + try: + if hydrus_provider is not None: + hydrus_client = hydrus_provider.get_client( + store_name=str(store_name), + allow_default=False, + ) + except Exception: + hydrus_client = None + elif not store_name: + try: + if hydrus_provider is not None: + hydrus_client = hydrus_provider.get_client() + 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 -instance 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-instance 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-instance 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-instance edges. + if king_hash and (not _hydrus_hash_exists(hydrus_client, king_hash)): + log( + f"Cross-instance 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-instance 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-instance 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-instance 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-instance 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-instance 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 <file> 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 + hydrus_provider = get_plugin("hydrusnetwork", config) + try: + hydrus_client = hydrus_provider.get_client() if hydrus_provider is not None else None + 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>,hash(alt)<HASH>,hash(related)<HASH>") + 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>,hash(alt)<HASH>,hash(related)<HASH>" + 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() diff --git a/cmdlet/metadata/url.py b/cmdlet/metadata/url.py index 064313e..905cd33 100644 --- a/cmdlet/metadata/url.py +++ b/cmdlet/metadata/url.py @@ -8,7 +8,7 @@ def run_url_action(action: str, result: Any, args: Sequence[str], config: Dict[s act = str(action or "").strip().lower() if act == "add": - from cmdlet.file.add_url import CMDLET as ADD_URL_CMDLET + from cmdlet.metadata.url_add import CMDLET as ADD_URL_CMDLET return int(ADD_URL_CMDLET.run(result, args, config)) diff --git a/cmdlet/metadata/url_add.py b/cmdlet/metadata/url_add.py new file mode 100644 index 0000000..5986766 --- /dev/null +++ b/cmdlet/metadata/url_add.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Sequence, Tuple +import sys + +from SYS import pipeline as ctx +from .. import _shared as sh +from SYS.logger import log +from Store import Store + + +class Add_Url(sh.Cmdlet): + """Add URL associations to files via hash+store.""" + + def __init__(self) -> None: + super().__init__( + name="add-url", + summary="Associate a URL with a file", + usage="@1 | add-url <url>", + arg=[ + sh.SharedArgs.QUERY, + sh.SharedArgs.INSTANCE, + sh.CmdletArg("url", + required=True, + description="URL to associate"), + ], + detail=[ + "- Associates URL with file identified by hash+store", + "- Multiple url can be comma-separated", + ], + exec=self.run, + ) + self.register() + + def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: + """Add URL to file via hash+store backend.""" + parsed = sh.parse_cmdlet_args(args, self) + + # Compatibility/piping fix: + # `SharedArgs.QUERY` is positional in the shared parser, so `add-url <url>` + # (and `@N | add-url <url>`) can mistakenly parse the URL into `query`. + # If `url` is missing and `query` looks like an http(s) URL, treat it as `url`. + try: + if (not parsed.get("url")) and isinstance(parsed.get("query"), str): + q = str(parsed.get("query") or "").strip() + if q.startswith(("http://", "https://")): + parsed["url"] = q + parsed.pop("query", None) + except Exception: + pass + + query_hash, query_valid = sh.require_single_hash_query( + parsed.get("query"), + "Error: -query must be of the form hash:<sha256>", + ) + if not query_valid: + return 1 + + # Bulk input is common in pipelines; treat a list of PipeObjects as a batch. + results: List[Any] = ( + result if isinstance(result, + list) else ([result] if result is not None else []) + ) + + if query_hash and len(results) > 1: + log("Error: -query hash:<sha256> cannot be used with multiple piped items") + return 1 + + # Extract hash and store from result or args + file_hash = query_hash or ( + sh.get_field(result, + "hash") if result is not None else None + ) + store_name = parsed.get("instance") or ( + sh.get_field(result, + "store") if result is not None else None + ) + url_arg = parsed.get("url") + if not url_arg: + try: + inferred = sh.extract_url_from_result(result) + if inferred: + candidate = inferred[0] + if isinstance(candidate, str) and candidate.strip(): + url_arg = candidate.strip() + parsed["url"] = url_arg + except Exception: + pass + + # If we have multiple piped items, we will resolve hash/store per item below. + if not results: + if not file_hash: + log( + 'Error: No file hash provided (pipe an item or use -query "hash:<sha256>")' + ) + return 1 + if not store_name: + log("Error: No store name provided") + return 1 + + if not url_arg: + log("Error: No URL provided") + return 1 + + # Normalize hash (single-item mode) + if not results and file_hash: + file_hash = sh.normalize_hash(file_hash) + if not file_hash: + log("Error: Invalid hash format") + return 1 + + # Parse url (comma-separated) + urls = [u.strip() for u in str(url_arg).split(",") if u.strip()] + if not urls: + log("Error: No valid url provided") + return 1 + + # Get backend and add url + try: + storage = Store(config) + + # Build batches per store. + store_override = parsed.get("instance") + + if results: + def _warn(message: str) -> None: + ctx.print_if_visible(f"[add-url] Warning: {message}", file=sys.stderr) + + batch, pass_through = sh.collect_store_hash_value_batch( + results, + store_registry=storage, + value_resolver=lambda _item: list(urls), + override_hash=query_hash, + override_store=store_override, + on_warning=_warn, + ) + + supported_batch: Dict[str, List[Tuple[str, Sequence[str]]]] = {} + for store_text, store_pairs in batch.items(): + backend, storage, _exc = sh.get_store_backend( + config, + store_text, + store_registry=storage, + ) + if backend is None: + _warn(f"Store '{store_text}' not configured; skipping") + continue + if not bool(getattr(backend, "supports_url_association", False)): + _warn(f"Store '{store_text}' does not support URLs; skipping") + continue + supported_batch[store_text] = store_pairs + + # Execute per-instance batches. + storage, batch_stats = sh.run_store_hash_value_batches( + config, + supported_batch, + bulk_method_name="add_url_bulk", + single_method_name="add_url", + store_registry=storage, + ) + for store_text, item_count, _value_count in batch_stats: + ctx.print_if_visible( + f"✓ add-url: {len(urls)} url(s) for {item_count} item(s) in '{store_text}'", + file=sys.stderr, + ) + + # Pass items through unchanged (but update url field for convenience). + for item in pass_through: + existing = sh.get_field(item, "url") + merged = sh.merge_urls(existing, list(urls)) + sh.set_item_urls(item, merged) + ctx.emit(item) + return 0 + + # Single-item mode + backend, storage, exc = sh.get_store_backend( + config, + str(store_name), + store_registry=storage, + ) + if backend is None: + log(f"Error: Storage backend '{store_name}' not configured") + return 1 + if not bool(getattr(backend, "supports_url_association", False)): + log(f"Error: Store '{store_name}' does not support URL associations") + return 1 + backend.add_url(str(file_hash), urls, config=config) + ctx.print_if_visible( + f"✓ add-url: {len(urls)} url(s) added", + file=sys.stderr + ) + if result is not None: + existing = sh.get_field(result, "url") + merged = sh.merge_urls(existing, list(urls)) + sh.set_item_urls(result, merged) + ctx.emit(result) + return 0 + + except Exception as exc: + log(f"Error adding URL: {exc}", file=sys.stderr) + return 1 + + +CMDLET = Add_Url()