cmdlet refactor

This commit is contained in:
2026-05-04 18:41:01 -07:00
parent 3ce339b3c1
commit 24f983473f
44 changed files with 1320 additions and 309 deletions
+14 -1
View File
@@ -89,7 +89,20 @@ def _load_root_modules() -> None:
def _load_helper_modules() -> None:
# Provider-specific module pre-loading removed; providers are loaded lazily
# through ProviderCore.registry when first referenced.
pass
#
# 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).
for mod in (
".file.add_note",
".file.add_url",
".file.add_relationship",
".metadata.get_note",
".metadata.get_relationship",
):
try:
_import_module(mod, __name__)
except Exception as exc:
print(f"Error importing cmdlet helper '{mod}': {exc}", file=sys.stderr)
def _register_native_commands() -> None:
+7
View File
@@ -1235,6 +1235,13 @@ def run_store_note_batches(
if on_store_error is not None and exc is not None:
on_store_error(store_name, exc)
continue
supports_note_capability = getattr(backend, "supports_note_association", None)
if supports_note_capability is None:
supports_note_capability = hasattr(backend, "set_note")
if not bool(supports_note_capability):
if on_unsupported_store is not None:
on_unsupported_store(store_name)
continue
if not hasattr(backend, "set_note"):
if on_unsupported_store is not None:
on_unsupported_store(store_name)
+3
View File
@@ -0,0 +1,3 @@
"""File action cmdlets package."""
__all__ = []
+132 -25
View File
@@ -19,7 +19,7 @@ from SYS.rich_display import show_available_plugins_panel, show_plugin_config_pa
from SYS.utils_constant import ALL_SUPPORTED_EXTENSIONS
from Store import Store
from API.HTTP import _download_direct_file
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -862,7 +862,7 @@ class Add_File(Cmdlet):
subject = collected_payloads[0] if len(collected_payloads) == 1 else collected_payloads
# Use helper to display items and make them @-selectable
from ._shared import display_and_persist_items
from .._shared import display_and_persist_items
display_and_persist_items(collected_payloads, title="Result", subject=subject)
try:
@@ -911,7 +911,7 @@ class Add_File(Cmdlet):
return None
try:
from cmdlet.search_file import CMDLET as search_file_cmdlet
from cmdlet.file.search import CMDLET as search_file_cmdlet
query = "hash:" + ",".join(hashes)
args = ["-instance", str(instance), "-internal-refresh", query]
@@ -1135,6 +1135,9 @@ class Add_File(Cmdlet):
except Exception:
continue
if not bool(getattr(backend, "supports_relationship_association", False)):
continue
setter = getattr(backend, "set_relationship", None)
if not callable(setter):
continue
@@ -1981,7 +1984,7 @@ class Add_File(Cmdlet):
return
try:
from ._shared import display_and_persist_items
from .._shared import display_and_persist_items
display_and_persist_items([payload], title="Result", subject=payload)
except Exception:
@@ -2045,7 +2048,7 @@ class Add_File(Cmdlet):
Returns the emitted search-file payload items on success, else None.
"""
try:
from cmdlet.search_file import CMDLET as search_file_cmdlet
from cmdlet.file.search import CMDLET as search_file_cmdlet
args = ["-instance", str(instance), f"hash:{str(hash_value)}"]
@@ -2233,6 +2236,63 @@ class Add_File(Cmdlet):
pipe_obj.extra["url"] = merged_url
return merged_tags, merged_url, preferred_title, file_hash
@staticmethod
def _normalize_hash_candidate(value: Any) -> str:
text = str(value or "").strip().lower()
if len(text) != 64:
return ""
if any(ch not in "0123456789abcdef" for ch in text):
return ""
return text
@staticmethod
def _find_existing_hash_by_urls(
backend: Any,
urls: Sequence[str],
) -> Optional[str]:
"""Best-effort duplicate detection by URL before ingesting file bytes."""
url_candidates: List[str] = []
for raw in urls or []:
text = str(raw or "").strip()
if not text or not Add_File._is_probable_url(text):
continue
if text not in url_candidates:
url_candidates.append(text)
if not url_candidates:
return None
lookup_exact = getattr(backend, "find_hashes_by_url", None)
if callable(lookup_exact):
for candidate_url in url_candidates:
try:
hashes = lookup_exact(candidate_url) or []
except Exception:
continue
if not isinstance(hashes, (list, tuple, set)):
continue
for item in hashes:
normalized = Add_File._normalize_hash_candidate(item)
if normalized:
return normalized
searcher = getattr(backend, "search", None)
if callable(searcher):
for candidate_url in url_candidates:
try:
hits = searcher(f"url:{candidate_url}", limit=1, minimal=True) or []
except Exception:
continue
if not isinstance(hits, list) or not hits:
continue
hit = hits[0]
for key in ("hash", "file_hash", "sha256"):
normalized = Add_File._normalize_hash_candidate(get_field(hit, key))
if normalized:
return normalized
return None
@staticmethod
def _handle_local_export(
media_path: Path,
@@ -2383,8 +2443,6 @@ class Add_File(Cmdlet):
list_plugins_with_capability,
)
log(f"Uploading via {plugin_name}: {media_path.name}", file=sys.stderr)
try:
file_provider = get_plugin_with_capability(plugin_name, "upload", config)
if not file_provider:
@@ -2406,7 +2464,36 @@ class Add_File(Cmdlet):
pipe_obj=pipe_obj,
instance=instance_name,
)
log(f"File uploaded: {hoster_url}", file=sys.stderr)
duplicate_upload = False
duplicate_rule = ""
duplicate_target = ""
try:
if isinstance(getattr(pipe_obj, "extra", None), dict):
duplicate_upload = bool(pipe_obj.extra.get("upload_duplicate"))
duplicate_rule = str(pipe_obj.extra.get("upload_duplicate_rule") or "").strip()
duplicate_target = str(pipe_obj.extra.get("upload_duplicate_target") or "").strip()
except Exception:
duplicate_upload = False
duplicate_rule = ""
duplicate_target = ""
try:
debug_panel(
"add-file plugin upload",
[
("plugin", plugin_name),
("instance", instance_name or "<default>"),
("source", media_path),
("duplicate", duplicate_upload),
("rule", duplicate_rule or "none"),
("target", duplicate_target or ""),
("url", hoster_url),
],
border_style="yellow" if duplicate_upload else "green",
)
except Exception:
pass
f_hash = Add_File._resolve_file_hash(None, media_path, pipe_obj, None)
@@ -2505,11 +2592,21 @@ class Add_File(Cmdlet):
try:
store = store_instance if store_instance is not None else Store(config)
backend = store[backend_name]
backend, store, backend_exc = sh.get_preferred_store_backend(
config,
backend_name,
store_registry=store,
suppress_debug=True,
)
if backend is None:
raise backend_exc or KeyError(f"Unknown store backend: {backend_name}")
# Use backend properties to drive metadata deferral behavior.
is_remote_backend = getattr(backend, "is_remote", False)
prefer_defer_tags = getattr(backend, "prefer_defer_tags", False)
supports_url_association = bool(getattr(backend, "supports_url_association", False))
supports_note_association = bool(getattr(backend, "supports_note_association", False))
supports_relationship_association = bool(getattr(backend, "supports_relationship_association", False))
# ...
# Prepare metadata from pipe_obj and sidecars
@@ -2573,7 +2670,7 @@ class Add_File(Cmdlet):
pass
# Collect relationship pairs for post-ingest DB/API persistence.
if collect_relationship_pairs is not None:
if collect_relationship_pairs is not None and supports_relationship_association:
rels = Add_File._get_relationships(result, pipe_obj)
if isinstance(rels, dict) and rels:
king_hash, alt_hashes = Add_File._parse_relationships_king_alts(rels)
@@ -2622,16 +2719,23 @@ class Add_File(Cmdlet):
except Exception:
pass
# Call backend's add_file with full metadata
# Backend returns hash as identifier. If we already know the hash from _resolve_source
# (which came from download-file emit), pass it to skip re-hashing the 4GB file.
file_identifier = backend.add_file(
media_path,
title=title,
tag=upload_tags,
url=[] if (defer_url_association and url) else url,
file_hash=f_hash,
)
duplicate_hash = Add_File._find_existing_hash_by_urls(backend, url)
if duplicate_hash:
debug(
f"[add-file] URL duplicate detected in '{backend_name}', skipping upload and reusing hash {duplicate_hash[:12]}..."
)
file_identifier = duplicate_hash
else:
# Call backend's add_file with full metadata.
# Backend returns hash as identifier. If we already know the hash from _resolve_source
# (which came from download-file emit), pass it to skip re-hashing large files.
file_identifier = backend.add_file(
media_path,
title=title,
tag=upload_tags,
url=[] if ((defer_url_association and url) or (not supports_url_association)) else url,
file_hash=f_hash,
)
##log(f"✓ File added to '{backend_name}': {file_identifier}", file=sys.stderr)
stored_path: Optional[str] = None
@@ -2687,7 +2791,7 @@ class Add_File(Cmdlet):
# If we have url(s), ensure they get associated with the destination file.
# This mirrors `add-url` behavior but avoids emitting extra pipeline noise.
if url:
if url and supports_url_association:
if defer_url_association and pending_url_associations is not None:
try:
pending_url_associations.setdefault(
@@ -2708,7 +2812,7 @@ class Add_File(Cmdlet):
# If a subtitle note was provided upstream (e.g., download-media writes notes.sub),
# persist it automatically like add-note would.
sub_note = Add_File._get_note_text(result, pipe_obj, "sub")
if sub_note:
if sub_note and supports_note_association:
try:
setter = getattr(backend, "set_note", None)
if callable(setter):
@@ -2726,7 +2830,7 @@ class Add_File(Cmdlet):
)
lyric_note = Add_File._get_note_text(result, pipe_obj, "lyric")
if lyric_note:
if lyric_note and supports_note_association:
try:
setter = getattr(backend, "set_note", None)
if callable(setter):
@@ -2744,7 +2848,7 @@ class Add_File(Cmdlet):
)
chapters_note = Add_File._get_note_text(result, pipe_obj, "chapters")
if chapters_note:
if chapters_note and supports_note_association:
try:
setter = getattr(backend, "set_note", None)
if callable(setter):
@@ -2762,7 +2866,7 @@ class Add_File(Cmdlet):
)
caption_note = Add_File._get_note_text(result, pipe_obj, "caption")
if caption_note:
if caption_note and supports_note_association:
try:
setter = getattr(backend, "set_note", None)
if callable(setter):
@@ -2905,6 +3009,9 @@ class Add_File(Cmdlet):
if backend is None:
continue
if not bool(getattr(backend, "supports_url_association", False)):
continue
items = sh.coalesce_hash_value_pairs(pairs)
if not items:
continue
@@ -8,7 +8,7 @@ import re
from SYS.logger import log
from SYS import pipeline as ctx
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -213,6 +213,12 @@ class Add_Note(Cmdlet):
)
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),
@@ -12,7 +12,7 @@ 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
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -603,6 +603,12 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
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
+20 -2
View File
@@ -4,7 +4,7 @@ from typing import Any, Dict, List, Sequence, Tuple
import sys
from SYS import pipeline as ctx
from . import _shared as sh
from .. import _shared as sh
from SYS.logger import log
from Store import Store
@@ -135,10 +135,25 @@ class Add_Url(sh.Cmdlet):
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,
batch,
supported_batch,
bulk_method_name="add_url_bulk",
single_method_name="add_url",
store_registry=storage,
@@ -166,6 +181,9 @@ class Add_Url(sh.Cmdlet):
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",
@@ -19,7 +19,7 @@ from SYS.utils import extract_hydrus_hash_from_url
from SYS import pipeline as ctx
from SYS.config import resolve_output_dir
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -9,7 +9,7 @@ import subprocess
from SYS.logger import log, debug
from SYS.payload_builders import build_file_result_payload
from SYS.utils import sha256_file
from . import _shared as sh
from .. import _shared as sh
from SYS import pipeline as ctx
Cmdlet = sh.Cmdlet
@@ -10,7 +10,7 @@ from pathlib import Path
from SYS.logger import debug, log
from ProviderCore.registry import get_plugin
from Store import Store
from . import _shared as sh
from .. import _shared as sh
from SYS import pipeline as ctx
from SYS.result_table_helpers import add_row_columns
from SYS.result_table import Table, _format_size
@@ -24,6 +24,7 @@ from SYS.pipeline_progress import PipelineProgress
from SYS.result_table import Table
from SYS.rich_display import stderr_console as get_stderr_console
from SYS import pipeline as pipeline_context
from rich.prompt import Prompt
# SYS.metadata import deferred: normalize_urls loaded lazily at call site to avoid
# pulling in Cryptodome (~900ms) at module import time.
from SYS.selection_builder import (
@@ -38,7 +39,7 @@ try:
except Exception: # pragma: no cover - optional dependency for tests/runtime wrappers
YtDlpTool = None # type: ignore
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -936,7 +937,7 @@ class Download_File(Cmdlet):
try:
subject = emitted_items[0] if len(emitted_items) == 1 else list(emitted_items)
# Use helper to display items and make them @-selectable
from ._shared import display_and_persist_items
from .._shared import display_and_persist_items
display_and_persist_items(list(emitted_items), title="Result", subject=subject)
except Exception:
pass
@@ -984,56 +985,13 @@ class Download_File(Cmdlet):
def _find_existing_hash_for_url(
cls, storage: Any, canonical_url: str, *, hydrus_available: bool
) -> Optional[str]:
if storage is None or not canonical_url:
return None
hydrus_provider = None
try:
registry_helpers = cls._load_provider_registry()
get_plugin = registry_helpers.get("get_plugin")
if callable(get_plugin):
hydrus_provider = get_plugin("hydrusnetwork", {})
except Exception:
hydrus_provider = None
try:
backend_names = list(storage.list_searchable_backends() or [])
except Exception:
backend_names = []
for backend_name in backend_names:
try:
backend = storage[backend_name]
except Exception:
continue
try:
if str(backend_name).strip().lower() == "temp":
continue
except Exception:
pass
try:
if hydrus_provider is not None and hydrus_provider.is_backend(backend, str(backend_name)) and not hydrus_available:
continue
except Exception:
pass
try:
if hydrus_provider is not None and hydrus_provider.is_backend(backend, str(backend_name)):
hashes = backend.find_hashes_by_url(canonical_url) or []
for existing_hash in hashes:
normalized = sh.normalize_hash(existing_hash)
if normalized:
return normalized
continue
hits = backend.search(f"url:{canonical_url}", limit=5) or []
except Exception:
hits = []
for hit in hits:
extracted = cls._extract_hash_from_search_hit(hit)
if extracted:
return extracted
return None
hashes = cls._find_existing_hashes_for_url(
storage,
canonical_url,
hydrus_available=hydrus_available,
config={},
)
return hashes[0] if hashes else None
@staticmethod
def _init_storage(config: Dict[str, Any]) -> tuple[Any, bool]:
@@ -1265,6 +1223,205 @@ class Download_File(Cmdlet):
download_timeout_seconds=int(config.get("_pipeobject_timeout_seconds") or 300) if isinstance(config, dict) else 300,
)
@classmethod
def _find_existing_hashes_for_url(
cls,
storage: Any,
canonical_url: str,
*,
hydrus_available: bool,
config: Optional[Dict[str, Any]] = None,
) -> List[str]:
if not canonical_url:
return []
config_dict = config if isinstance(config, dict) else {}
found_hashes: List[str] = []
seen_hashes: set[str] = set()
seen_backends: set[str] = set()
def _add_hash(value: Any) -> None:
normalized = sh.normalize_hash(str(value) if value is not None else None)
if not normalized or normalized in seen_hashes:
return
seen_hashes.add(normalized)
found_hashes.append(normalized)
def _iter_backends() -> List[tuple[str, Any]]:
backends: List[tuple[str, Any]] = []
if storage is not None:
try:
backend_names = list(storage.list_searchable_backends() or [])
except Exception:
backend_names = []
for backend_name in backend_names:
try:
backend = storage[backend_name]
except Exception:
continue
name_text = str(backend_name).strip()
if not name_text:
continue
if name_text.lower() == "temp":
continue
key = name_text.lower()
if key in seen_backends:
continue
seen_backends.add(key)
backends.append((name_text, backend))
# Hydrus can be plugin-configured without appearing in Store.list_searchable_backends().
try:
registry_helpers = cls._load_provider_registry()
get_plugin = registry_helpers.get("get_plugin")
hydrus_provider = get_plugin("hydrusnetwork", config_dict) if callable(get_plugin) else None
if hydrus_provider is not None:
for backend_name, backend in hydrus_provider.iter_backends():
name_text = str(backend_name or "").strip()
if not name_text:
continue
key = name_text.lower()
if key in seen_backends:
continue
seen_backends.add(key)
backends.append((name_text, backend))
except Exception:
pass
return backends
for backend_name, backend in _iter_backends():
try:
if not hydrus_available and str(getattr(backend, "STORE_TYPE", "")).strip().lower() == "hydrusnetwork":
continue
except Exception:
pass
lookup_exact = getattr(backend, "find_hashes_by_url", None)
if callable(lookup_exact):
try:
hashes = lookup_exact(canonical_url) or []
except Exception:
hashes = []
if isinstance(hashes, (list, tuple, set)):
for existing_hash in hashes:
_add_hash(existing_hash)
if found_hashes:
continue
searcher = getattr(backend, "search", None)
if callable(searcher):
try:
hits = searcher(f"url:{canonical_url}", limit=5, minimal=True) or []
except Exception:
hits = []
for hit in hits:
extracted = cls._extract_hash_from_search_hit(hit)
_add_hash(extracted)
return found_hashes
def _preflight_explicit_url_duplicates(
self,
*,
raw_urls: Sequence[str],
config: Dict[str, Any],
) -> tuple[List[str], Optional[int], int]:
"""Return (urls_to_process, early_exit, skipped_count)."""
urls = [str(u or "").strip() for u in (raw_urls or []) if str(u or "").strip()]
if not urls:
return [], None, 0
if bool(config.get("_skip_url_preflight")):
return urls, None, 0
storage, hydrus_available = self._init_storage(config)
duplicates: Dict[str, List[str]] = {}
for url in urls:
found = self._find_existing_hashes_for_url(
storage,
url,
hydrus_available=hydrus_available,
config=config,
)
if found:
duplicates[url] = found
if not duplicates:
return urls, None, 0
duplicate_count = len(duplicates)
total_count = len(urls)
try:
debug_panel(
"download-file duplicate preflight",
[
("total_urls", total_count),
("duplicate_urls", duplicate_count),
],
border_style="yellow",
)
except Exception:
pass
table = Table(f"Duplicate URLs detected ({duplicate_count}/{total_count})", max_columns=8)
table._interactive(False)
for url, hashes in duplicates.items():
table.add_result(
build_table_result_payload(
title="(exists)",
columns=[
("URL", url),
("Hash", str((hashes[0] if hashes else "") or "")),
],
url=url,
hash=str((hashes[0] if hashes else "") or ""),
)
)
try:
stdin_interactive = bool(sys.stdin and sys.stdin.isatty())
except Exception:
stdin_interactive = False
policy = "skip"
if stdin_interactive:
console = get_stderr_console()
console.print(table)
policy = Prompt.ask(
"Duplicate URLs found. Action?",
choices=["ignore", "skip", "cancel"],
default="skip",
console=console,
)
else:
# Safe default in non-interactive runs: avoid redownloading known duplicates.
policy = "skip"
try:
get_stderr_console().print(table)
except Exception:
pass
if policy == "cancel":
try:
pipeline_context.request_pipeline_stop(reason="duplicate-url cancelled", exit_code=0)
except Exception:
pass
return [], 0, 0
if policy == "ignore":
return urls, None, 0
filtered = [u for u in urls if u not in duplicates]
skipped = len(urls) - len(filtered)
if skipped:
try:
log(f"Skipped {skipped} duplicate URL(s); processing remaining {len(filtered)}.", file=sys.stderr)
except Exception:
pass
return filtered, None, skipped
@staticmethod
def _format_timecode(seconds: int, *, force_hours: bool) -> str:
total = max(0, int(seconds))
@@ -1739,8 +1896,18 @@ class Download_File(Cmdlet):
items_preview=preview
)
raw_url, preflight_exit, skipped_dupe_count = self._preflight_explicit_url_duplicates(
raw_urls=raw_url,
config=config,
)
if preflight_exit is not None:
return int(preflight_exit)
downloaded_count = 0
if skipped_dupe_count and not raw_url and not piped_items:
return 0
urls_downloaded, early_exit = self._process_explicit_urls(
raw_urls=raw_url,
final_output_dir=final_output_dir,
+1 -1
View File
@@ -16,7 +16,7 @@ from urllib.parse import urljoin
from urllib.request import pathname2url
from SYS import pipeline as ctx
from . import _shared as sh
from .. import _shared as sh
from SYS.item_accessors import get_result_title
from SYS.logger import log, debug, debug_panel
from SYS.config import resolve_output_dir
@@ -13,7 +13,7 @@ import re as _re
from SYS.config import resolve_output_dir
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -23,7 +23,7 @@ from SYS.item_accessors import extract_item_tags, get_result_title
from API.HTTP import HTTPClient
from SYS.pipeline_progress import PipelineProgress
from SYS.utils import ensure_directory, sha256_file, unique_path, unique_preserve_order
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -26,7 +26,7 @@ from SYS.item_accessors import get_extension_field, get_int_field, get_result_ti
from SYS.selection_builder import build_default_selection
from SYS.result_publication import publish_result_table
from ._shared import (
from .._shared import (
Cmdlet,
CmdletArg,
SharedArgs,
+1 -1
View File
@@ -14,7 +14,7 @@ from urllib.parse import urlparse
from SYS.logger import log, debug
from SYS.item_accessors import get_store_name
from SYS.utils import sha256_file
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
from importlib import import_module
from typing import Any, Dict, List, Sequence
import sys
from SYS.logger import log
from . import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
SharedArgs = sh.SharedArgs
class File(Cmdlet):
"""Unified file command: file -add|-delete|-get|-merge|..."""
_ACTION_FLAGS = {
"add": {"-add", "--add"},
"delete": {"-delete", "--delete", "-del", "--del"},
"get": {"-get", "--get"},
"merge": {"-merge", "--merge"},
"download": {"-download", "--download", "-dl", "--dl"},
"search": {"-search", "--search"},
"convert": {"-convert", "--convert"},
"trim": {"-trim", "--trim"},
"archive": {"-archive", "--archive"},
"screenshot": {"-screenshot", "--screenshot", "-screen-shot", "--screen-shot", "-shot", "--shot"},
}
_ACTION_MODULE = {
"add": "cmdlet.file.add",
"delete": "cmdlet.file.delete",
"get": "cmdlet.file.get",
"merge": "cmdlet.file.merge",
"download": "cmdlet.file.download",
"search": "cmdlet.file.search",
"convert": "cmdlet.file.convert",
"trim": "cmdlet.file.trim",
"archive": "cmdlet.file.archive",
"screenshot": "cmdlet.file.screenshot",
}
def __init__(self) -> None:
super().__init__(
name="file",
summary="Manage file operations with one command",
usage='file (-add|-delete|-get|-merge|-download|-search|-convert|-trim|-archive|-screenshot) [args]',
arg=[
SharedArgs.QUERY,
SharedArgs.INSTANCE,
SharedArgs.PATH,
CmdletArg("-add", type="flag", required=False, description="Run add-file"),
CmdletArg("-delete", type="flag", required=False, description="Run delete-file", alias="del"),
CmdletArg("-get", type="flag", required=False, description="Run get-file"),
CmdletArg("-merge", type="flag", required=False, description="Run merge-file"),
CmdletArg("-download", type="flag", required=False, description="Run download-file", alias="dl"),
CmdletArg("-search", type="flag", required=False, description="Run search-file"),
CmdletArg("-convert", type="flag", required=False, description="Run convert-file"),
CmdletArg("-trim", type="flag", required=False, description="Run trim-file"),
CmdletArg("-archive", type="flag", required=False, description="Run archive-file"),
CmdletArg("-screenshot", type="flag", required=False, description="Run screen-shot", alias="shot"),
],
detail=[
"- Exactly one action flag is required.",
"- Remaining args are passed through to the selected file cmdlet.",
"- Examples: file -add ..., file -delete ..., file -merge ...",
],
exec=self.run,
)
self.register()
@classmethod
def _extract_action(cls, args: Sequence[str]) -> tuple[str | None, List[str], List[str]]:
matched_actions: List[str] = []
passthrough: List[str] = []
for token in args or []:
text = str(token or "")
lower = text.strip().lower()
matched = None
for action_name, variants in cls._ACTION_FLAGS.items():
if lower in variants:
matched = action_name
break
if matched:
matched_actions.append(matched)
continue
passthrough.append(text)
unique_actions: List[str] = []
for action in matched_actions:
if action not in unique_actions:
unique_actions.append(action)
if len(unique_actions) != 1:
return None, passthrough, unique_actions
return unique_actions[0], passthrough, unique_actions
@classmethod
def _dispatch(cls, action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
module_name = cls._ACTION_MODULE.get(action)
if not module_name:
log(f"file: unsupported action '{action}'", file=sys.stderr)
return 1
module = import_module(module_name)
cmdlet_obj = getattr(module, "CMDLET", None)
if cmdlet_obj is not None:
exec_fn = getattr(cmdlet_obj, "exec", None)
if callable(exec_fn):
return int(exec_fn(result, args, config))
fallback_run = getattr(module, "_run", None)
if callable(fallback_run):
return int(fallback_run(result, args, config))
log(f"file: cannot dispatch action '{action}' via module '{module_name}'", file=sys.stderr)
return 1
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
action, passthrough_args, seen = self._extract_action(args)
if action is None:
if not seen:
log(
"file: missing action flag; choose exactly one of -add, -delete, -get, -merge, -download, -search, -convert, -trim, -archive, -screenshot",
file=sys.stderr,
)
else:
rendered = ", ".join(f"-{name}" for name in seen)
log(f"file: conflicting actions ({rendered}); choose exactly one", file=sys.stderr)
return 1
return self._dispatch(action, result, passthrough_args, config)
CMDLET = File()
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
"""Metadata domain command handlers.
This package centralizes routing for metadata sub-domains (tag, url,
relationship, note, inspect) behind the top-level `metadata` cmdlet.
"""
from .tag import run_tag_action
from .url import run_url_action
from .relationship import run_relationship_action
from .note import run_note_action
from .inspect import run_inspect_action
__all__ = [
"run_tag_action",
"run_url_action",
"run_relationship_action",
"run_note_action",
"run_inspect_action",
]
@@ -10,7 +10,7 @@ from SYS.result_publication import publish_result_table
from SYS.result_table_helpers import add_row_columns
from SYS import pipeline as ctx
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -11,7 +11,7 @@ from SYS.selection_builder import build_hash_store_selection
from SYS.result_publication import publish_result_table
from SYS import pipeline as ctx
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Any, Dict, Sequence
def run_inspect_action(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Route metadata inspect to get-metadata implementation."""
from cmdlet.get_metadata import CMDLET as GET_METADATA_CMDLET
exec_fn = getattr(GET_METADATA_CMDLET, "exec", None)
if callable(exec_fn):
return int(exec_fn(result, args, config))
return int(GET_METADATA_CMDLET.run(result, args, config))
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any, Dict, Sequence
def run_note_action(action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Route metadata note actions to note cmdlets."""
act = str(action or "").strip().lower()
if act == "add":
from cmdlet.file.add_note import CMDLET as ADD_NOTE_CMDLET
return int(ADD_NOTE_CMDLET.run(result, args, config))
if act == "delete":
from cmdlet.delete_note import CMDLET as DELETE_NOTE_CMDLET
return int(DELETE_NOTE_CMDLET.run(result, args, config))
if act == "get":
from cmdlet.metadata.get_note import CMDLET as GET_NOTE_CMDLET
return int(GET_NOTE_CMDLET.run(result, args, config))
return 1
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any, Dict, Sequence
def run_relationship_action(action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Route metadata relationship actions to relationship cmdlets."""
act = str(action or "").strip().lower()
if act == "add":
from cmdlet.file.add_relationship import _run as run_add_relationship
return int(run_add_relationship(result, args, config))
if act == "delete":
from cmdlet.delete_relationship import _run as run_delete_relationship
return int(run_delete_relationship(list(args), config))
if act == "get":
from cmdlet.metadata.get_relationship import _run as run_get_relationship
return int(run_get_relationship(result, args, config))
return 1
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any, Dict, Sequence
def run_tag_action(action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Route metadata tag actions to the existing tag implementations."""
act = str(action or "").strip().lower()
if act == "add":
from cmdlet.metadata.tag_add import Add_Tag
return Add_Tag(register_cmdlet=False).run(result, args, config)
if act == "delete":
from cmdlet.metadata.tag_delete import _run as run_delete
return run_delete(result, args, config)
if act == "get":
from cmdlet.metadata.tag_get import _run as run_get
return run_get(result, args, config)
return 1
@@ -12,7 +12,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 .. import _shared as sh
from Store import Store # retained for test monkeypatch compatibility
normalize_result_input = sh.normalize_result_input
@@ -411,7 +411,7 @@ def _refresh_tag_view(
get_tag = None
try:
get_tag = get_cmdlet("get-tag")
get_tag = get_cmdlet("metadata")
except Exception:
get_tag = None
if not callable(get_tag):
@@ -422,7 +422,7 @@ def _refresh_tag_view(
if not subject or not _matches_target(subject, target_hash, target_path, store_name):
return
refresh_args: List[str] = ["-query", f"hash:{target_hash}"]
refresh_args: List[str] = ["-get", "-query", f"hash:{target_hash}"]
# Build a lean subject so get-tag fetches fresh tags instead of reusing cached payloads.
def _build_refresh_subject() -> Dict[str, Any]:
@@ -463,14 +463,14 @@ def _refresh_tag_view(
class Add_Tag(Cmdlet):
"""Class-based add-tag cmdlet with Cmdlet metadata inheritance."""
"""Class-based metadata -add tag handler with Cmdlet metadata inheritance."""
def __init__(self) -> None:
def __init__(self, *, register_cmdlet: bool = True) -> None:
super().__init__(
name="add-tag",
name="tag",
summary="Add tag to a file in a store.",
usage=
'add-tag -instance <store> [-query "hash:<sha256>"] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]',
'metadata -add -instance <store> [-query "hash:<sha256>"] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]',
arg=[
CmdletArg(
"tag",
@@ -519,22 +519,23 @@ class Add_Tag(Cmdlet):
"- If -query is not provided, uses the piped item's hash (or derives from its path when possible).",
"- Multiple tag can be comma-separated or space-separated.",
"- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult",
'- tag can also reference lists with curly braces: add-tag {philosophy} "other:tag"',
'- tag can also reference lists with curly braces: metadata -add {philosophy} "other:tag"',
"- Use -duplicate to copy EXISTING tag values to new namespaces:",
" Explicit format: -duplicate title:album,artist (copies title: to album: and artist:)",
" Inferred format: -duplicate title,album,artist (first is source, rest are targets)",
"- The source namespace must already exist in the file being tagged.",
"- Target namespaces that already have a value are skipped (not overwritten).",
"- Use -extract to derive namespaced tags from the current title (title field or title: tag) using a simple template.",
"- Use #(namespace) inside a tag value to insert existing values, e.g. add-tag \"title:#(track) - #(series)\".",
"- Use angle-bracket transforms for advanced formatting, e.g. add-tag \"code:e<padding(00,#(episode))>\".",
"- Use #(namespace) inside a tag value to insert existing values, e.g. metadata -add \"title:#(track) - #(series)\".",
"- Use angle-bracket transforms for advanced formatting, e.g. metadata -add \"code:e<padding(00,#(episode))>\".",
"- Current documented transforms include padding, default, replace, and increment.",
"- Template examples assume lowercase tag text; case transforms are intentionally not part of the documented syntax.",
"- See docs/tag_template_syntax.md for recipe-style examples and the current shared template syntax.",
],
exec=self.run,
)
self.register()
if register_cmdlet:
self.register()
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Add tag to a file with smart filtering for pipeline results."""
@@ -1215,4 +1216,3 @@ class Add_Tag(Cmdlet):
return 0
CMDLET = Add_Tag()
@@ -7,7 +7,7 @@ from SYS import pipeline as ctx
from SYS.item_accessors import set_field
from SYS.payload_builders import extract_title_tag_value
from SYS.result_publication import publish_result_table
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -203,7 +203,7 @@ def _refresh_tag_view_if_current(
get_tag = None
try:
get_tag = get_cmdlet("get-tag")
get_tag = get_cmdlet("metadata")
except Exception:
get_tag = None
if not callable(get_tag):
@@ -245,7 +245,7 @@ def _refresh_tag_view_if_current(
if not is_match:
return
refresh_args: list[str] = []
refresh_args: list[str] = ["-get"]
if file_hash:
refresh_args.extend(["-query", f"hash:{file_hash}"])
@@ -385,10 +385,10 @@ def _parse_delete_tag_arguments(arguments: Sequence[str]) -> list[str]:
return tags
CMDLET = Cmdlet(
name="delete-tag",
_DELETE_TAG_CMDLET = Cmdlet(
name="tag",
summary="Remove tags from a file in a store.",
usage='delete-tag -instance <store> [-query "hash:<sha256>"] <tag>[,<tag>...]',
usage='metadata -delete -instance <store> [-query "hash:<sha256>"] <tag>[,<tag>...]',
arg=[
SharedArgs.QUERY,
SharedArgs.INSTANCE,
@@ -401,8 +401,8 @@ CMDLET = Cmdlet(
detail=[
"- Requires a Hydrus file (hash present) or explicit -query override.",
"- Multiple tags can be comma-separated or space-separated.",
"- Use #(namespace) inside a tag value to remove a derived tag, e.g. delete-tag \"title:#(track) - #(series)\".",
"- Angle-bracket transforms match add-tag syntax, e.g. delete-tag \"code:e<padding(00,#(episode))>\".",
"- Use #(namespace) inside a tag value to remove a derived tag, e.g. metadata -delete \"title:#(track) - #(series)\".",
"- Angle-bracket transforms match metadata -add syntax, e.g. metadata -delete \"code:e<padding(00,#(episode))>\".",
"- Current documented transforms include padding, default, replace, and increment.",
"- Template examples assume lowercase tag text; case transforms are intentionally not part of the documented syntax.",
"- See docs/tag_template_syntax.md for recipe-style examples and the current shared template syntax.",
@@ -413,7 +413,7 @@ CMDLET = Cmdlet(
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Help
if should_show_help(args):
log(f"Cmdlet: {CMDLET.name}\nSummary: {CMDLET.summary}\nUsage: {CMDLET.usage}")
log(f"Cmdlet: {_DELETE_TAG_CMDLET.name}\nSummary: {_DELETE_TAG_CMDLET.summary}\nUsage: {_DELETE_TAG_CMDLET.usage}")
return 0
def _looks_like_tag_row(obj: Any) -> bool:
@@ -474,7 +474,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# If @ reaches here as a literal argument, it's almost certainly user error.
if rest and str(rest[0]
).startswith("@") and not (has_piped_tag or has_piped_tag_list):
log("Selection syntax is only supported via piping. Use: @N | delete-tag")
log("Selection syntax is only supported via piping. Use: @N | metadata -delete")
return 1
# Special case: grouped tag selection created by the pipeline runner.
@@ -766,7 +766,7 @@ def _process_deletion(
remaining_titles = [t for t in current_titles if t.lower() not in del_title_set]
if current_titles and not remaining_titles:
log(
'Cannot delete the last title: tag. Add a replacement title first (add-tags "title:new title").',
'Cannot delete the last title: tag. Add a replacement title first (metadata -add "title:new title").',
file=sys.stderr,
)
return False
@@ -803,6 +803,3 @@ def _process_deletion(
return False
# Register cmdlet (no legacy decorator)
CMDLET.exec = _run
CMDLET.register()
@@ -37,7 +37,7 @@ from SYS.detail_view_helpers import create_detail_view, prepare_detail_metadata
from SYS.payload_builders import extract_title_tag_value
from SYS.result_publication import publish_result_table
from SYS.result_table_helpers import add_row_columns
from . import _shared as sh
from .. import _shared as sh
from SYS.field_access import get_field
normalize_hash = sh.normalize_hash
@@ -276,8 +276,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Get tags from Hydrus, local sidecar, or URL metadata.
Usage:
get-tag [-query "hash:<sha256>"] [--instance <key>] [--emit]
get-tag -scrape <url|provider>
metadata -get [-query "hash:<sha256>"] [--instance <key>] [--emit]
metadata -get -scrape <url|provider>
Options:
-query "hash:<sha256>": Override hash to use instead of result's hash
@@ -292,7 +292,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Internal implementation details for get-tag."""
"""Internal implementation details for metadata -get."""
emit_mode = False
is_store_backed = False
args_list = [str(arg) for arg in (args or [])]
@@ -329,7 +329,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return getattr(obj, field, default)
# Parse arguments using shared parser
parsed_args = parse_cmdlet_args(args_list, CMDLET)
parsed_args = parse_cmdlet_args(args_list, Get_Tag(register_cmdlet=False))
# Detect if -scrape flag was provided without a value (parse_cmdlet_args skips missing values)
scrape_flag_present = any(
@@ -660,7 +660,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
table = Table(f"Metadata: {provider.name}")
table.set_table(f"metadata.{provider.name}")
table.set_source_command("get-tag", [])
table.set_source_command("metadata", ["-get"])
selection_payload = []
hash_for_payload = normalize_hash(hash_override) or normalize_hash(
get_field(result,
@@ -956,15 +956,15 @@ _SCRAPE_CHOICES = [
class Get_Tag(Cmdlet):
"""Class-based get-tag cmdlet with self-registration."""
"""Class-based metadata -get tag handler with self-registration."""
def __init__(self) -> None:
"""Initialize get-tag cmdlet."""
def __init__(self, *, register_cmdlet: bool = True) -> None:
"""Initialize metadata -get handler."""
super().__init__(
name="get-tag",
name="tag",
summary="Get tag values from Hydrus or local sidecar metadata",
usage=
'get-tag [-query "hash:<sha256>"] [--instance <key>] [--emit] [-scrape <url|provider>]',
'metadata -get [-query "hash:<sha256>"] [--instance <key>] [--emit] [-scrape <url|provider>]',
alias=[],
arg=[
SharedArgs.QUERY,
@@ -1001,12 +1001,11 @@ class Get_Tag(Cmdlet):
],
exec=self.run,
)
self.register()
if register_cmdlet:
self.register()
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Execute get-tag cmdlet."""
"""Execute metadata -get."""
return _run(result, args, config)
# Create and register the cmdlet
CMDLET = Get_Tag()
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any, Dict, Sequence
def run_url_action(action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Route metadata URL actions to URL cmdlets."""
act = str(action or "").strip().lower()
if act == "add":
from cmdlet.file.add_url import CMDLET as ADD_URL_CMDLET
return int(ADD_URL_CMDLET.run(result, args, config))
if act == "delete":
from cmdlet.delete_url import CMDLET as DELETE_URL_CMDLET
return int(DELETE_URL_CMDLET.run(result, args, config))
if act == "get":
from cmdlet.get_url import CMDLET as GET_URL_CMDLET
return int(GET_URL_CMDLET.run(result, args, config))
return 1
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
from typing import Any, Dict, List, Sequence
import sys
from SYS.logger import log
from . import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
SharedArgs = sh.SharedArgs
class Metadata(Cmdlet):
"""Unified metadata command with domain + action routing."""
_ACTION_FLAGS = {
"add": {"-add", "--add"},
"delete": {"-delete", "--delete", "-del", "--del"},
"get": {"-get", "--get"},
"inspect": {"-inspect", "--inspect", "-info", "--info", "-file", "--file"},
}
_DOMAIN_FLAGS = {
"tag": {"-tag", "--tag", "-tags", "--tags"},
"url": {"-url", "--url", "-urls", "--urls"},
"relationship": {
"-relationship",
"--relationship",
"-relationships",
"--relationships",
"-rel",
"--rel",
},
"note": {"-note", "--note", "-notes", "--notes"},
"file": {"-file", "--file"},
}
def __init__(self) -> None:
super().__init__(
name="metadata",
summary="Manage metadata domains with one command",
usage='metadata [-tag|-url|-relationship|-note] (-add|-delete|-get) [args] OR metadata -inspect [args]',
alias=["meta"],
arg=[
SharedArgs.QUERY,
SharedArgs.INSTANCE,
CmdletArg("-tag", type="flag", required=False, description="Metadata tag domain (default if omitted)"),
CmdletArg("-url", type="flag", required=False, description="URL metadata domain"),
CmdletArg("-relationship", type="flag", required=False, description="Relationship metadata domain", alias="rel"),
CmdletArg("-note", type="flag", required=False, description="Note metadata domain"),
CmdletArg("-add", type="flag", required=False, description="Add metadata tag value(s)"),
CmdletArg("-delete", type="flag", required=False, description="Delete metadata tag value(s)", alias="del"),
CmdletArg("-get", type="flag", required=False, description="Read metadata values for selected domain"),
CmdletArg("-inspect", type="flag", required=False, description="Inspect file metadata details", alias="info"),
CmdletArg(
"<tag>[,<tag>...]",
type="string",
required=False,
variadic=True,
description="Tag values for -add/-delete",
),
],
detail=[
"- Use one action flag: -add, -delete, -get, or -inspect.",
"- Domain flags: -tag (default), -url, -relationship, -note.",
"- Examples:",
" metadata -url -add <url>",
" metadata -url -delete <url>",
" metadata -relationship -get",
" metadata -note -add -query \"title:lyric text:...\"",
"- -inspect maps to get-metadata and prints rich metadata details.",
],
exec=self.run,
)
self.register()
@classmethod
def _extract_parts(
cls,
args: Sequence[str],
) -> tuple[str | None, str, List[str], List[str], List[str]]:
matched_actions: List[str] = []
matched_domains: List[str] = []
passthrough: List[str] = []
for token in args or []:
text = str(token or "")
lower = text.strip().lower()
matched = None
for action_name, variants in cls._ACTION_FLAGS.items():
if lower in variants:
matched = action_name
break
if matched:
matched_actions.append(matched)
continue
matched_domain = None
for domain_name, variants in cls._DOMAIN_FLAGS.items():
if lower in variants:
matched_domain = domain_name
break
if matched_domain:
matched_domains.append(matched_domain)
continue
passthrough.append(text)
unique_actions: List[str] = []
for action in matched_actions:
if action not in unique_actions:
unique_actions.append(action)
unique_domains: List[str] = []
for domain in matched_domains:
if domain not in unique_domains:
unique_domains.append(domain)
action = unique_actions[0] if len(unique_actions) == 1 else None
domain = unique_domains[0] if len(unique_domains) == 1 else "tag"
return action, domain, passthrough, unique_actions, unique_domains
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
action, domain, passthrough_args, seen_actions, seen_domains = self._extract_parts(args)
if action is None:
if not seen_actions:
log(
"metadata: missing action flag; choose exactly one of -add, -delete, -get, -inspect",
file=sys.stderr,
)
else:
rendered = ", ".join(f"-{name}" for name in seen_actions)
log(f"metadata: conflicting actions ({rendered}); choose exactly one", file=sys.stderr)
return 1
if len(seen_domains) > 1:
rendered_domains = ", ".join(f"-{name}" for name in seen_domains)
log(f"metadata: conflicting domains ({rendered_domains}); choose one domain", file=sys.stderr)
return 1
if action == "inspect":
from cmdlet.metadata.inspect import run_inspect_action
return run_inspect_action(result, passthrough_args, config)
if domain == "file":
log("metadata: -file only supports -inspect; use metadata -inspect", file=sys.stderr)
return 1
if domain == "tag":
from cmdlet.metadata.tag import run_tag_action
return run_tag_action(action, result, passthrough_args, config)
if domain == "url":
from cmdlet.metadata.url import run_url_action
return run_url_action(action, result, passthrough_args, config)
if domain == "relationship":
from cmdlet.metadata.relationship import run_relationship_action
return run_relationship_action(action, result, passthrough_args, config)
if domain == "note":
from cmdlet.metadata.note import run_note_action
return run_note_action(action, result, passthrough_args, config)
log(f"metadata: unsupported domain '{domain}'", file=sys.stderr)
return 1
CMDLET = Metadata()