d
This commit is contained in:
@@ -3,18 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from SYS.logger import log
|
||||
|
||||
import models
|
||||
import pipeline as ctx
|
||||
from API import HydrusNetwork as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args, normalize_result_input, should_show_help, get_field
|
||||
from API.folder import read_sidecar, find_sidecar
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, normalize_result_input, should_show_help, get_field
|
||||
from API.folder import read_sidecar, find_sidecar, API_folder_store
|
||||
from Store import Store
|
||||
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
@@ -23,13 +22,19 @@ CMDLET = Cmdlet(
|
||||
usage="@1-3 | add-relationship -king @4 OR add-relationship -path <file> OR @1,@2,@3 | add-relationship",
|
||||
arg=[
|
||||
CmdletArg("path", type="string", description="Specify the local file path (if not piping a result)."),
|
||||
SharedArgs.STORE,
|
||||
SharedArgs.HASH,
|
||||
CmdletArg("-king", type="string", description="Explicitly set the king hash/file for relationships (e.g., -king @4 or -king hash)"),
|
||||
CmdletArg("-alt", type="string", description="Explicitly select alt item(s) by @ selection or hash list (e.g., -alt @3-5 or -alt <hash>,<hash>)"),
|
||||
CmdletArg("-type", type="string", description="Relationship type for piped items (default: 'alt', options: 'king', 'alt', 'related')"),
|
||||
],
|
||||
detail=[
|
||||
"- Mode 1: Pipe multiple items, first becomes king, rest become alts (default)",
|
||||
"- Mode 2: Use -king to explicitly set which item/hash is the king: @1-3 | add-relationship -king @4",
|
||||
"- Mode 3: Read relationships from sidecar (format: 'relationship: hash(king)<HASH>,hash(alt)<HASH>...')",
|
||||
"- Mode 2b: Use -king and -alt to select both sides from the last table: add-relationship -king @1 -alt @3-5",
|
||||
"- Mode 3: Read relationships from sidecar tags:",
|
||||
" - New format: 'relationship: <KING_HASH>,<ALT_HASH>,<ALT_HASH>' (first hash is king)",
|
||||
" - Legacy: 'relationship: hash(king)<HASH>,hash(alt)<HASH>...'",
|
||||
"- Supports three relationship types: king (primary), alt (alternative), related (other versions)",
|
||||
"- When using -king, all piped items become the specified relationship type to the king",
|
||||
],
|
||||
@@ -47,39 +52,236 @@ def _normalise_hash_hex(value: Optional[str]) -> Optional[str]:
|
||||
|
||||
|
||||
def _extract_relationships_from_tag(tag_value: str) -> Dict[str, list[str]]:
|
||||
"""Parse relationship tag like 'relationship: hash(king)<HASH>,hash(alt)<HASH>'.
|
||||
|
||||
"""Parse relationship tags.
|
||||
|
||||
Supported formats:
|
||||
- New: relationship: <KING_HASH>,<ALT_HASH>,<ALT_HASH>
|
||||
- Old: relationship: hash(king)<HASH>,hash(alt)<HASH>...
|
||||
|
||||
Returns a dict like {"king": ["HASH1"], "alt": ["HASH2"], ...}
|
||||
"""
|
||||
result: Dict[str, list[str]] = {}
|
||||
if not isinstance(tag_value, str):
|
||||
return result
|
||||
|
||||
# Match patterns like hash(king)HASH or hash(type)HASH (no angle brackets)
|
||||
pattern = r'hash\((\w+)\)([a-fA-F0-9]{64})'
|
||||
# Match patterns like hash(king)HASH or hash(type)<HASH>
|
||||
pattern = r'hash\((\w+)\)<?([a-fA-F0-9]{64})>?'
|
||||
matches = re.findall(pattern, tag_value)
|
||||
|
||||
for rel_type, hash_value in matches:
|
||||
normalized = _normalise_hash_hex(hash_value)
|
||||
if normalized:
|
||||
if rel_type not in result:
|
||||
result[rel_type] = []
|
||||
result[rel_type].append(normalized)
|
||||
|
||||
|
||||
if matches:
|
||||
for rel_type, hash_value in matches:
|
||||
normalized = _normalise_hash_hex(hash_value)
|
||||
if normalized:
|
||||
if rel_type not in result:
|
||||
result[rel_type] = []
|
||||
result[rel_type].append(normalized)
|
||||
return result
|
||||
|
||||
# New format: extract hashes, first is king
|
||||
hashes = re.findall(r"\b[a-fA-F0-9]{64}\b", tag_value)
|
||||
hashes = [h.strip().lower() for h in hashes if isinstance(h, str)]
|
||||
if not hashes:
|
||||
return result
|
||||
king = _normalise_hash_hex(hashes[0])
|
||||
if not king:
|
||||
return result
|
||||
result["king"] = [king]
|
||||
alts: list[str] = []
|
||||
for h in hashes[1:]:
|
||||
normalized = _normalise_hash_hex(h)
|
||||
if normalized and normalized != king:
|
||||
alts.append(normalized)
|
||||
if alts:
|
||||
result["alt"] = alts
|
||||
return result
|
||||
|
||||
|
||||
def _apply_relationships_from_tags(
|
||||
relationship_tags: Sequence[str],
|
||||
*,
|
||||
hydrus_client: Any,
|
||||
use_local_storage: bool,
|
||||
local_storage_path: Optional[Path],
|
||||
config: Dict[str, Any],
|
||||
) -> int:
|
||||
"""Persist relationship tags into Hydrus or local DB.
|
||||
|
||||
Local DB semantics:
|
||||
- Treat the first hash (king) as the king.
|
||||
- Store directional alt -> king relationships (no reverse edge).
|
||||
"""
|
||||
rel_tags = [t for t in relationship_tags if isinstance(t, str) and t.strip().lower().startswith("relationship:")]
|
||||
if not rel_tags:
|
||||
return 0
|
||||
|
||||
# Prefer Hydrus if available (hash-based relationships map naturally).
|
||||
if hydrus_client is not None and hasattr(hydrus_client, "set_relationship"):
|
||||
processed: set[tuple[str, str, str]] = set()
|
||||
for tag in rel_tags:
|
||||
rels = _extract_relationships_from_tag(tag)
|
||||
king = (rels.get("king") or [None])[0]
|
||||
if not king:
|
||||
continue
|
||||
king_norm = _normalise_hash_hex(king)
|
||||
if not king_norm:
|
||||
continue
|
||||
|
||||
for rel_type in ("alt", "related"):
|
||||
for other in rels.get(rel_type, []) or []:
|
||||
other_norm = _normalise_hash_hex(other)
|
||||
if not other_norm or other_norm == king_norm:
|
||||
continue
|
||||
key = (other_norm, king_norm, rel_type)
|
||||
if key in processed:
|
||||
continue
|
||||
try:
|
||||
hydrus_client.set_relationship(other_norm, king_norm, rel_type)
|
||||
processed.add(key)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
# Local DB fallback (store/hash-first)
|
||||
if use_local_storage and local_storage_path is not None:
|
||||
try:
|
||||
with API_folder_store(local_storage_path) as db:
|
||||
processed_pairs: set[tuple[str, str]] = set()
|
||||
for tag in rel_tags:
|
||||
rels = _extract_relationships_from_tag(tag)
|
||||
king = (rels.get("king") or [None])[0]
|
||||
if not king:
|
||||
continue
|
||||
king_norm = _normalise_hash_hex(king)
|
||||
if not king_norm:
|
||||
continue
|
||||
|
||||
# For local DB we treat all non-king hashes as alts.
|
||||
alt_hashes: list[str] = []
|
||||
for bucket in ("alt", "related"):
|
||||
alt_hashes.extend([h for h in (rels.get(bucket) or []) if isinstance(h, str)])
|
||||
|
||||
for alt in alt_hashes:
|
||||
alt_norm = _normalise_hash_hex(alt)
|
||||
if not alt_norm or alt_norm == king_norm:
|
||||
continue
|
||||
if (alt_norm, king_norm) in processed_pairs:
|
||||
continue
|
||||
db.set_relationship_by_hash(alt_norm, king_norm, "alt", bidirectional=False)
|
||||
processed_pairs.add((alt_norm, king_norm))
|
||||
except Exception:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_at_selection(token: str) -> Optional[list[int]]:
|
||||
"""Parse standard @ selection syntax into a list of 0-based indices.
|
||||
|
||||
Supports: @2, @2-5, @{1,3,5}, @3,5,7, @3-6,8, @*
|
||||
"""
|
||||
if not isinstance(token, str):
|
||||
return None
|
||||
t = token.strip()
|
||||
if not t.startswith('@'):
|
||||
return None
|
||||
if t == "@*":
|
||||
return [] # special sentinel: caller interprets as "all"
|
||||
|
||||
selector = t[1:].strip()
|
||||
if not selector:
|
||||
return None
|
||||
if selector.startswith("{") and selector.endswith("}"):
|
||||
selector = selector[1:-1].strip()
|
||||
|
||||
parts = [p.strip() for p in selector.split(",") if p.strip()]
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
indices_1based: set[int] = set()
|
||||
for part in parts:
|
||||
try:
|
||||
if "-" in part:
|
||||
start_s, end_s = part.split("-", 1)
|
||||
start = int(start_s.strip())
|
||||
end = int(end_s.strip())
|
||||
if start <= 0 or end <= 0 or start > end:
|
||||
return None
|
||||
for i in range(start, end + 1):
|
||||
indices_1based.add(i)
|
||||
else:
|
||||
num = int(part)
|
||||
if num <= 0:
|
||||
return None
|
||||
indices_1based.add(num)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return sorted(i - 1 for i in indices_1based)
|
||||
|
||||
|
||||
def _resolve_items_from_at(token: str) -> Optional[list[Any]]:
|
||||
"""Resolve @ selection token into actual items from the current result context."""
|
||||
items = ctx.get_last_result_items()
|
||||
if not items:
|
||||
return None
|
||||
parsed = _parse_at_selection(token)
|
||||
if parsed is None:
|
||||
return None
|
||||
if token.strip() == "@*":
|
||||
return list(items)
|
||||
selected: list[Any] = []
|
||||
for idx in parsed:
|
||||
if 0 <= idx < len(items):
|
||||
selected.append(items[idx])
|
||||
return selected
|
||||
|
||||
|
||||
def _extract_hash_and_store(item: Any) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Extract (hash_hex, store) from a result item (dict/object)."""
|
||||
try:
|
||||
h = get_field(item, "hash_hex") or get_field(item, "hash") or get_field(item, "file_hash")
|
||||
s = get_field(item, "store")
|
||||
|
||||
hash_norm = _normalise_hash_hex(str(h) if h is not None else None)
|
||||
|
||||
store_norm: Optional[str]
|
||||
if s is None:
|
||||
store_norm = None
|
||||
else:
|
||||
store_norm = str(s).strip()
|
||||
if not store_norm:
|
||||
store_norm = None
|
||||
|
||||
return hash_norm, store_norm
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _hydrus_hash_exists(hydrus_client: Any, hash_hex: str) -> bool:
|
||||
"""Best-effort check whether a hash exists in the connected Hydrus backend."""
|
||||
try:
|
||||
if hydrus_client is None or not hasattr(hydrus_client, "fetch_file_metadata"):
|
||||
return False
|
||||
payload = hydrus_client.fetch_file_metadata(
|
||||
hashes=[hash_hex],
|
||||
include_service_keys_to_tags=False,
|
||||
include_file_url=False,
|
||||
include_duration=False,
|
||||
include_size=False,
|
||||
include_mime=False,
|
||||
include_notes=False,
|
||||
)
|
||||
meta = payload.get("metadata") if isinstance(payload, dict) else None
|
||||
return bool(isinstance(meta, list) and meta)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_king_reference(king_arg: str) -> Optional[str]:
|
||||
"""Resolve a king reference like '@4' to its actual hash or path.
|
||||
|
||||
Supports:
|
||||
- Direct hash: '0123456789abcdef...' (64 chars)
|
||||
- Selection reference: '@4' (resolves from pipeline context)
|
||||
|
||||
Returns:
|
||||
- For Hydrus items: normalized hash
|
||||
- For local storage items: file path
|
||||
- None if not found
|
||||
"""Resolve a king reference like '@4' to its actual hash.
|
||||
|
||||
Store/hash mode intentionally avoids file-path dependency.
|
||||
"""
|
||||
if not king_arg:
|
||||
return None
|
||||
@@ -89,53 +291,30 @@ def _resolve_king_reference(king_arg: str) -> Optional[str]:
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
# Try to resolve as @N selection from pipeline context
|
||||
# Try to resolve as @ selection from pipeline context
|
||||
if king_arg.startswith('@'):
|
||||
try:
|
||||
# Get the result items from the pipeline context
|
||||
from pipeline import get_last_result_items
|
||||
items = get_last_result_items()
|
||||
if not items:
|
||||
log(f"Cannot resolve {king_arg}: no search results in context", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Parse @N to get the index (1-based)
|
||||
index_str = king_arg[1:] # Remove '@'
|
||||
index = int(index_str) - 1 # Convert to 0-based
|
||||
|
||||
if 0 <= index < len(items):
|
||||
item = items[index]
|
||||
|
||||
# Try to extract hash from the item (could be dict or object)
|
||||
item_hash = (
|
||||
get_field(item, 'hash_hex')
|
||||
or get_field(item, 'hash')
|
||||
or get_field(item, 'file_hash')
|
||||
)
|
||||
|
||||
if item_hash:
|
||||
normalized = _normalise_hash_hex(item_hash)
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
# If no hash, try to get file path (for local storage)
|
||||
file_path = (
|
||||
get_field(item, 'file_path')
|
||||
or get_field(item, 'path')
|
||||
or get_field(item, 'target')
|
||||
)
|
||||
|
||||
if file_path:
|
||||
return str(file_path)
|
||||
|
||||
log(f"Item {king_arg} has no hash or path information", file=sys.stderr)
|
||||
return None
|
||||
else:
|
||||
log(f"Index {king_arg} out of range", file=sys.stderr)
|
||||
return None
|
||||
except (ValueError, IndexError) as e:
|
||||
log(f"Cannot resolve {king_arg}: {e}", file=sys.stderr)
|
||||
selected = _resolve_items_from_at(king_arg)
|
||||
if not selected:
|
||||
log(f"Cannot resolve {king_arg}: no selection context", file=sys.stderr)
|
||||
return None
|
||||
if len(selected) != 1:
|
||||
log(f"{king_arg} selects {len(selected)} items; -king requires exactly 1", file=sys.stderr)
|
||||
return None
|
||||
|
||||
item = selected[0]
|
||||
item_hash = (
|
||||
get_field(item, 'hash_hex')
|
||||
or get_field(item, 'hash')
|
||||
or get_field(item, 'file_hash')
|
||||
)
|
||||
|
||||
if item_hash:
|
||||
normalized = _normalise_hash_hex(str(item_hash))
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
log(f"Item {king_arg} has no hash information", file=sys.stderr)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
@@ -208,7 +387,10 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Parse arguments using CMDLET spec
|
||||
parsed = parse_cmdlet_args(_args, CMDLET)
|
||||
arg_path: Optional[Path] = None
|
||||
override_store = parsed.get("store")
|
||||
override_hash = parsed.get("hash")
|
||||
king_arg = parsed.get("king")
|
||||
alt_arg = parsed.get("alt")
|
||||
rel_type = parsed.get("type", "alt")
|
||||
|
||||
raw_path = parsed.get("path")
|
||||
@@ -221,6 +403,45 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Handle @N selection which creates a list
|
||||
# Use normalize_result_input to handle both single items and lists
|
||||
items_to_process = normalize_result_input(result)
|
||||
|
||||
# Allow selecting alt items directly from the last table via -alt @...
|
||||
# This enables: add-relationship -king @1 -alt @3-5
|
||||
if alt_arg:
|
||||
alt_text = str(alt_arg).strip()
|
||||
resolved_alt_items: list[Any] = []
|
||||
if alt_text.startswith('@'):
|
||||
selected = _resolve_items_from_at(alt_text)
|
||||
if not selected:
|
||||
log(f"Failed to resolve -alt {alt_text}: no selection context", file=sys.stderr)
|
||||
return 1
|
||||
resolved_alt_items = selected
|
||||
else:
|
||||
# Treat as comma/semicolon-separated list of hashes
|
||||
parts = [p.strip() for p in alt_text.replace(";", ",").split(",") if p.strip()]
|
||||
hashes = [h for h in (_normalise_hash_hex(p) for p in parts) if h]
|
||||
if not hashes:
|
||||
log("Invalid -alt value (expected @ selection or 64-hex sha256 hash list)", file=sys.stderr)
|
||||
return 1
|
||||
if not override_store:
|
||||
log("-store is required when using -alt with a raw hash list", file=sys.stderr)
|
||||
return 1
|
||||
resolved_alt_items = [{"hash": h, "store": str(override_store)} for h in hashes]
|
||||
items_to_process = normalize_result_input(resolved_alt_items)
|
||||
|
||||
# Allow explicit -hash operation (store/hash-first)
|
||||
if (not items_to_process) and override_hash:
|
||||
# Support comma-separated hashes
|
||||
raw = str(override_hash)
|
||||
parts = [p.strip() for p in raw.replace(";", ",").split(",")]
|
||||
hashes = [h for h in (_normalise_hash_hex(p) for p in parts) if h]
|
||||
if not hashes:
|
||||
log("Invalid -hash value (expected 64-hex sha256)", file=sys.stderr)
|
||||
return 1
|
||||
# Use the selected/override store; required in this mode
|
||||
if not override_store:
|
||||
log("-store is required when using -hash without piped items", file=sys.stderr)
|
||||
return 1
|
||||
items_to_process = [{"hash": h, "store": str(override_store)} for h in hashes]
|
||||
|
||||
if not items_to_process and not arg_path:
|
||||
log("No items provided to add-relationship (no piped result and no -path)", file=sys.stderr)
|
||||
@@ -230,42 +451,242 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if not items_to_process and arg_path:
|
||||
items_to_process = [{"file_path": arg_path}]
|
||||
|
||||
# Import local storage utilities
|
||||
from API.folder import LocalLibrarySearchOptimizer
|
||||
from config import get_local_storage_path
|
||||
|
||||
local_storage_path = get_local_storage_path(config) if config else None
|
||||
|
||||
# Check if any items have Hydrus hashes (file_hash or hash_hex fields)
|
||||
has_hydrus_hashes = any(
|
||||
(isinstance(item, dict) and (item.get('hash_hex') or item.get('hash')))
|
||||
or (hasattr(item, 'hash_hex') or hasattr(item, 'hash'))
|
||||
for item in items_to_process
|
||||
)
|
||||
|
||||
# Only try to initialize Hydrus if we actually have Hydrus hashes to work with
|
||||
# Resolve the king reference once (if provided)
|
||||
king_hash: Optional[str] = None
|
||||
king_store: Optional[str] = None
|
||||
if king_arg:
|
||||
king_text = str(king_arg).strip()
|
||||
if king_text.startswith('@'):
|
||||
selected = _resolve_items_from_at(king_text)
|
||||
if not selected:
|
||||
log(f"Cannot resolve {king_text}: no selection context", file=sys.stderr)
|
||||
return 1
|
||||
if len(selected) != 1:
|
||||
log(f"{king_text} selects {len(selected)} items; -king requires exactly 1", file=sys.stderr)
|
||||
return 1
|
||||
king_hash, king_store = _extract_hash_and_store(selected[0])
|
||||
if not king_hash:
|
||||
log(f"Item {king_text} has no hash information", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
king_hash = _resolve_king_reference(king_text)
|
||||
if not king_hash:
|
||||
log(f"Failed to resolve king argument: {king_text}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Decide target store: override_store > (king store + piped item stores) (must be consistent)
|
||||
store_name: Optional[str] = str(override_store).strip() if override_store else None
|
||||
if not store_name:
|
||||
stores = set()
|
||||
if king_store:
|
||||
stores.add(str(king_store))
|
||||
for item in items_to_process:
|
||||
s = get_field(item, "store")
|
||||
if s:
|
||||
stores.add(str(s))
|
||||
if len(stores) == 1:
|
||||
store_name = next(iter(stores))
|
||||
elif len(stores) > 1:
|
||||
log("Multiple stores detected (king/alt across stores); use -store and ensure all selections are from the same store", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Enforce same-store relationships when store context is available.
|
||||
if king_store and store_name and str(king_store) != str(store_name):
|
||||
log(f"Cross-store relationship blocked: king is in store '{king_store}' but -store is '{store_name}'", file=sys.stderr)
|
||||
return 1
|
||||
if store_name:
|
||||
for item in items_to_process:
|
||||
s = get_field(item, "store")
|
||||
if s and str(s) != str(store_name):
|
||||
log(f"Cross-store relationship blocked: alt item store '{s}' != '{store_name}'", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Resolve backend for store/hash operations
|
||||
backend = None
|
||||
is_folder_store = False
|
||||
store_root: Optional[Path] = None
|
||||
if store_name:
|
||||
try:
|
||||
store = Store(config)
|
||||
backend = store[str(store_name)]
|
||||
loc = getattr(backend, "location", None)
|
||||
if callable(loc):
|
||||
is_folder_store = True
|
||||
store_root = Path(str(loc()))
|
||||
except Exception:
|
||||
backend = None
|
||||
is_folder_store = False
|
||||
store_root = None
|
||||
|
||||
# Select Hydrus client:
|
||||
# - If a store is specified and maps to a HydrusNetwork backend, use that backend's client.
|
||||
# - If no store is specified, use the default Hydrus client.
|
||||
# NOTE: When a store is specified, we do not fall back to a global/default Hydrus client.
|
||||
hydrus_client = None
|
||||
if has_hydrus_hashes:
|
||||
if store_name and (not is_folder_store) and backend is not None:
|
||||
try:
|
||||
candidate = getattr(backend, "_client", None)
|
||||
if candidate is not None and hasattr(candidate, "set_relationship"):
|
||||
hydrus_client = candidate
|
||||
except Exception:
|
||||
hydrus_client = None
|
||||
elif not store_name:
|
||||
try:
|
||||
hydrus_client = hydrus_wrapper.get_client(config)
|
||||
except Exception as exc:
|
||||
log(f"Hydrus unavailable, will use local storage: {exc}", file=sys.stderr)
|
||||
|
||||
# Use local storage if it's available and either Hydrus is not available or items are local files
|
||||
use_local_storage = local_storage_path and (not has_hydrus_hashes or (arg_path and arg_path.exists()))
|
||||
except Exception:
|
||||
hydrus_client = None
|
||||
|
||||
# Resolve the king reference once (if provided)
|
||||
king_hash = None
|
||||
if king_arg:
|
||||
# Resolve the king reference (could be @4 or a direct hash)
|
||||
king_hash = _resolve_king_reference(king_arg)
|
||||
if not king_hash:
|
||||
log(f"Failed to resolve king argument: {king_arg}", file=sys.stderr)
|
||||
return 1
|
||||
# Sidecar/tag import fallback DB root (legacy): if a folder store is selected, use it;
|
||||
# otherwise fall back to configured local storage path.
|
||||
from config import get_local_storage_path
|
||||
|
||||
local_storage_root: Optional[Path] = None
|
||||
if store_root is not None:
|
||||
local_storage_root = store_root
|
||||
else:
|
||||
try:
|
||||
p = get_local_storage_path(config) if config else None
|
||||
local_storage_root = Path(p) if p else None
|
||||
except Exception:
|
||||
local_storage_root = None
|
||||
|
||||
use_local_storage = local_storage_root is not None
|
||||
|
||||
if king_hash:
|
||||
log(f"Using king hash: {king_hash}", file=sys.stderr)
|
||||
|
||||
# Process each item in the list
|
||||
for item_idx, item in enumerate(items_to_process):
|
||||
# If -path is provided, try reading relationship tags from its sidecar and persisting them.
|
||||
if arg_path is not None and arg_path.exists() and arg_path.is_file():
|
||||
try:
|
||||
sidecar_path = find_sidecar(arg_path)
|
||||
if sidecar_path is not None and sidecar_path.exists():
|
||||
_, tags, _ = read_sidecar(sidecar_path)
|
||||
relationship_tags = [t for t in (tags or []) if isinstance(t, str) and t.lower().startswith("relationship:")]
|
||||
if relationship_tags:
|
||||
code = _apply_relationships_from_tags(
|
||||
relationship_tags,
|
||||
hydrus_client=hydrus_client,
|
||||
use_local_storage=use_local_storage,
|
||||
local_storage_path=local_storage_root,
|
||||
config=config,
|
||||
)
|
||||
return 0 if code == 0 else 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If piped items include relationship tags, persist them (one pass) then exit.
|
||||
try:
|
||||
rel_tags_from_pipe: list[str] = []
|
||||
for item in items_to_process:
|
||||
tags_val = None
|
||||
if isinstance(item, dict):
|
||||
tags_val = item.get("tag") or item.get("tags")
|
||||
else:
|
||||
tags_val = getattr(item, "tag", None)
|
||||
if isinstance(tags_val, list):
|
||||
rel_tags_from_pipe.extend([t for t in tags_val if isinstance(t, str) and t.lower().startswith("relationship:")])
|
||||
elif isinstance(tags_val, str) and tags_val.lower().startswith("relationship:"):
|
||||
rel_tags_from_pipe.append(tags_val)
|
||||
|
||||
if rel_tags_from_pipe:
|
||||
code = _apply_relationships_from_tags(
|
||||
rel_tags_from_pipe,
|
||||
hydrus_client=hydrus_client,
|
||||
use_local_storage=use_local_storage,
|
||||
local_storage_path=local_storage_root,
|
||||
config=config,
|
||||
)
|
||||
return 0 if code == 0 else 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# STORE/HASH MODE (preferred): use -store and hashes; do not require file paths.
|
||||
if store_name and is_folder_store and store_root is not None:
|
||||
try:
|
||||
with API_folder_store(store_root) as db:
|
||||
# Mode 1: no explicit king -> first is king, rest are alts
|
||||
if not king_hash:
|
||||
first_hash = None
|
||||
for item in items_to_process:
|
||||
h, item_store = _extract_hash_and_store(item)
|
||||
if item_store and store_name and str(item_store) != str(store_name):
|
||||
log(f"Cross-store relationship blocked: item store '{item_store}' != '{store_name}'", file=sys.stderr)
|
||||
return 1
|
||||
if not h:
|
||||
continue
|
||||
if not first_hash:
|
||||
first_hash = h
|
||||
continue
|
||||
# directional alt -> king by default for local DB
|
||||
bidirectional = str(rel_type).lower() != "alt"
|
||||
db.set_relationship_by_hash(h, first_hash, str(rel_type), bidirectional=bidirectional)
|
||||
return 0
|
||||
|
||||
# Mode 2: explicit king
|
||||
for item in items_to_process:
|
||||
h, item_store = _extract_hash_and_store(item)
|
||||
if item_store and store_name and str(item_store) != str(store_name):
|
||||
log(f"Cross-store relationship blocked: item store '{item_store}' != '{store_name}'", file=sys.stderr)
|
||||
return 1
|
||||
if not h or h == king_hash:
|
||||
continue
|
||||
bidirectional = str(rel_type).lower() != "alt"
|
||||
db.set_relationship_by_hash(h, king_hash, str(rel_type), bidirectional=bidirectional)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log(f"Failed to set store relationships: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if store_name and (not is_folder_store):
|
||||
# Hydrus store/hash mode
|
||||
if hydrus_client is None:
|
||||
log("Hydrus client unavailable for this store", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Verify hashes exist in this Hydrus backend to prevent cross-store edges.
|
||||
if king_hash and (not _hydrus_hash_exists(hydrus_client, king_hash)):
|
||||
log(f"Cross-store relationship blocked: king hash not found in store '{store_name}'", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Mode 1: first is king
|
||||
if not king_hash:
|
||||
first_hash = None
|
||||
for item in items_to_process:
|
||||
h, item_store = _extract_hash_and_store(item)
|
||||
if item_store and store_name and str(item_store) != str(store_name):
|
||||
log(f"Cross-store relationship blocked: item store '{item_store}' != '{store_name}'", file=sys.stderr)
|
||||
return 1
|
||||
if not h:
|
||||
continue
|
||||
if not first_hash:
|
||||
first_hash = h
|
||||
if not _hydrus_hash_exists(hydrus_client, first_hash):
|
||||
log(f"Cross-store relationship blocked: hash not found in store '{store_name}'", file=sys.stderr)
|
||||
return 1
|
||||
continue
|
||||
if h != first_hash:
|
||||
if not _hydrus_hash_exists(hydrus_client, h):
|
||||
log(f"Cross-store relationship blocked: hash not found in store '{store_name}'", file=sys.stderr)
|
||||
return 1
|
||||
hydrus_client.set_relationship(h, first_hash, str(rel_type))
|
||||
return 0
|
||||
|
||||
# Mode 2: explicit king
|
||||
for item in items_to_process:
|
||||
h, item_store = _extract_hash_and_store(item)
|
||||
if item_store and store_name and str(item_store) != str(store_name):
|
||||
log(f"Cross-store relationship blocked: item store '{item_store}' != '{store_name}'", file=sys.stderr)
|
||||
return 1
|
||||
if not h or h == king_hash:
|
||||
continue
|
||||
if not _hydrus_hash_exists(hydrus_client, h):
|
||||
log(f"Cross-store relationship blocked: hash not found in store '{store_name}'", file=sys.stderr)
|
||||
return 1
|
||||
hydrus_client.set_relationship(h, king_hash, str(rel_type))
|
||||
return 0
|
||||
|
||||
# Process each item in the list (legacy path-based mode)
|
||||
for item in items_to_process:
|
||||
# Extract hash and path from current item
|
||||
file_hash = None
|
||||
file_path_from_result = None
|
||||
@@ -277,9 +698,83 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
file_hash = getattr(item, "hash_hex", None) or getattr(item, "hash", None)
|
||||
file_path_from_result = getattr(item, "file_path", None) or getattr(item, "path", None)
|
||||
|
||||
# Legacy LOCAL STORAGE MODE: Handle relationships for local files
|
||||
# (kept for -path sidecar workflows; store/hash mode above is preferred)
|
||||
from API.folder import LocalLibrarySearchOptimizer
|
||||
from config import get_local_storage_path
|
||||
local_storage_path = get_local_storage_path(config) if config else None
|
||||
use_local_storage = bool(local_storage_path)
|
||||
local_storage_root: Optional[Path] = None
|
||||
if local_storage_path:
|
||||
try:
|
||||
local_storage_root = Path(local_storage_path)
|
||||
except Exception:
|
||||
local_storage_root = None
|
||||
|
||||
if use_local_storage and file_path_from_result:
|
||||
try:
|
||||
file_path_obj = Path(str(file_path_from_result))
|
||||
except Exception as exc:
|
||||
log(f"Local storage error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not file_path_obj.exists():
|
||||
# Not a local file; fall through to Hydrus if possible.
|
||||
file_path_obj = None
|
||||
|
||||
if file_path_obj is not None:
|
||||
try:
|
||||
if local_storage_root is None:
|
||||
log("Local storage path unavailable", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with LocalLibrarySearchOptimizer(local_storage_root) as opt:
|
||||
if opt.db is None:
|
||||
log("Local storage DB unavailable", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if king_hash:
|
||||
normalized_king = _normalise_hash_hex(str(king_hash))
|
||||
if not normalized_king:
|
||||
log(f"King hash invalid: {king_hash}", file=sys.stderr)
|
||||
return 1
|
||||
king_file_path = opt.db.search_hash(normalized_king)
|
||||
if not king_file_path:
|
||||
log(f"King hash not found in local DB: {king_hash}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
bidirectional = str(rel_type).lower() != "alt"
|
||||
opt.db.set_relationship(file_path_obj, king_file_path, rel_type, bidirectional=bidirectional)
|
||||
log(f"Set {rel_type} relationship: {file_path_obj.name} -> {king_file_path.name}", file=sys.stderr)
|
||||
_refresh_relationship_view_if_current(None, str(file_path_obj), str(king_file_path), config)
|
||||
else:
|
||||
# Original behavior: first becomes king, rest become alts
|
||||
try:
|
||||
king_path = ctx.load_value("relationship_king_path")
|
||||
except Exception:
|
||||
king_path = None
|
||||
|
||||
if not king_path:
|
||||
try:
|
||||
ctx.store_value("relationship_king_path", str(file_path_obj))
|
||||
log(f"Established king file: {file_path_obj.name}", file=sys.stderr)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if king_path and king_path != str(file_path_obj):
|
||||
bidirectional = str(rel_type).lower() != "alt"
|
||||
opt.db.set_relationship(file_path_obj, Path(king_path), rel_type, bidirectional=bidirectional)
|
||||
log(f"Set {rel_type} relationship: {file_path_obj.name} -> {Path(king_path).name}", file=sys.stderr)
|
||||
_refresh_relationship_view_if_current(None, str(file_path_obj), str(king_path), config)
|
||||
except Exception as exc:
|
||||
log(f"Local storage error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
continue
|
||||
|
||||
# PIPELINE MODE with Hydrus: Track relationships using hash
|
||||
if file_hash and hydrus_client:
|
||||
file_hash = _normalise_hash_hex(file_hash)
|
||||
file_hash = _normalise_hash_hex(str(file_hash) if file_hash is not None else None)
|
||||
if not file_hash:
|
||||
log("Invalid file hash format", file=sys.stderr)
|
||||
return 1
|
||||
@@ -292,7 +787,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
f"[add-relationship] Set {rel_type} relationship: {file_hash} <-> {king_hash}",
|
||||
file=sys.stderr
|
||||
)
|
||||
_refresh_relationship_view_if_current(file_hash, file_path_from_result, king_hash, config)
|
||||
_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
|
||||
@@ -320,58 +815,12 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
f"[add-relationship] Set {rel_type} relationship: {file_hash} <-> {existing_king}",
|
||||
file=sys.stderr
|
||||
)
|
||||
_refresh_relationship_view_if_current(file_hash, file_path_from_result, existing_king, config)
|
||||
_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
|
||||
|
||||
# LOCAL STORAGE MODE: Handle relationships for local files
|
||||
elif use_local_storage and file_path_from_result:
|
||||
try:
|
||||
file_path_obj = Path(str(file_path_from_result))
|
||||
|
||||
if not file_path_obj.exists():
|
||||
log(f"File not found: {file_path_obj}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if king_hash:
|
||||
# king_hash is a file path from _resolve_king_reference (or a Hydrus hash)
|
||||
king_file_path = Path(str(king_hash)) if king_hash else None
|
||||
if king_file_path and king_file_path.exists():
|
||||
with LocalLibrarySearchOptimizer(local_storage_path) as db:
|
||||
db.set_relationship(file_path_obj, king_file_path, rel_type)
|
||||
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:
|
||||
log(f"King file not found or invalid: {king_hash}", file=sys.stderr)
|
||||
return 1
|
||||
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 # Move to next item
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if king_path and king_path != str(file_path_obj):
|
||||
try:
|
||||
with LocalLibrarySearchOptimizer(local_storage_path) as db:
|
||||
db.set_relationship(file_path_obj, Path(king_path), rel_type)
|
||||
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"Failed to set relationship: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as exc:
|
||||
log(f"Local storage error: {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
|
||||
|
||||
@@ -395,12 +844,12 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
# Build Hydrus client
|
||||
try:
|
||||
client = hydrus_wrapper.get_client(config)
|
||||
hydrus_client = hydrus_wrapper.get_client(config)
|
||||
except Exception as exc:
|
||||
log(f"Hydrus client unavailable: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if client is None:
|
||||
if hydrus_client is None:
|
||||
log("Hydrus client unavailable", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user