updated
This commit is contained in:
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+30
-2
@@ -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,7 +1485,30 @@ 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
|
||||||
|
# (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
|
return None, None, None
|
||||||
|
|
||||||
pipe_obj.path = str(candidate)
|
pipe_obj.path = str(candidate)
|
||||||
|
|||||||
+63
-4
@@ -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
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
Reference in New Issue
Block a user