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
+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)