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:[,instance:][,hash:]") [ -instance | ]',
- 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:. 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:,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: requires instance: in -query or -instance ",
- 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: hash: ...")',
- 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 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 ,)",
- ),
- CmdletArg(
- "-type",
- type="string",
- description=
- "Relationship type for piped items (default: 'alt', options: 'king', 'alt', 'related')",
- ),
- ],
- detail=[
- "- Mode 1: Pipe multiple items, first becomes king, rest become alts (default)",
- "- Mode 2: Use -king to explicitly set which item/hash is the king: @1-3 | add-relationship -king @4",
- "- Mode 2b: Use -king and -alt to select both sides from the last table: add-relationship -king @1 -alt @3-5",
- "- Mode 3: Read relationships from sidecar tags:",
- " - New format: 'relationship: ,,' (first hash is king)",
- " - Legacy: 'relationship: hash(king),hash(alt)...'",
- "- Supports three relationship types: king (primary), alt (alternative), related (other versions)",
- "- When using -king, all piped items become the specified relationship type to the king",
- ],
-)
-
-
-_normalize_hash_hex = sh.normalize_hash
-
-
-def _extract_relationships_from_tag(tag_value: str) -> Dict[str, list[str]]:
- """Parse relationship tags.
-
- Supported formats:
- - New: relationship: ,,
- - Old: relationship: hash(king),hash(alt)...
-
- Returns a dict like {"king": ["HASH1"], "alt": ["HASH2"], ...}
- """
- result: Dict[str,
- list[str]] = {}
- if not isinstance(tag_value, str):
- return result
-
- # Match patterns like hash(king)HASH or hash(type)
- pattern = r"hash\((\w+)\)([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(alt)")
- 2. Pipeline mode: When piping multiple results, the first becomes "king" and subsequent items become "alt"
-
- Returns 0 on success, non-zero on failure.
- """
- # Help
- if should_show_help(_args):
- log(f"Cmdlet: {CMDLET.name}\nSummary: {CMDLET.summary}\nUsage: {CMDLET.usage}")
- return 0
-
- # Parse arguments using CMDLET spec
- parsed = parse_cmdlet_args(_args, CMDLET)
- arg_path: Optional[Path] = None
- override_store = parsed.get("instance")
- override_hashes, query_valid = sh.require_hash_query(
- parsed.get("query"),
- "Invalid -query value (expected hash:)",
- log_file=sys.stderr,
- )
- if not query_valid:
- return 1
- king_arg = parsed.get("king")
- alt_arg = parsed.get("alt")
- rel_type = parsed.get("type", "alt")
-
- raw_path = parsed.get("path")
- if raw_path:
- try:
- arg_path = Path(str(raw_path)).expanduser()
- except Exception:
- arg_path = Path(str(raw_path))
-
- # Handle @N selection which creates a list
- # Use normalize_result_input to handle both single items and lists
- items_to_process = normalize_result_input(result)
-
- # Allow selecting alt items directly from the last table via -alt @...
- # This enables: add-relationship -king @1 -alt @3-5
- if alt_arg:
- alt_text = str(alt_arg).strip()
- resolved_alt_items: list[Any] = []
- if alt_text.startswith("@"):
- selected = _resolve_items_from_at(alt_text)
- if not selected:
- log(
- f"Failed to resolve -alt {alt_text}: no selection context",
- file=sys.stderr
- )
- return 1
- resolved_alt_items = selected
- else:
- # Treat as comma/semicolon-separated list of hashes
- parts = [
- p.strip() for p in alt_text.replace(";", ",").split(",") if p.strip()
- ]
- hashes = [h for h in (_normalize_hash_hex(p) for p in parts) if h]
- if not hashes:
- log(
- "Invalid -alt value (expected @ selection or 64-hex sha256 hash list)",
- file=sys.stderr,
- )
- return 1
- if not override_store:
- log(
- "-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:" (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 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(alt),hash(related)")
- relationship_tags = [
- t for t in tags if isinstance(t, str) and t.lower().startswith("relationship:")
- ]
-
- if not relationship_tags:
- log("No relationship tags found in sidecar", file=sys.stderr)
- return 0 # Not an error, just nothing to do
-
- # Get the file hash from result (should have been set by add-file)
- file_hash = getattr(result, "hash_hex", None)
- if not file_hash:
- log("File hash not available (run add-file first)", file=sys.stderr)
- return 1
-
- file_hash = _normalize_hash_hex(file_hash)
- if not file_hash:
- log("Invalid file hash format", file=sys.stderr)
- return 1
-
- # Parse relationships from tags and apply them
- success_count = 0
- error_count = 0
-
- for rel_tag in relationship_tags:
- try:
- # Parse: "relationship: hash(king),hash(alt),hash(related)"
- rel_str = rel_tag.split(":", 1)[1].strip() # Get part after "relationship:"
-
- # Parse relationships
- rels = _extract_relationships_from_tag(f"relationship: {rel_str}")
-
- # Set the relationships in Hydrus
- for rel_type, related_hashes in rels.items():
- if not related_hashes:
- continue
-
- for related_hash in related_hashes:
- # Don't set relationship between hash and itself
- if file_hash == related_hash:
- continue
-
- try:
- hydrus_client.set_relationship(
- file_hash,
- related_hash,
- rel_type
- )
- log(
- f"[add-relationship] Set {rel_type} relationship: "
- f"{file_hash} <-> {related_hash}",
- file=sys.stderr,
- )
- success_count += 1
- except Exception as exc:
- log(
- f"Failed to set {rel_type} relationship: {exc}",
- file=sys.stderr
- )
- error_count += 1
-
- except Exception as exc:
- log(f"Failed to parse relationship tag: {exc}", file=sys.stderr)
- error_count += 1
-
- if success_count > 0:
- log(
- f"Successfully set {success_count} relationship(s) for {media_path.name}",
- file=sys.stderr,
- )
- ctx.emit(
- f"add-relationship: {media_path.name} ({success_count} relationships set)"
- )
- return 0
- elif error_count == 0:
- log("No relationships to set", file=sys.stderr)
- return 0 # Success with nothing to do
- else:
- log(f"Failed with {error_count} error(s)", file=sys.stderr)
- return 1
-
-
-# Register cmdlet (no legacy decorator)
-CMDLET.exec = _run
-CMDLET.alias = ["add-rel"]
-CMDLET.register()
+# 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 ",
- 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 `
- # (and `@N | add-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:",
- )
- 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: 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:")'
- )
- 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:,text:[,instance:][,hash:]") [ -instance | ]',
+ 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:. 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:,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: requires instance: in -query or -instance ",
+ 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: hash: ...")',
+ 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 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 ,)",
+ ),
+ CmdletArg(
+ "-type",
+ type="string",
+ description=
+ "Relationship type for piped items (default: 'alt', options: 'king', 'alt', 'related')",
+ ),
+ ],
+ detail=[
+ "- Mode 1: Pipe multiple items, first becomes king, rest become alts (default)",
+ "- Mode 2: Use -king to explicitly set which item/hash is the king: @1-3 | add-relationship -king @4",
+ "- Mode 2b: Use -king and -alt to select both sides from the last table: add-relationship -king @1 -alt @3-5",
+ "- Mode 3: Read relationships from sidecar tags:",
+ " - New format: 'relationship: ,,' (first hash is king)",
+ " - Legacy: 'relationship: hash(king),hash(alt)...'",
+ "- Supports three relationship types: king (primary), alt (alternative), related (other versions)",
+ "- When using -king, all piped items become the specified relationship type to the king",
+ ],
+)
+
+
+_normalize_hash_hex = sh.normalize_hash
+
+
+def _extract_relationships_from_tag(tag_value: str) -> Dict[str, list[str]]:
+ """Parse relationship tags.
+
+ Supported formats:
+ - New: relationship: ,,
+ - Old: relationship: hash(king),hash(alt)...
+
+ Returns a dict like {"king": ["HASH1"], "alt": ["HASH2"], ...}
+ """
+ result: Dict[str,
+ list[str]] = {}
+ if not isinstance(tag_value, str):
+ return result
+
+ # Match patterns like hash(king)HASH or hash(type)
+ pattern = r"hash\((\w+)\)([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(alt)")
+ 2. Pipeline mode: When piping multiple results, the first becomes "king" and subsequent items become "alt"
+
+ Returns 0 on success, non-zero on failure.
+ """
+ # Help
+ if should_show_help(_args):
+ log(f"Cmdlet: {CMDLET.name}\nSummary: {CMDLET.summary}\nUsage: {CMDLET.usage}")
+ return 0
+
+ # Parse arguments using CMDLET spec
+ parsed = parse_cmdlet_args(_args, CMDLET)
+ arg_path: Optional[Path] = None
+ override_store = parsed.get("instance")
+ override_hashes, query_valid = sh.require_hash_query(
+ parsed.get("query"),
+ "Invalid -query value (expected hash:)",
+ log_file=sys.stderr,
+ )
+ if not query_valid:
+ return 1
+ king_arg = parsed.get("king")
+ alt_arg = parsed.get("alt")
+ rel_type = parsed.get("type", "alt")
+
+ raw_path = parsed.get("path")
+ if raw_path:
+ try:
+ arg_path = Path(str(raw_path)).expanduser()
+ except Exception:
+ arg_path = Path(str(raw_path))
+
+ # Handle @N selection which creates a list
+ # Use normalize_result_input to handle both single items and lists
+ items_to_process = normalize_result_input(result)
+
+ # Allow selecting alt items directly from the last table via -alt @...
+ # This enables: add-relationship -king @1 -alt @3-5
+ if alt_arg:
+ alt_text = str(alt_arg).strip()
+ resolved_alt_items: list[Any] = []
+ if alt_text.startswith("@"):
+ selected = _resolve_items_from_at(alt_text)
+ if not selected:
+ log(
+ f"Failed to resolve -alt {alt_text}: no selection context",
+ file=sys.stderr
+ )
+ return 1
+ resolved_alt_items = selected
+ else:
+ # Treat as comma/semicolon-separated list of hashes
+ parts = [
+ p.strip() for p in alt_text.replace(";", ",").split(",") if p.strip()
+ ]
+ hashes = [h for h in (_normalize_hash_hex(p) for p in parts) if h]
+ if not hashes:
+ log(
+ "Invalid -alt value (expected @ selection or 64-hex sha256 hash list)",
+ file=sys.stderr,
+ )
+ return 1
+ if not override_store:
+ log(
+ "-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:" (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 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(alt),hash(related)")
+ relationship_tags = [
+ t for t in tags if isinstance(t, str) and t.lower().startswith("relationship:")
+ ]
+
+ if not relationship_tags:
+ log("No relationship tags found in sidecar", file=sys.stderr)
+ return 0 # Not an error, just nothing to do
+
+ # Get the file hash from result (should have been set by add-file)
+ file_hash = getattr(result, "hash_hex", None)
+ if not file_hash:
+ log("File hash not available (run add-file first)", file=sys.stderr)
+ return 1
+
+ file_hash = _normalize_hash_hex(file_hash)
+ if not file_hash:
+ log("Invalid file hash format", file=sys.stderr)
+ return 1
+
+ # Parse relationships from tags and apply them
+ success_count = 0
+ error_count = 0
+
+ for rel_tag in relationship_tags:
+ try:
+ # Parse: "relationship: hash(king),hash(alt),hash(related)"
+ rel_str = rel_tag.split(":", 1)[1].strip() # Get part after "relationship:"
+
+ # Parse relationships
+ rels = _extract_relationships_from_tag(f"relationship: {rel_str}")
+
+ # Set the relationships in Hydrus
+ for rel_type, related_hashes in rels.items():
+ if not related_hashes:
+ continue
+
+ for related_hash in related_hashes:
+ # Don't set relationship between hash and itself
+ if file_hash == related_hash:
+ continue
+
+ try:
+ hydrus_client.set_relationship(
+ file_hash,
+ related_hash,
+ rel_type
+ )
+ log(
+ f"[add-relationship] Set {rel_type} relationship: "
+ f"{file_hash} <-> {related_hash}",
+ file=sys.stderr,
+ )
+ success_count += 1
+ except Exception as exc:
+ log(
+ f"Failed to set {rel_type} relationship: {exc}",
+ file=sys.stderr
+ )
+ error_count += 1
+
+ except Exception as exc:
+ log(f"Failed to parse relationship tag: {exc}", file=sys.stderr)
+ error_count += 1
+
+ if success_count > 0:
+ log(
+ f"Successfully set {success_count} relationship(s) for {media_path.name}",
+ file=sys.stderr,
+ )
+ ctx.emit(
+ f"add-relationship: {media_path.name} ({success_count} relationships set)"
+ )
+ return 0
+ elif error_count == 0:
+ log("No relationships to set", file=sys.stderr)
+ return 0 # Success with nothing to do
+ else:
+ log(f"Failed with {error_count} error(s)", file=sys.stderr)
+ return 1
+
+
+# Register cmdlet (no legacy decorator)
+CMDLET.exec = _run
+CMDLET.alias = ["add-rel"]
+CMDLET.register()
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 ",
+ 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 `
+ # (and `@N | add-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:",
+ )
+ 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: 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:")'
+ )
+ 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()