diff --git a/CLI.py b/CLI.py index 0533b4b..99aa724 100644 --- a/CLI.py +++ b/CLI.py @@ -98,7 +98,7 @@ from SYS.result_table import Table from SYS.worker import WorkerManagerRegistry, WorkerStages, WorkerOutputMirror, WorkerStageSession from SYS.pipeline import PipelineExecutor -from PluginCore.registry import plugin_inline_query_choices +from PluginCore.registry import plugin_inline_query_choices, plugin_query_field_map @@ -1242,10 +1242,13 @@ class CmdletCompleter(Completer): if query_fragment is not None: field_choices: Dict[str, List[str]] = {} ordered_fields: List[str] = [] + query_only_field_names: set = set() for spec in query_specs: key = str(spec.get("query_key") or spec.get("name") or "").strip().lower() if not key: continue + if spec.get("query_only"): + query_only_field_names.add(key) if key not in field_choices: ordered_fields.append(key) field_choices[key] = [str(choice) for choice in list(spec.get("choices", []) or [])] @@ -1255,6 +1258,31 @@ class CmdletCompleter(Completer): continue field_choices.setdefault(alias_text, field_choices[key]) + plugin_field_map: Dict[str, List[str]] = {} + if selected_plugin: + try: + plugin_field_map = plugin_query_field_map(selected_plugin, config) + except Exception: + plugin_field_map = {} + if plugin_field_map: + ordered_fields = [f for f in ordered_fields if f not in query_only_field_names] + removed_keys = set() + for key in query_only_field_names: + field_choices.pop(key, None) + removed_keys.add(key) + aliases_to_remove = set() + for alias_key, alias_values in list(field_choices.items()): + if alias_key in removed_keys: + continue + if alias_key not in ordered_fields and alias_key not in plugin_field_map: + aliases_to_remove.add(alias_key) + for akey in aliases_to_remove: + field_choices.pop(akey, None) + for field_name, choices in plugin_field_map.items(): + if field_name not in field_choices: + ordered_fields.append(field_name) + field_choices[field_name] = choices + raw_fragment = str(query_fragment or "") segment = raw_fragment[1:] if raw_fragment[:1] in {"'", '"'} else raw_fragment if "," in segment: @@ -1267,8 +1295,8 @@ class CmdletCompleter(Completer): partial_lower = partial.strip().lower() inline_choices = [] - if effective_cmd == "search-file" and provider_name: - inline_choices = self._inline_query_choices(provider_name, field, config) + if selected_plugin and effective_cmd in {"search-file", "download-file"}: + inline_choices = self._inline_query_choices(selected_plugin, field, config) choice_pool = inline_choices or field_choices.get(field, []) if choice_pool: diff --git a/PluginCore/registry.py b/PluginCore/registry.py index 55a892c..c592b19 100644 --- a/PluginCore/registry.py +++ b/PluginCore/registry.py @@ -863,6 +863,36 @@ def plugin_inline_query_choices( return [] +def plugin_query_field_map( + plugin_name: str, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, List[str]]: + pname = str(plugin_name or "").strip().lower() + if not pname: + return {} + try: + info = REGISTRY.get(pname) + if info is not None: + mapping = collect_choice(info.plugin_class) + else: + plugin = get_plugin(pname, config) + if plugin is None: + return {} + mapping = collect_choice(plugin) + result: Dict[str, List[str]] = {} + for field, choices in mapping.items(): + texts = [] + for c in choices: + text = str(c.get("text") or c.get("value") or "") + if text: + texts.append(text) + if texts: + result[field] = texts + return result + except Exception: + return {} + + def get_plugin_for_url(url: str, config: Optional[Dict[str, Any]] = None) -> Optional[Provider]: name = match_plugin_name_for_url(url) diff --git a/SYS/pipeline.py b/SYS/pipeline.py index 53d1473..e549516 100644 --- a/SYS/pipeline.py +++ b/SYS/pipeline.py @@ -2076,8 +2076,8 @@ class PipelineExecutor: except Exception: row_action = None if row_action: - prefer_row_action = True - preferred_row_action = list(row_action) + stages.insert(0, list(row_action)) + return True, None # Command expansion via @N: # - Default behavior: expand ONLY for single-row selections. # - Special case: allow multi-row expansion for add-file directory tables by diff --git a/cmdnat/config.py b/cmdnat/config.py index 03d4aed..67b43fd 100644 --- a/cmdnat/config.py +++ b/cmdnat/config.py @@ -32,6 +32,7 @@ _PREFERENCES_BROWSE_PATH = "__preferences__" _PLUGINS_BROWSE_PATH = "__plugins__" _PLUGIN_CATEGORY_KEYS = ("plugin",) _CREATE_INSTANCE_FLAG = "-create-instance" +_CHOOSE_FLAG = "-choose" _KNOWN_SECTION_LABELS = { "plugin": "Plugins", } @@ -65,6 +66,7 @@ _CONFIG_ITEM_FIELDS = ( "type", "display_path", "instance_target", + "choices", ) CMDLET = Cmdlet( @@ -576,6 +578,7 @@ def _build_value_item( key_path: str, name: str, value: Any, + choices: Any = None, ) -> Dict[str, Any]: display_value = "***" if _is_sensitive_config_key(key_path) else _format_config_value(value) path_parts = [part for part in str(key_path or "").split(".") if part] @@ -583,6 +586,7 @@ def _build_value_item( [_format_config_path_label(".".join(path_parts[:-1]))] if len(path_parts) > 1 else [] + [_format_config_label(path_parts[-1])] if path_parts else [_format_config_label(name)] ) + choice_list = list(choices) if isinstance(choices, (list, tuple)) else [] return { "kind": "value", "key": key_path, @@ -592,6 +596,7 @@ def _build_value_item( "value_display": display_value, "display_path": display_path, "type": type(value).__name__, + "choices": choice_list, } @@ -700,6 +705,25 @@ def _build_root_config_items(config_data: Dict[str, Any]) -> List[Dict[str, Any] return items +def _lookup_plugin_choices(browse_path: str, key_name: str) -> Optional[List[str]]: + parts = _split_config_path(browse_path) + if len(parts) < 2 or parts[0] != "plugin": + return None + plugin_name = parts[1] + try: + from SYS.plugin_config import get_plugin_schema + schema = get_plugin_schema(plugin_name) + except Exception: + return None + for field in schema: + if field.get("key") == key_name: + choices = field.get("choices") + if isinstance(choices, list): + return [str(c) for c in choices] + break + return None + + def _build_nested_config_items( config_data: Dict[str, Any], browse_path: str, @@ -737,6 +761,7 @@ def _build_nested_config_items( key_path=full_key, name=key, value=value, + choices=_lookup_plugin_choices(browse_path, key), ) ) @@ -799,6 +824,21 @@ def _extract_browse_arg(args: Sequence[str]) -> Optional[str]: return _extract_arg_value(args, flags={"-browse", "--browse"}, allow_positional=False) +def _extract_choose_arg(args: Sequence[str]) -> Optional[str]: + return _extract_arg_value(args, flags={_CHOOSE_FLAG}, allow_positional=False) + + +def _resolve_config_value_raw(config: Dict[str, Any], key_path: str) -> Any: + parts = [p for p in str(key_path or "").split(".") if p] + current: Any = config + for part in parts: + if isinstance(current, dict): + current = current.get(part) + else: + return None + return current + + def _extract_selected_update_value(args: Sequence[str]) -> Optional[str]: explicit = _extract_arg_value(args, flags=VALUE_ARG_FLAGS, allow_positional=False) if explicit is not None: @@ -871,6 +911,44 @@ def _normalize_config_item(candidate: Any) -> Optional[Dict[str, Any]]: return normalized or None +def _show_choice_table( + target_key: str, + choices: List[str], + current_value: str, + display_name: str, +) -> int: + items: List[Dict[str, Any]] = [] + for choice in choices: + choice_str = str(choice) + marker = " ← current" if choice_str == current_value else "" + items.append({ + "kind": "choice", + "key": target_key, + "title": choice_str, + "value": choice_str, + "display": f"{choice_str}{marker}", + }) + + table = Table(f"Configuration: {display_name}", preserve_order=True) + table.set_table("config") + table.set_source_command(".config", [target_key]) + table.set_header_lines([ + f"Available values for {display_name}. Current: {current_value}", + "Use @N to select a value. Use @.. to go back.", + ]) + + for idx, item in enumerate(items): + row = table.add_row() + row.add_column("Choice", item.get("display", item.get("title", ""))) + table.set_row_selection_action(idx, [".config", str(item.get("key")), str(item.get("value"))]) + + ctx.set_last_result_table(table, items) + ctx.set_current_stage_table(table) + print() + print(table) + return 0 + + def _show_config_table( config_data: Dict[str, Any], *, @@ -893,13 +971,23 @@ def _show_config_table( for idx, item in enumerate(items): row = table.add_row() row.add_column("Name", item.get("title", "")) - row.add_column("Value", item.get("summary") or item.get("value_display", "")) + value_text = item.get("summary") or item.get("value_display", "") + choices = item.get("choices") + if choices and isinstance(choices, list) and len(choices) > 0: + choices_repr = ", ".join(str(c) for c in choices) + value_text = f"{value_text} [{choices_repr}]" + row.add_column("Value", value_text) row.add_column("Type", item.get("type", "")) if item.get("kind") == "section" and item.get("browse_path"): table.set_row_selection_action( idx, [".config", "-browse", str(item.get("browse_path"))], ) + elif item.get("kind") == "value" and choices and isinstance(choices, list) and len(choices) > 0 and item.get("key"): + table.set_row_selection_action( + idx, + [".config", "-choose", str(item.get("key"))], + ) elif item.get("kind") == "create_instance" and item.get("instance_target"): table.set_row_selection_action( idx, @@ -966,6 +1054,22 @@ def _run(piped_result: Any, args: List[str], config: Dict[str, Any]) -> int: if browse_path: return _show_config_table(current_config, browse_path=browse_path) + choose_key = _extract_choose_arg(args) + if choose_key: + choices = _lookup_plugin_choices(choose_key, choose_key.split(".")[-1]) or [] + if not choices: + nested_branch = _resolve_config_branch(current_config, choose_key) + if isinstance(nested_branch, dict): + return _show_config_table(current_config, browse_path=choose_key) + print(f"No choices available for '{_format_config_path_label(choose_key)}'") + return 0 + try: + current_value = str(_resolve_config_value_raw(current_config, choose_key) or "").strip() + except Exception: + current_value = "" + display_name = _format_config_path_label(choose_key) + return _show_choice_table(choose_key, choices, current_value, display_name) + selection_item = _get_selected_config_item() or _normalize_config_item(piped_result) create_instance_target = _extract_create_instance_target(args) @@ -1025,6 +1129,31 @@ def _run(piped_result: Any, args: List[str], config: Dict[str, Any]) -> int: log(f"Error updating config '{target_key}': {exc}") print(f"Error updating config: {exc}") return 1 + choices = (selection_item or {}).get("choices") + if choices and isinstance(choices, list) and len(choices) > 0: + current_value = str((selection_item or {}).get("value") or "").strip() + display_name = selection_display_path or selection_key + return _show_choice_table(selection_key, choices, current_value, display_name) + + if selection_kind == "choice" and selection_key: + choice_value = str((selection_item or {}).get("value") or "").strip() + if choice_value: + try: + target_key = _resolve_update_key(current_config, selection_key) + set_nested_config(current_config, target_key, choice_value) + _save_updated_config(current_config, target_key) + print(f"Updated '{_format_config_path_label(selection_key)}' to '{choice_value}'") + current_config = load_config() + key_parts = [p for p in str(selection_key).split(".") if p] + if len(key_parts) >= 2: + parent_path = ".".join(key_parts[:-1]) + if _resolve_config_branch(current_config, parent_path): + return _show_config_table(current_config, browse_path=parent_path) + return 0 + except Exception as exc: + log(f"Error updating config '{selection_key}': {exc}") + print(f"Error updating config: {exc}") + return 1 if not args: if sys.stdin.isatty() and not piped_result: @@ -1047,6 +1176,16 @@ def _run(piped_result: Any, args: List[str], config: Dict[str, Any]) -> int: set_nested_config(current_config, key, value) _save_updated_config(current_config, key) print(f"Updated '{key}' to '{value}'") + current_config = load_config() + parent_path = _resolve_direct_browse_path(current_config, key) + if not parent_path: + key_parts = [p for p in str(key).split(".") if p] + if len(key_parts) >= 2: + candidate = ".".join(key_parts[:-1]) + if _resolve_config_branch(current_config, candidate): + parent_path = candidate + if parent_path: + return _show_config_table(current_config, browse_path=parent_path) return 0 except Exception as exc: log(f"Error updating config '{key}': {exc}") diff --git a/plugins/local/__init__.py b/plugins/local/__init__.py index 079b4a7..c280f3c 100644 --- a/plugins/local/__init__.py +++ b/plugins/local/__init__.py @@ -251,6 +251,12 @@ class Local(Provider): folder_name = str(kwargs.get("folder_name") or "").strip() if not folder_name and not direct_export_download: folder_name = self._folder_name_from_pipe(kwargs.get("pipe_obj")) + if not folder_name and not direct_export_download: + title_hint = str(kwargs.get("title") or "").strip() + if not title_hint: + title_hint = source_path.stem.replace("_", " ").strip() + if title_hint: + folder_name = title_hint export_root = destination_root if folder_name and not direct_export_download: diff --git a/plugins/openlibrary/__init__.py b/plugins/openlibrary/__init__.py index 9d354eb..9c4cc30 100644 --- a/plugins/openlibrary/__init__.py +++ b/plugins/openlibrary/__init__.py @@ -612,6 +612,13 @@ class OpenLibrary(Provider): TABLE_AUTO_STAGES = { "openlibrary.edition": ["download-file"], } + QUERY_ARG_CHOICES = { + "quality": ["high", "medium", "low"], + "language": [ + "english", "spanish", "french", "german", "italian", + "portuguese", "polish", "russian", "chinese", "japanese", + ], + } @classmethod def config_schema(cls) -> List[Dict[str, Any]]: @@ -1175,7 +1182,7 @@ class OpenLibrary(Provider): Returns a dict with the downloaded path and SearchResult when successful. """ - self, + sr = self.search_result_from_url(url) if sr is None: return None