This commit is contained in:
2026-05-04 15:58:24 -07:00
parent bca85defa4
commit 3ce339b3c1
6 changed files with 322 additions and 18 deletions
+29 -5
View File
@@ -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:
+131 -2
View File
@@ -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",
+32 -3
View File
@@ -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
+31 -3
View File
@@ -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
+63 -4
View File
@@ -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):
+36 -1
View File
@@ -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,
*,