refactored and updated tags cmdlet and hydrusnetwork interaction plugin features

This commit is contained in:
2026-05-04 15:08:18 -07:00
parent 5534812426
commit bca85defa4
17 changed files with 380 additions and 175 deletions
+99 -24
View File
@@ -13,6 +13,7 @@ from SYS.result_publication import publish_result_table
from SYS import models
from SYS import pipeline as ctx
from . import _shared as sh
from Store import Store # retained for test monkeypatch compatibility
normalize_result_input = sh.normalize_result_input
filter_results_by_temp = sh.filter_results_by_temp
@@ -28,7 +29,6 @@ parse_cmdlet_args = sh.parse_cmdlet_args
collapse_namespace_tag = sh.collapse_namespace_tag
should_show_help = sh.should_show_help
get_field = sh.get_field
from Store import Store
_FIELD_NAME_RE = re.compile(r"^[A-Za-z0-9_]+$")
@@ -663,8 +663,40 @@ class Add_Tag(Cmdlet):
total_added = 0
total_modified = 0
unresolved_template_count = 0
store_registry: Any = None
store_registry = Store(config, suppress_debug=True)
def _resolve_backend(name: Optional[str]) -> tuple[Any | None, Any, Exception | None]:
nonlocal store_registry
backend_name = str(name or "").strip()
if not backend_name:
return None, store_registry, KeyError("Missing store name")
if backend_name in _backend_instance_cache:
return _backend_instance_cache[backend_name], store_registry, None
try:
backend, registry, exc = sh.get_preferred_store_backend(
config,
backend_name,
store_registry=store_registry,
suppress_debug=True,
)
except TypeError as exc2:
# Tests may monkeypatch get_store_backend with a reduced signature.
if "store_registry" in str(exc2):
backend, registry, exc = sh.get_store_backend(
config,
backend_name,
suppress_debug=True,
)
else:
raise
if registry is not None:
store_registry = registry
if backend is not None:
_backend_instance_cache[backend_name] = backend
return backend, store_registry, exc
pending_bulk_add: Dict[tuple[int, tuple[str, ...], tuple[str, ...]], Dict[str, Any]] = {}
_backend_instance_cache: Dict[str, Any] = {}
extract_matched_items = 0
extract_no_match_items = 0
@@ -697,9 +729,8 @@ class Add_Tag(Cmdlet):
is_known_backend = False
try:
is_known_backend = bool(store_name_str) and store_registry.is_available(
store_name_str
)
backend_probe, store_registry, _probe_exc = _resolve_backend(store_name_str)
is_known_backend = backend_probe is not None
except Exception:
pass
@@ -864,7 +895,7 @@ class Add_Tag(Cmdlet):
# If it's not a known backend and we didn't handle it above as a local/pipeline
# metadata edit, then it's an error.
log(
f"[add_tag] Error: Unknown store '{store_name_str}'. Available: {store_registry.list_backends()}",
f"[add_tag] Error: Unknown store '{store_name_str}'",
file=sys.stderr,
)
return 1
@@ -883,12 +914,7 @@ class Add_Tag(Cmdlet):
ctx.emit(res)
continue
backend, store_registry, exc = sh.get_store_backend(
config,
str(store_name),
store_registry=store_registry,
suppress_debug=True,
)
backend, store_registry, exc = _resolve_backend(str(store_name))
if backend is None:
log(
f"[add_tag] Error: Unknown store '{store_name}': {exc}",
@@ -1027,18 +1053,40 @@ class Add_Tag(Cmdlet):
tags_to_add = []
merged_tags = list(existing_tag_list)
try:
ok_add = backend.add_tag(
resolved_hash,
item_tag_to_add,
config=config,
existing_tags=existing_tag_list,
)
if not ok_add:
log("[add_tag] Warning: Store rejected tag update", file=sys.stderr)
except Exception as exc:
log(f"[add_tag] Warning: Failed adding tag: {exc}", file=sys.stderr)
ok_add = False
queued_bulk = False
ok_add = False
add_tags_bulk_fn = getattr(backend, "add_tags_bulk", None)
if tags_to_add and callable(add_tags_bulk_fn):
add_key = tuple(sorted({str(t).strip().lower() for t in tags_to_add if str(t).strip()}))
remove_key = tuple(sorted({str(t).strip().lower() for t in tags_to_remove if str(t).strip()}))
if add_key:
batch_key = (id(backend), add_key, remove_key)
bucket = pending_bulk_add.get(batch_key)
if bucket is None:
bucket = {
"backend": backend,
"add_tags": list(add_key),
"remove_tags": list(remove_key),
"hashes": [],
}
pending_bulk_add[batch_key] = bucket
bucket["hashes"].append(resolved_hash)
queued_bulk = True
ok_add = True
if not queued_bulk:
try:
ok_add = backend.add_tag(
resolved_hash,
item_tag_to_add,
config=config,
existing_tags=existing_tag_list,
)
if not ok_add:
log("[add_tag] Warning: Store rejected tag update", file=sys.stderr)
except Exception as exc:
log(f"[add_tag] Warning: Failed adding tag: {exc}", file=sys.stderr)
ok_add = False
if ok_add and merged_tags:
refreshed_list = list(merged_tags)
@@ -1075,6 +1123,33 @@ class Add_Tag(Cmdlet):
ctx.emit(res)
for bucket in pending_bulk_add.values():
backend = bucket.get("backend")
add_tags_for_batch = list(bucket.get("add_tags") or [])
remove_tags_for_batch = list(bucket.get("remove_tags") or [])
hashes_for_batch = [str(h).strip().lower() for h in (bucket.get("hashes") or []) if str(h).strip()]
if backend is None or not hashes_for_batch:
continue
batch_items = [(h, list(add_tags_for_batch), list(remove_tags_for_batch)) for h in hashes_for_batch]
add_tags_bulk_fn = getattr(backend, "add_tags_bulk", None)
applied = False
if callable(add_tags_bulk_fn):
try:
applied = bool(add_tags_bulk_fn(batch_items))
except Exception:
applied = False
if applied:
continue
# Fallback path: retain correctness if backend bulk call fails.
for h in hashes_for_batch:
try:
backend.add_tag(h, list(add_tags_for_batch), config=config)
except Exception as exc:
log(f"[add_tag] Warning: Failed fallback add_tag for {h}: {exc}", file=sys.stderr)
log(
f"[add_tag] Added {total_added} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)",
file=sys.stderr,
+112 -31
View File
@@ -551,21 +551,44 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
log("Requires at least one tag argument when deleting from files")
return 1
# Process each item
# Collect (store_name, tags_key) -> {backend, hashes, items} groups for bulk dispatch.
# Items that need per-item existing-tag resolution (e.g. namespace-wildcard expand)
# are handled individually; static literal tag sets are batched.
_backend_cache: Dict[str, Any] = {}
# If we have tags from @ syntax (e.g. delete-tag @{1,2}), we ignore the piped result for tag selection
# but we might need the piped result for the file context if @ selection was from a Tag table
# Actually, the @ selection logic above already extracted tags.
def _get_backend(store_name_str: str) -> Any | None:
if store_name_str in _backend_cache:
return _backend_cache[store_name_str]
try:
backend, _reg, _exc = sh.get_preferred_store_backend(
config, store_name_str, suppress_debug=True
)
except TypeError:
backend, _reg, _exc = sh.get_store_backend(
config, store_name_str, suppress_debug=True
)
if backend is not None:
_backend_cache[store_name_str] = backend
return backend
# Bucket: key = (store_name, sorted_tag_tuple) → list of (hash, item, path)
bulk_groups: Dict[tuple[str, tuple[str, ...]], list[tuple[str, Any, str | None]]] = {}
items_needing_individual: list[tuple[Any, str, str | None, str]] = []
tags_has_namespace_wildcard = any(
(isinstance(t, str) and ":" in t and not t.split(":", 1)[1].strip())
for t in tags_arg
)
tags_has_template = any(
(isinstance(t, str) and "#(" in t)
for t in tags_arg
)
needs_individual = tags_has_namespace_wildcard or tags_has_template
# Process items from pipe (or single result)
# If args are provided, they are the tags to delete from EACH item
# If items are TagItems and no args, the tag to delete is the item itself
for item in items_to_process:
tags_to_delete: list[str] = []
item_hash = (
normalize_hash(override_hash)
if override_hash else normalize_hash(get_field(item,
"hash"))
if override_hash else normalize_hash(get_field(item, "hash"))
)
item_path = get_field(item, "path") or get_field(item, "target")
item_store = override_store or get_field(item, "store")
@@ -575,27 +598,74 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
tags_to_delete = tags_arg
else:
tag_name = get_field(item, "tag_name")
if tag_name:
tags_to_delete = [str(tag_name)]
tags_to_delete = [str(tag_name)] if tag_name else []
else:
if tags_arg:
tags_to_delete = tags_arg
else:
continue
tags_to_delete = tags_arg or []
if tags_to_delete:
if _process_deletion(tags_to_delete,
item_hash,
item_path,
item_store,
config,
result=item):
success_count += 1
if not tags_to_delete or not item_hash or not item_store:
continue
store_str = str(item_store)
# Namespace wildcards (e.g. "album:") and template tags (e.g. "title:#(track)")
# need existing tags to expand — handle individually.
if needs_individual:
items_needing_individual.append((item, item_hash, item_path, store_str))
continue
tag_key = tuple(sorted(str(t).strip().lower() for t in tags_to_delete if str(t).strip()))
bulk_groups.setdefault((store_str, tag_key), []).append((item_hash, item, item_path))
# --- Bulk dispatch ---
for (store_str, tag_key), entries in bulk_groups.items():
backend = _get_backend(store_str)
if backend is None:
log(f"Store '{store_str}' not found", file=sys.stderr)
continue
hashes = [h for h, _item, _path in entries]
tag_list = list(tag_key)
bulk_fn = getattr(backend, "delete_tags_bulk", None)
bulk_ok = False
if callable(bulk_fn):
try:
bulk_ok = bool(bulk_fn([(h, tag_list) for h in hashes]))
except Exception:
bulk_ok = False
if not bulk_ok:
# fallback: individual delete_tag per hash
for h in hashes:
try:
backend.delete_tag(h, tag_list, config=config)
except Exception:
pass
success_count += 1
delete_set = {t.lower() for t in tag_key}
for h, item, path in entries:
# Update in-memory tag list on each result
old_tags = [str(t) for t in (get_field(item, "tag") or []) if t]
new_tags = [t for t in old_tags if t.strip().casefold() not in delete_set]
_set_result_tags(item, new_tags)
title_value = extract_title_tag_value(new_tags)
if title_value:
_apply_title_to_result(item, title_value)
_refresh_result_table_tags(new_tags, h, store_str, path)
try:
ctx.emit(item)
except Exception:
pass
# --- Individual dispatch (namespace wildcards) ---
for item, item_hash, item_path, store_str in items_needing_individual:
if _process_deletion(tags_arg, item_hash, item_path, store_str, config, result=item):
success_count += 1
try:
ctx.emit(item)
except Exception:
pass
if success_count > 0:
return 0
return 1
@@ -631,13 +701,28 @@ def _process_deletion(
)
return False
def _fetch_existing_tags() -> list[str]:
def _resolve_backend() -> tuple[Any | None, Any, Exception | None]:
try:
backend, _store_registry, _exc = sh.get_store_backend(
return sh.get_preferred_store_backend(
config,
store_name,
suppress_debug=True,
)
except TypeError as exc:
# Some tests monkeypatch get_store_backend with a reduced signature.
# Fall back so runtime still prefers plugin instance resolution while
# preserving compatibility with those injected callables.
if "store_registry" in str(exc):
return sh.get_store_backend(
config,
store_name,
suppress_debug=True,
)
raise
def _fetch_existing_tags() -> list[str]:
try:
backend, _store_registry, _exc = _resolve_backend()
if backend is None:
return []
existing, _src = backend.get_tag(resolved_hash, config=config)
@@ -687,11 +772,7 @@ def _process_deletion(
return False
try:
backend, _store_registry, exc = sh.get_store_backend(
config,
store_name,
suppress_debug=True,
)
backend, _store_registry, exc = _resolve_backend()
if backend is None:
raise exc or KeyError(store_name)
ok = backend.delete_tag(resolved_hash, list(tags), config=config)
+76 -22
View File
@@ -18,7 +18,7 @@ from urllib.request import pathname2url
from SYS import pipeline as ctx
from . import _shared as sh
from SYS.item_accessors import get_result_title
from SYS.logger import log, debug
from SYS.logger import log, debug, debug_panel
from SYS.config import resolve_output_dir
from API.HTTP import _download_direct_file
from SYS.payload_builders import build_file_result_payload
@@ -53,9 +53,18 @@ class Get_File(sh.Cmdlet):
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Export file via hash+store backend."""
debug(f"[get-file] run() called with result type: {type(result)}")
parsed = sh.parse_cmdlet_args(args, self)
debug(f"[get-file] parsed args: {parsed}")
try:
debug_panel(
"get-file",
[
("result_type", type(result).__name__),
("parsed_args", parsed),
],
border_style="cyan",
)
except Exception:
pass
query_hash, query_valid = sh.require_single_hash_query(
parsed.get("query"),
@@ -70,8 +79,6 @@ class Get_File(sh.Cmdlet):
output_path = parsed.get("path")
output_name = parsed.get("name")
debug(f"[get-file] file_hash={file_hash} store_name={store_name}")
if not file_hash:
log(
'Error: No file hash provided (pipe an item or use -query "hash:<sha256>")'
@@ -88,7 +95,19 @@ class Get_File(sh.Cmdlet):
log("Error: Invalid hash format")
return 1
debug(f"[get-file] Getting storage backend: {store_name}")
try:
debug_panel(
"get-file selection",
[
("hash", file_hash),
("instance", store_name),
("output_path", output_path or "<default>"),
("output_name", output_name or "<auto>"),
],
border_style="blue",
)
except Exception:
pass
backend, _store_registry, _exc = sh.get_preferred_store_backend(
config,
@@ -99,17 +118,23 @@ class Get_File(sh.Cmdlet):
log(f"Error: Storage backend '{store_name}' not found", file=sys.stderr)
return 1
debug(f"[get-file] Backend retrieved: {type(backend).__name__}")
# Get file metadata to determine name and extension
debug("[get-file] Getting metadata for hash...")
metadata = backend.get_metadata(file_hash)
if not metadata:
log(f"Error: File metadata not found for hash {file_hash}")
return 1
debug(
f"[get-file] Metadata retrieved: title={metadata.get('title')}, ext={metadata.get('ext')}"
)
try:
debug_panel(
"get-file backend",
[
("backend", type(backend).__name__),
("title", metadata.get("title") or ""),
("ext", metadata.get("ext") or ""),
],
border_style="green",
)
except Exception:
pass
def resolve_display_title() -> str:
candidates = [
@@ -124,17 +149,12 @@ class Get_File(sh.Cmdlet):
return text
return ""
debug(f"[get-file] Calling backend.get_file({file_hash})")
# 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)
debug(f"[get-file] Requesting file from backend (url_hint={want_url})...")
source_path = backend.get_file(file_hash, url=want_url)
debug(f"[get-file] backend.get_file returned: {source_path}")
download_url = None
if isinstance(source_path, str):
if source_path.startswith("http://") or source_path.startswith("https://"):
@@ -142,6 +162,19 @@ class Get_File(sh.Cmdlet):
else:
source_path = Path(source_path)
try:
debug_panel(
"get-file fetch",
[
("url_hint", want_url),
("mode", "browser-url" if download_url else "local-path"),
("source", download_url or source_path or "<missing>"),
],
border_style="magenta",
)
except Exception:
pass
if download_url and output_path is None:
# Hydrus backend returns a URL; open it only when no output path
try:
@@ -149,7 +182,18 @@ class Get_File(sh.Cmdlet):
except Exception as exc:
log(f"Error opening browser: {exc}", file=sys.stderr)
else:
debug(f"Opened in browser: {download_url}", file=sys.stderr)
try:
debug_panel(
"get-file open",
[
("action", "browser-open"),
("url", download_url),
],
file=sys.stderr,
border_style="green",
)
except Exception:
pass
ctx.emit(
build_file_result_payload(
@@ -172,7 +216,6 @@ class Get_File(sh.Cmdlet):
else:
output_dir = resolve_output_dir(config)
debug(f"[get-file] Output dir: {output_dir}")
output_dir.mkdir(parents=True, exist_ok=True)
# Determine output filename (only when exporting)
@@ -202,13 +245,25 @@ class Get_File(sh.Cmdlet):
suggested_filename=filename,
)
dest_path = downloaded.path
debug(f"[get-file] Downloaded remote file to {dest_path}", file=sys.stderr)
else:
dest_path = self._unique_path(output_dir / filename)
# Copy file to destination
debug(f"[get-file] Copying {source_path} -> {dest_path}", file=sys.stderr)
shutil.copy2(source_path, dest_path)
try:
debug_panel(
"get-file export",
[
("mode", "download" if download_url else "copy"),
("destination", dest_path),
("filename", filename),
],
file=sys.stderr,
border_style="green",
)
except Exception:
pass
log(f"Exported: {dest_path}", file=sys.stderr)
# Emit result for pipeline
@@ -221,7 +276,6 @@ class Get_File(sh.Cmdlet):
)
)
debug("[get-file] Completed successfully")
return 0
def _open_file_default(self, path: Path) -> None: