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": if normalized_arg == "plugin":
canonical_cmd = (cmd_name or "").replace("_", "-").lower() canonical_cmd = (cmd_name or "").replace("_", "-").lower()
try: 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: except Exception:
list_configured_plugin_names_with_capability = None # type: ignore list_configured_plugin_names_with_capability = None # type: ignore
list_plugin_names_for_cmdlet = None # type: ignore
plugin_choices: List[str] = [] plugin_choices: List[str] = []
if canonical_cmd in {"add-file"} and list_configured_plugin_names_with_capability is not None: if list_plugin_names_for_cmdlet is not None:
return list_configured_plugin_names_with_capability("upload", config) or [] configured = list_plugin_names_for_cmdlet(
canonical_cmd,
if list_configured_plugin_names_with_capability is not None: 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 [] plugin_choices = list_configured_plugin_names_with_capability("search", config) or []
if plugin_choices: if plugin_choices:
+131 -2
View File
@@ -530,6 +530,53 @@ def get_plugin_class(name: str) -> Optional[Type[Provider]]:
return info.plugin_class 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( def selection_auto_stage_for_table(
table_type: str, table_type: str,
stage_args: Optional[Sequence[str]] = None, 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) 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: def _supports_capability(provider: Provider, capability: str) -> bool:
capability_key = str(capability or "").strip().lower() capability_key = str(capability or "").strip().lower()
if capability_key == "search": if capability_key == "search":
return _supports_search(provider) return _supports_search(provider)
if capability_key in {"upload", "file", "file-provider"}: if capability_key in {"upload", "file", "file-provider"}:
return _supports_upload(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"}: if capability_key in {"pipe-item-context", "pipe-context"}:
return _class_supports_method( return _class_supports_method(
provider.__class__, provider.__class__,
@@ -592,6 +657,16 @@ def _info_supports_capability(info: PluginInfo, capability: str) -> bool:
return bool(info.supports_search) return bool(info.supports_search)
if capability_key in {"upload", "file", "file-provider"}: if capability_key in {"upload", "file", "file-provider"}:
return bool(info.supports_upload) 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"}: if capability_key in {"pipe-item-context", "pipe-context"}:
return _class_supports_method( return _class_supports_method(
info.plugin_class, info.plugin_class,
@@ -762,8 +837,11 @@ def list_configured_plugin_names_with_capability(
name = info.canonical_name name = info.canonical_name
if info.is_multi_instance: if info.is_multi_instance:
try: try:
instances = info.plugin_class(cfg).configured_instances() plugin_obj = info.plugin_class(cfg)
if instances: 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) result.append(name)
except Exception: except Exception:
pass pass
@@ -774,6 +852,55 @@ def list_configured_plugin_names_with_capability(
return sorted(result) 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]: def match_plugin_name_for_url(url: str) -> Optional[str]:
raw_url = str(url or "").strip() raw_url = str(url or "").strip()
raw_url_lower = raw_url.lower() raw_url_lower = raw_url.lower()
@@ -990,11 +1117,13 @@ __all__ = [
"get_plugin_with_capability", "get_plugin_with_capability",
"list_plugins_with_capability", "list_plugins_with_capability",
"list_plugin_names_with_capability", "list_plugin_names_with_capability",
"list_plugin_names_for_cmdlet",
"list_configured_plugin_names_with_capability", "list_configured_plugin_names_with_capability",
"match_plugin_name_for_url", "match_plugin_name_for_url",
"get_plugin_for_url", "get_plugin_for_url",
"list_selection_url_prefixes", "list_selection_url_prefixes",
"get_plugin_class", "get_plugin_class",
"get_plugin_capabilities",
"selection_auto_stage_for_table", "selection_auto_stage_for_table",
"plugin_inline_query_choices", "plugin_inline_query_choices",
"is_known_plugin_name", "is_known_plugin_name",
+32 -3
View File
@@ -33,7 +33,7 @@ from SYS.plugin_config import (
get_item_schema_map, get_item_schema_map,
get_required_config_keys, 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.matrix_room_picker import MatrixRoomPicker
from TUI.modalscreen.selection_modal import SelectionModal from TUI.modalscreen.selection_modal import SelectionModal
import logging import logging
@@ -459,6 +459,21 @@ class ConfigModal(ModalScreen):
) )
container.mount(row) 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: def render_providers(self, container: ScrollableContainer) -> None:
container.mount(Label("Configured Plugins", classes="config-label")) container.mount(Label("Configured Plugins", classes="config-label"))
providers = self.config_data.get("provider", {}) providers = self.config_data.get("provider", {})
@@ -482,8 +497,12 @@ class ConfigModal(ModalScreen):
del_id = f"del-provider-{idx}" del_id = f"del-provider-{idx}"
self._button_id_map[edit_id] = ("edit", f"plugin-{plugin_name}", instance_name) 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) 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( row = Horizontal(
Static(f"{display_name} ({plugin_name})", classes="item-label"), Static(row_label, classes="item-label"),
Button("Edit", id=edit_id), Button("Edit", id=edit_id),
Button("Delete", variant="error", id=del_id), Button("Delete", variant="error", id=del_id),
classes="item-row" classes="item-row"
@@ -496,8 +515,9 @@ class ConfigModal(ModalScreen):
del_id = f"del-provider-{idx}" del_id = f"del-provider-{idx}"
self._button_id_map[edit_id] = ("edit", "plugin", plugin_name) self._button_id_map[edit_id] = ("edit", "plugin", plugin_name)
self._button_id_map[del_id] = ("del", "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( row = Horizontal(
Static(plugin_name, classes="item-label"), Static(row_label, classes="item-label"),
Button("Edit", id=edit_id), Button("Edit", id=edit_id),
Button("Delete", variant="error", id=del_id), Button("Delete", variant="error", id=del_id),
classes="item-row" classes="item-row"
@@ -530,16 +550,20 @@ class ConfigModal(ModalScreen):
item_name = str(self.editing_item_name or "") item_name = str(self.editing_item_name or "")
item_schema_map = get_item_schema_map(item_type, item_name) item_schema_map = get_item_schema_map(item_type, item_name)
render_state = {"group": None, "mounted_any": False} render_state = {"group": None, "mounted_any": False}
provider_for_caps: Optional[str] = None
# Parse item_type: plugin-{ptype} (multi-instance) or flat type # Parse item_type: plugin-{ptype} (multi-instance) or flat type
if item_type.startswith("plugin-"): if item_type.startswith("plugin-"):
ptype = item_type[len("plugin-"):] ptype = item_type[len("plugin-"):]
container.mount(Label(f"Editing {ptype}: {item_name}", classes="config-label")) 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_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 {} plugin_instances = plugin_block.get(ptype, {}) if isinstance(plugin_block, dict) else {}
section = plugin_instances.get(item_name, {}) if isinstance(plugin_instances, dict) else {} section = plugin_instances.get(item_name, {}) if isinstance(plugin_instances, dict) else {}
else: else:
container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label")) 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, {}) section = self.config_data.get(item_type, {}).get(item_name, {})
# Use columns for better layout of inputs with paste buttons # 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 # actually we don't need to do anything else here because refresh_view calls render_item_editor
# which now handles the paste buttons. # 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 # Show all existing keys
existing_keys_upper = set() existing_keys_upper = set()
idx = 0 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 # Used by multiple methods in this file to guard against URL strings being
# treated as local file paths. # treated as local file paths.
_REMOTE_URL_PREFIXES: tuple[str, ...] = ( _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 return None, None
if not url_text.lower().startswith(_REMOTE_URL_PREFIXES): if not url_text.lower().startswith(_REMOTE_URL_PREFIXES):
return None, None 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 tmp_dir: Optional[Path] = None
try: try:
@@ -1480,8 +1485,31 @@ class Add_File(Cmdlet):
if candidate: if candidate:
s = str(candidate).lower() s = str(candidate).lower()
if s.startswith(_REMOTE_URL_PREFIXES): if s.startswith(_REMOTE_URL_PREFIXES):
log("add-file ingests local files only. Use download-file first.", file=sys.stderr) # For remote sources, prefer plugin-specific resolvers first
return None, None, None # (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) pipe_obj.path = str(candidate)
# Retain hash from input if available to avoid re-hashing # Retain hash from input if available to avoid re-hashing
+63 -4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Sequence from typing import Any, Dict, List, Sequence
import posixpath
import sys import sys
from pathlib import Path from pathlib import Path
@@ -128,6 +129,27 @@ class Delete_File(sh.Cmdlet):
else: else:
store = sh.get_field(item, "store") 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 "" store_lower = str(store).lower() if store else ""
hydrus_provider = get_plugin("hydrusnetwork", config) hydrus_provider = get_plugin("hydrusnetwork", config)
@@ -169,14 +191,51 @@ class Delete_File(sh.Cmdlet):
) )
local_deleted = False local_deleted = False
_target_str = str(target).strip().lower() if isinstance(target, str) else ""
local_target = ( local_target = (
isinstance(target, isinstance(target, str) and target.strip()
str) and target.strip() and not _target_str.startswith(("http://", "https://", "ftp://", "ftps://"))
and not str(target).lower().startswith(("http://",
"https://"))
) )
deleted_rows: List[Dict[str, Any]] = [] 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 # 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. # via the backend API. This supports store items where `path`/`target` is the hash.
if conserve != "local" and store and (not is_hydrus_store): if conserve != "local" and store and (not is_hydrus_store):
+36 -1
View File
@@ -73,7 +73,7 @@ class FTP(Provider):
PLUGIN_NAME = "ftp" PLUGIN_NAME = "ftp"
URL = ("ftp://", "ftps://") URL = ("ftp://", "ftps://")
MULTI_INSTANCE = True MULTI_INSTANCE = True
SUPPORTED_CMDLETS = frozenset({"add-file", "get-file", "search-file"}) SUPPORTED_CMDLETS = frozenset({"add-file", "delete-file", "get-file", "search-file"})
@property @property
def label(self) -> str: def label(self) -> str:
@@ -576,6 +576,41 @@ class FTP(Provider):
return self._build_url(remote_path, settings=settings) 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( def _connect(
self, self,
*, *,