From 3ce339b3c19352cb93e77ea764de2c9d802451f1 Mon Sep 17 00:00:00 2001 From: Nose Date: Mon, 4 May 2026 15:58:24 -0700 Subject: [PATCH] updated --- CLI.py | 34 ++++++-- ProviderCore/registry.py | 133 +++++++++++++++++++++++++++++++- TUI/modalscreen/config_modal.py | 35 ++++++++- cmdlet/add_file.py | 34 +++++++- cmdlet/delete_file.py | 67 +++++++++++++++- plugins/ftp/__init__.py | 37 ++++++++- 6 files changed, 322 insertions(+), 18 deletions(-) diff --git a/CLI.py b/CLI.py index bcef5ad..e3f9090 100644 --- a/CLI.py +++ b/CLI.py @@ -506,16 +506,40 @@ class CmdletIntrospection: if normalized_arg == "plugin": canonical_cmd = (cmd_name or "").replace("_", "-").lower() try: - from ProviderCore.registry import list_configured_plugin_names_with_capability + from ProviderCore.registry import ( + list_configured_plugin_names_with_capability, + list_plugin_names_for_cmdlet, + ) except Exception: list_configured_plugin_names_with_capability = None # type: ignore + list_plugin_names_for_cmdlet = None # type: ignore plugin_choices: List[str] = [] - if canonical_cmd in {"add-file"} and list_configured_plugin_names_with_capability is not None: - return list_configured_plugin_names_with_capability("upload", config) or [] - - if list_configured_plugin_names_with_capability is not None: + if list_plugin_names_for_cmdlet is not None: + configured = list_plugin_names_for_cmdlet( + canonical_cmd, + config, + configured_only=True, + ) or [] + available = list_plugin_names_for_cmdlet( + canonical_cmd, + config, + configured_only=False, + ) or [] + # Prefer configured plugins first, but still show valid plugin options. + seen: Set[str] = set() + merged: List[str] = [] + for entry in [*configured, *available]: + key = str(entry or "").strip().lower() + if not key or key in seen: + continue + seen.add(key) + merged.append(str(entry)) + plugin_choices = merged + elif canonical_cmd in {"add-file"} and list_configured_plugin_names_with_capability is not None: + plugin_choices = list_configured_plugin_names_with_capability("upload", config) or [] + elif list_configured_plugin_names_with_capability is not None: plugin_choices = list_configured_plugin_names_with_capability("search", config) or [] if plugin_choices: diff --git a/ProviderCore/registry.py b/ProviderCore/registry.py index a8863a1..a51ee6b 100644 --- a/ProviderCore/registry.py +++ b/ProviderCore/registry.py @@ -530,6 +530,53 @@ def get_plugin_class(name: str) -> Optional[Type[Provider]]: return info.plugin_class +def get_plugin_capabilities( + name: str, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Return a normalized capability summary for a plugin name.""" + info = REGISTRY.get(name) + if info is None: + return { + "name": str(name or "").strip().lower(), + "supported_cmdlets": [], + "supports_search": False, + "supports_upload": False, + "supports_pipe_download": False, + "supports_delete_file": False, + "is_multi_instance": False, + "configured_instances": [], + } + + supported_cmdlets = sorted(str(c) for c in info.supported_cmdlets) + supports_pipe_download = _class_supports_method( + info.plugin_class, + "resolve_pipe_result_download", + Provider.resolve_pipe_result_download, + ) + delete_method = getattr(info.plugin_class, "delete_file", None) + base_delete_method = getattr(Provider, "delete_file", None) + supports_delete_file = callable(delete_method) and delete_method is not base_delete_method + + configured_instances: List[str] = [] + try: + plugin_obj = info.plugin_class(config or {}) + configured_instances = [str(v) for v in (plugin_obj.configured_instances() or []) if str(v).strip()] + except Exception: + configured_instances = [] + + return { + "name": info.canonical_name, + "supported_cmdlets": supported_cmdlets, + "supports_search": bool(info.supports_search), + "supports_upload": bool(info.supports_upload), + "supports_pipe_download": bool(supports_pipe_download), + "supports_delete_file": bool(supports_delete_file), + "is_multi_instance": bool(info.is_multi_instance), + "configured_instances": configured_instances, + } + + def selection_auto_stage_for_table( table_type: str, stage_args: Optional[Sequence[str]] = None, @@ -565,12 +612,30 @@ def _supports_upload(provider: Provider) -> bool: return exposed and _class_supports_method(provider.__class__, "upload", Provider.upload) +def _supports_pipe_result_download(provider: Provider) -> bool: + return _class_supports_method( + provider.__class__, + "resolve_pipe_result_download", + Provider.resolve_pipe_result_download, + ) + + +def _supports_delete_file(provider: Provider) -> bool: + method = getattr(provider.__class__, "delete_file", None) + base_method = getattr(Provider, "delete_file", None) + return callable(method) and method is not base_method + + def _supports_capability(provider: Provider, capability: str) -> bool: capability_key = str(capability or "").strip().lower() if capability_key == "search": return _supports_search(provider) if capability_key in {"upload", "file", "file-provider"}: return _supports_upload(provider) + if capability_key in {"pipe-download", "pipe_result_download", "pipe-result-download"}: + return _supports_pipe_result_download(provider) + if capability_key in {"delete-file", "delete_file", "delete"}: + return _supports_delete_file(provider) if capability_key in {"pipe-item-context", "pipe-context"}: return _class_supports_method( provider.__class__, @@ -592,6 +657,16 @@ def _info_supports_capability(info: PluginInfo, capability: str) -> bool: return bool(info.supports_search) if capability_key in {"upload", "file", "file-provider"}: return bool(info.supports_upload) + if capability_key in {"pipe-download", "pipe_result_download", "pipe-result-download"}: + return _class_supports_method( + info.plugin_class, + "resolve_pipe_result_download", + Provider.resolve_pipe_result_download, + ) + if capability_key in {"delete-file", "delete_file", "delete"}: + method = getattr(info.plugin_class, "delete_file", None) + base_method = getattr(Provider, "delete_file", None) + return callable(method) and method is not base_method if capability_key in {"pipe-item-context", "pipe-context"}: return _class_supports_method( info.plugin_class, @@ -762,8 +837,11 @@ def list_configured_plugin_names_with_capability( name = info.canonical_name if info.is_multi_instance: try: - instances = info.plugin_class(cfg).configured_instances() - if instances: + plugin_obj = info.plugin_class(cfg) + instances = plugin_obj.configured_instances() + # Treat explicit multi-instance names as configured, but also allow + # a default/single config block for multi-instance plugins. + if instances or bool(plugin_obj.plugin_config_root()): result.append(name) except Exception: pass @@ -774,6 +852,55 @@ def list_configured_plugin_names_with_capability( return sorted(result) +def list_plugin_names_for_cmdlet( + cmdlet_name: str, + config: Optional[Dict[str, Any]] = None, + *, + configured_only: bool = False, +) -> List[str]: + """Return plugin names suitable for a cmdlet. + + Priority: + 1) Plugins that explicitly declare the cmdlet in SUPPORTED_CMDLETS. + 2) Capability fallback for legacy plugins that do not yet declare cmdlets. + """ + cmd = str(cmdlet_name or "").strip().lower() + if not cmd: + return [] + + supported = { + info.canonical_name for info in REGISTRY.get_plugins_for_cmdlet(cmd) + } + + fallback_capability = { + "search-file": "search", + "add-file": "upload", + "download-file": "search", + "delete-file": "delete-file", + }.get(cmd) + + if fallback_capability: + supported.update(list_plugin_names_with_capability(fallback_capability)) + + if not configured_only: + return sorted(supported) + + cfg = config or {} + configured: set[str] = set() + if fallback_capability: + configured.update(list_configured_plugin_names_with_capability(fallback_capability, cfg)) + + # Keep cmdlet-declared plugins if they appear configured in plugin/provider sections. + plugin_section: Dict[str, Any] = cfg.get("plugin") or {} # type: ignore[assignment] + provider_section: Dict[str, Any] = cfg.get("provider") or {} # type: ignore[assignment] + for name in supported: + key = str(name or "").strip().lower() + if isinstance(plugin_section.get(key), dict) or isinstance(provider_section.get(key), dict): + configured.add(name) + + return sorted(configured) + + def match_plugin_name_for_url(url: str) -> Optional[str]: raw_url = str(url or "").strip() raw_url_lower = raw_url.lower() @@ -990,11 +1117,13 @@ __all__ = [ "get_plugin_with_capability", "list_plugins_with_capability", "list_plugin_names_with_capability", + "list_plugin_names_for_cmdlet", "list_configured_plugin_names_with_capability", "match_plugin_name_for_url", "get_plugin_for_url", "list_selection_url_prefixes", "get_plugin_class", + "get_plugin_capabilities", "selection_auto_stage_for_table", "plugin_inline_query_choices", "is_known_plugin_name", diff --git a/TUI/modalscreen/config_modal.py b/TUI/modalscreen/config_modal.py index d527fe1..23e609d 100644 --- a/TUI/modalscreen/config_modal.py +++ b/TUI/modalscreen/config_modal.py @@ -33,7 +33,7 @@ from SYS.plugin_config import ( get_item_schema_map, get_required_config_keys, ) -from ProviderCore.registry import get_plugin, get_plugin_class +from ProviderCore.registry import get_plugin, get_plugin_class, get_plugin_capabilities from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker from TUI.modalscreen.selection_modal import SelectionModal import logging @@ -459,6 +459,21 @@ class ConfigModal(ModalScreen): ) container.mount(row) + def _plugin_capability_summary(self, plugin_name: str) -> str: + caps = get_plugin_capabilities(plugin_name, self.config_data) + cmdlets = [str(v) for v in (caps.get("supported_cmdlets") or []) if str(v).strip()] + if caps.get("supports_pipe_download") and "pipe-download" not in cmdlets: + cmdlets.append("pipe-download") + if not cmdlets: + return "" + return ", ".join(sorted(set(cmdlets))) + + def _plugin_label_with_capabilities(self, base_label: str, plugin_name: str) -> str: + summary = self._plugin_capability_summary(plugin_name) + if not summary: + return base_label + return f"{base_label} [caps: {summary}]" + def render_providers(self, container: ScrollableContainer) -> None: container.mount(Label("Configured Plugins", classes="config-label")) providers = self.config_data.get("provider", {}) @@ -482,8 +497,12 @@ class ConfigModal(ModalScreen): del_id = f"del-provider-{idx}" self._button_id_map[edit_id] = ("edit", f"plugin-{plugin_name}", instance_name) self._button_id_map[del_id] = ("del", f"plugin-{plugin_name}", instance_name) + row_label = self._plugin_label_with_capabilities( + f"{display_name} ({plugin_name})", + str(plugin_name), + ) row = Horizontal( - Static(f"{display_name} ({plugin_name})", classes="item-label"), + Static(row_label, classes="item-label"), Button("Edit", id=edit_id), Button("Delete", variant="error", id=del_id), classes="item-row" @@ -496,8 +515,9 @@ class ConfigModal(ModalScreen): del_id = f"del-provider-{idx}" self._button_id_map[edit_id] = ("edit", "plugin", plugin_name) self._button_id_map[del_id] = ("del", "plugin", plugin_name) + row_label = self._plugin_label_with_capabilities(str(plugin_name), str(plugin_name)) row = Horizontal( - Static(plugin_name, classes="item-label"), + Static(row_label, classes="item-label"), Button("Edit", id=edit_id), Button("Delete", variant="error", id=del_id), classes="item-row" @@ -530,16 +550,20 @@ class ConfigModal(ModalScreen): item_name = str(self.editing_item_name or "") item_schema_map = get_item_schema_map(item_type, item_name) render_state = {"group": None, "mounted_any": False} + provider_for_caps: Optional[str] = None # Parse item_type: plugin-{ptype} (multi-instance) or flat type if item_type.startswith("plugin-"): ptype = item_type[len("plugin-"):] container.mount(Label(f"Editing {ptype}: {item_name}", classes="config-label")) + provider_for_caps = str(ptype) plugin_block = self.config_data.get("plugin") or self.config_data.get("provider") or {} plugin_instances = plugin_block.get(ptype, {}) if isinstance(plugin_block, dict) else {} section = plugin_instances.get(item_name, {}) if isinstance(plugin_instances, dict) else {} else: container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label")) + if item_type in ("plugin", "provider"): + provider_for_caps = str(item_name) section = self.config_data.get(item_type, {}).get(item_name, {}) # Use columns for better layout of inputs with paste buttons @@ -549,6 +573,11 @@ class ConfigModal(ModalScreen): # actually we don't need to do anything else here because refresh_view calls render_item_editor # which now handles the paste buttons. + if provider_for_caps: + caps_summary = self._plugin_capability_summary(provider_for_caps) + if caps_summary: + container.mount(Static(f"Capabilities: {caps_summary}", classes="config-help")) + # Show all existing keys existing_keys_upper = set() idx = 0 diff --git a/cmdlet/add_file.py b/cmdlet/add_file.py index 70681b5..05a27e3 100644 --- a/cmdlet/add_file.py +++ b/cmdlet/add_file.py @@ -98,7 +98,7 @@ DEBUG_PIPE_NOTE_PREVIEW_LENGTH = 256 # Used by multiple methods in this file to guard against URL strings being # treated as local file paths. _REMOTE_URL_PREFIXES: tuple[str, ...] = ( - "http://", "https://", "magnet:", "torrent:", "tidal:", "hydrus:", + "http://", "https://", "ftp://", "ftps://", "magnet:", "torrent:", "tidal:", "hydrus:", ) @@ -1241,6 +1241,11 @@ class Add_File(Cmdlet): return None, None if not url_text.lower().startswith(_REMOTE_URL_PREFIXES): return None, None + # This helper performs generic HTTP downloads only. + # Non-HTTP schemes (e.g. hydrus://, tidal:) should be handled by + # plugin-specific resolvers via _maybe_download_plugin_result. + if not url_text.lower().startswith(("http://", "https://")): + return None, None tmp_dir: Optional[Path] = None try: @@ -1480,8 +1485,31 @@ class Add_File(Cmdlet): if candidate: s = str(candidate).lower() if s.startswith(_REMOTE_URL_PREFIXES): - log("add-file ingests local files only. Use download-file first.", file=sys.stderr) - return None, None, None + # For remote sources, prefer plugin-specific resolvers first + # (e.g. hydrus://), then generic HTTP fallback. + downloaded_path, hash_hint, tmp_dir = Add_File._maybe_download_plugin_result( + result, + pipe_obj, + config, + deps=deps, + ) + if downloaded_path: + pipe_obj.path = str(downloaded_path) + return downloaded_path, hash_hint, tmp_dir + + dl_path, tmp_dir = Add_File._download_remote_backend_url( + str(candidate), + pipe_obj, + file_hash=get_field(result, "hash") or get_field(result, "file_hash"), + output_dir=export_destination, + ) + if dl_path: + pipe_obj.path = str(dl_path) + hash_hint = get_field(result, "hash") or get_field(result, "file_hash") + return dl_path, hash_hint, tmp_dir + + log("add-file could not auto-fetch remote source. Use download-file first.", file=sys.stderr) + return None, None, None pipe_obj.path = str(candidate) # Retain hash from input if available to avoid re-hashing diff --git a/cmdlet/delete_file.py b/cmdlet/delete_file.py index 788036a..fb11bbe 100644 --- a/cmdlet/delete_file.py +++ b/cmdlet/delete_file.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any, Dict, List, Sequence +import posixpath import sys from pathlib import Path @@ -128,6 +129,27 @@ class Delete_File(sh.Cmdlet): else: store = sh.get_field(item, "store") + # Extract plugin/provider identity and full metadata for plugin-level dispatch + provider_name = None + full_metadata: Dict[str, Any] = {} + if isinstance(item, dict): + provider_name = item.get("provider") or item.get("table") + raw_meta = item.get("full_metadata") or item.get("metadata") + if isinstance(raw_meta, dict): + full_metadata = raw_meta + else: + try: + provider_name = sh.get_field(item, "provider") or sh.get_field(item, "table") + except Exception: + pass + try: + raw_meta = sh.get_field(item, "full_metadata") or sh.get_field(item, "metadata") + if isinstance(raw_meta, dict): + full_metadata = raw_meta + except Exception: + pass + provider_name = str(provider_name or "").strip().lower() or None + store_lower = str(store).lower() if store else "" hydrus_provider = get_plugin("hydrusnetwork", config) @@ -169,14 +191,51 @@ class Delete_File(sh.Cmdlet): ) local_deleted = False + _target_str = str(target).strip().lower() if isinstance(target, str) else "" local_target = ( - isinstance(target, - str) and target.strip() - and not str(target).lower().startswith(("http://", - "https://")) + isinstance(target, str) and target.strip() + and not _target_str.startswith(("http://", "https://", "ftp://", "ftps://")) ) deleted_rows: List[Dict[str, Any]] = [] + # --- Plugin-level delete dispatch --- + # When the item originates from a plugin (e.g. FTP), and that plugin exposes + # a delete_file() method, delegate to it instead of attempting a local unlink. + if conserve != "local" and provider_name and not is_hydrus_store: + try: + candidate_plugin = get_plugin(provider_name, config) + plugin_deleter = getattr(candidate_plugin, "delete_file", None) if candidate_plugin else None + if callable(plugin_deleter): + # Prefer ftp_path from full_metadata; fall back to the path/url field + remote = ( + full_metadata.get("ftp_path") + or full_metadata.get("selection_url") + or full_metadata.get("ftp_url") + or (str(target).strip() if isinstance(target, str) else "") + ) + instance_hint = full_metadata.get("instance") or None + if remote: + plugin_ok = bool(plugin_deleter(remote, instance=instance_hint)) + if plugin_ok: + local_deleted = True + size_hint = ( + full_metadata.get("size") + or (item.get("size_bytes") if isinstance(item, dict) else None) + or sh.get_field(item, "size_bytes") + ) + deleted_rows.append( + { + "title": str(title_val).strip() if title_val else posixpath.basename(str(remote).rstrip("/")), + "store": instance_hint or provider_name, + "hash": hash_hex or "", + "size_bytes": size_hint, + "ext": _get_ext_from_item(), + } + ) + return deleted_rows + except Exception: + pass + # If this item references a configured non-Hydrus store backend, prefer deleting # via the backend API. This supports store items where `path`/`target` is the hash. if conserve != "local" and store and (not is_hydrus_store): diff --git a/plugins/ftp/__init__.py b/plugins/ftp/__init__.py index 7f13da4..d491b03 100644 --- a/plugins/ftp/__init__.py +++ b/plugins/ftp/__init__.py @@ -73,7 +73,7 @@ class FTP(Provider): PLUGIN_NAME = "ftp" URL = ("ftp://", "ftps://") MULTI_INSTANCE = True - SUPPORTED_CMDLETS = frozenset({"add-file", "get-file", "search-file"}) + SUPPORTED_CMDLETS = frozenset({"add-file", "delete-file", "get-file", "search-file"}) @property def label(self) -> str: @@ -576,6 +576,41 @@ class FTP(Provider): return self._build_url(remote_path, settings=settings) + def delete_file(self, remote_path_or_url: str, **kwargs: Any) -> bool: + """Delete a file from the FTP server. + + Accepts either a full FTP URL (ftp://host/path/file) or a raw remote + path (/path/to/file). Returns True on success, False on failure. + """ + path_text = str(remote_path_or_url or "").strip() + if not path_text: + return False + + settings = self._resolve_settings( + instance_name=str(kwargs.get("instance") or kwargs.get("store") or "").strip() or None, + require_explicit=bool(kwargs.get("instance") or kwargs.get("store")), + ) + if not settings.get("host"): + requested = str(kwargs.get("instance") or kwargs.get("store") or "").strip() + if requested: + raise RuntimeError(f"FTP instance '{requested}' is unavailable") + raise RuntimeError("No configured FTP instance is available") + + # Accept full FTP URL or raw path + if path_text.startswith(("ftp://", "ftps://")): + remote_path = self._normalize_remote_path(path_text, default=self._base_path) + else: + remote_path = self._normalize_remote_path(path_text, default=str(settings.get("base_path") or "/")) + + ftp = self._connect(settings=settings) + try: + ftp.delete(remote_path) + return True + except ftplib.error_perm: + return False + finally: + self._close(ftp) + def _connect( self, *,