diff --git a/ProviderCore/base.py b/ProviderCore/base.py index 214aaf9..6cfb629 100644 --- a/ProviderCore/base.py +++ b/ProviderCore/base.py @@ -208,10 +208,13 @@ class Provider(ABC): { "key": "api_key", "label": "API Key", + "group": "Authentication", + "type": "text", # text|boolean|integer|float|path|secret|multiline "default": "", "required": True, "secret": True, - "choices": ["Option 1", "Option 2"] + "choices": ["Option 1", "Option 2"], + "placeholder": "Paste value here" } """ return [] diff --git a/SYS/config.py b/SYS/config.py index 2152f2b..fad4ac3 100644 --- a/SYS/config.py +++ b/SYS/config.py @@ -43,20 +43,25 @@ def global_config() -> List[Dict[str, Any]]: { "key": "debug", "label": "Debug Output", + "group": "Runtime", + "type": "boolean", "default": "false", - "choices": ["true", "false"] + "choices": ["true", "false"], }, { "key": "auto_update", "label": "Auto-Update", + "group": "Runtime", + "type": "boolean", "default": "true", - "choices": ["true", "false"] + "choices": ["true", "false"], }, { "key": "table_appearance", "label": "Table Appearance", + "group": "Display", "default": "rainbow", - "choices": ["plain", "bw-striped", "rainbow"] + "choices": ["plain", "bw-striped", "rainbow"], } ] diff --git a/SYS/plugin_config.py b/SYS/plugin_config.py new file mode 100644 index 0000000..1070345 --- /dev/null +++ b/SYS/plugin_config.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import importlib +import logging +import pkgutil +from typing import Any, Dict, Iterable, List, Optional + +from SYS.config import global_config +from ProviderCore.registry import get_plugin_class, list_plugins +from Store.registry import _discover_store_classes, _required_keys_for + +logger = logging.getLogger(__name__) + + +ConfigField = Dict[str, Any] + + +def _normalize_schema(fields: Optional[Iterable[Any]]) -> List[ConfigField]: + normalized: List[ConfigField] = [] + seen: set[str] = set() + for raw_field in list(fields or []): + if not isinstance(raw_field, dict): + continue + key = str(raw_field.get("key") or "").strip() + if not key: + continue + key_upper = key.upper() + if key_upper in seen: + continue + seen.add(key_upper) + + field = dict(raw_field) + field["key"] = key + if "label" in field and field.get("label") is not None: + field["label"] = str(field.get("label") or "") + choices = field.get("choices") + if choices is not None and not isinstance(choices, (list, tuple)): + field["choices"] = [choices] + elif isinstance(choices, tuple): + field["choices"] = list(choices) + normalized.append(field) + return normalized + + +def _call_schema(owner: Any, label: str) -> List[ConfigField]: + schema_fn = getattr(owner, "config_schema", None) + if not callable(schema_fn): + return [] + try: + return _normalize_schema(schema_fn()) + except Exception: + logger.exception("Failed to load config schema for %s", label) + return [] + + +def get_store_schema(store_type: str) -> List[ConfigField]: + classes = _discover_store_classes() + cls = classes.get(str(store_type or "").strip()) + if cls is None: + return [] + return _call_schema(cls, f"store '{store_type}'") + + +def get_provider_schema(provider_name: str) -> List[ConfigField]: + plugin_class = get_plugin_class(str(provider_name or "").strip()) + if plugin_class is None: + return [] + return _call_schema(plugin_class, f"provider '{provider_name}'") + + +def get_tool_schema(tool_name: str) -> List[ConfigField]: + tool_name = str(tool_name or "").strip() + if not tool_name: + return [] + try: + module = importlib.import_module(f"tool.{tool_name}") + except Exception: + logger.exception("Failed to import tool module 'tool.%s'", tool_name) + return [] + return _call_schema(module, f"tool '{tool_name}'") + + +def get_item_schema(item_type: str, item_name: str) -> List[ConfigField]: + normalized_type = str(item_type or "").strip() + normalized_name = str(item_name or "").strip() + if normalized_type.startswith("store-"): + return get_store_schema(normalized_type.replace("store-", "", 1)) + if normalized_type == "provider": + return get_provider_schema(normalized_name) + if normalized_type == "tool": + return get_tool_schema(normalized_name) + return [] + + +def get_item_schema_map(item_type: str, item_name: str) -> Dict[str, ConfigField]: + return {field["key"].upper(): field for field in get_item_schema(item_type, item_name)} + + +def get_global_schema() -> List[ConfigField]: + return _normalize_schema(global_config()) + + +def get_global_schema_map() -> Dict[str, ConfigField]: + return {field["key"].upper(): field for field in get_global_schema()} + + +def build_default_store_config(store_type: str, instance_name: str) -> Dict[str, Any]: + config: Dict[str, Any] = {"NAME": instance_name} + schema = get_store_schema(store_type) + if schema: + for field in schema: + key = field["key"] + if key.upper() == "NAME": + continue + config[key] = field.get("default", "") + return config + + classes = _discover_store_classes() + cls = classes.get(str(store_type or "").strip()) + if cls is None: + return config + for required_key in _required_keys_for(cls): + if required_key.upper() == "NAME": + continue + config[required_key] = "" + return config + + +def build_default_provider_config(provider_name: str) -> Dict[str, Any]: + config: Dict[str, Any] = {} + schema = get_provider_schema(provider_name) + if schema: + for field in schema: + config[field["key"]] = field.get("default", "") + return config + + plugin_class = get_plugin_class(str(provider_name or "").strip()) + if plugin_class is None: + return config + try: + for required_key in plugin_class.required_config_keys(): + config[str(required_key)] = "" + except Exception: + logger.exception("Failed to load legacy required config keys for provider '%s'", provider_name) + return config + + +def build_default_tool_config(tool_name: str) -> Dict[str, Any]: + config: Dict[str, Any] = {} + for field in get_tool_schema(tool_name): + config[field["key"]] = field.get("default", "") + return config + + +def get_required_config_keys(item_type: str, item_name: str) -> List[str]: + normalized_type = str(item_type or "").strip() + normalized_name = str(item_name or "").strip() + required_keys: List[str] = [] + seen: set[str] = set() + + def _add_key(value: Any) -> None: + key = str(value or "").strip() + if not key: + return + key_upper = key.upper() + if key_upper in seen: + return + seen.add(key_upper) + required_keys.append(key) + + for field in get_item_schema(normalized_type, normalized_name): + if field.get("required"): + _add_key(field.get("key")) + + if normalized_type.startswith("store-"): + store_type = normalized_type.replace("store-", "", 1) + classes = _discover_store_classes() + cls = classes.get(store_type) + if cls is not None: + for required_key in _required_keys_for(cls): + _add_key(required_key) + elif normalized_type == "provider": + plugin_class = get_plugin_class(normalized_name) + if plugin_class is not None: + try: + for required_key in plugin_class.required_config_keys(): + _add_key(required_key) + except Exception: + logger.exception("Failed to load required config keys for provider '%s'", normalized_name) + + return required_keys + + +def get_configurable_store_types() -> List[str]: + options: List[str] = [] + for store_type in _discover_store_classes().keys(): + if get_store_schema(store_type): + options.append(str(store_type)) + return sorted(set(options)) + + +def get_configurable_provider_types() -> List[str]: + options: List[str] = [] + for provider_name in list_plugins().keys(): + if get_provider_schema(provider_name): + options.append(str(provider_name)) + return sorted(set(options)) + + +def get_configurable_tool_types() -> List[str]: + options: List[str] = [] + try: + import tool as tool_package + + for module_info in pkgutil.iter_modules(tool_package.__path__): + tool_name = str(module_info.name or "").strip() + if not tool_name: + continue + if get_tool_schema(tool_name): + options.append(tool_name) + except Exception: + logger.exception("Failed to discover configurable tool modules") + return sorted(set(options)) \ No newline at end of file diff --git a/Store/_base.py b/Store/_base.py index c68713f..fb7dd02 100644 --- a/Store/_base.py +++ b/Store/_base.py @@ -20,9 +20,12 @@ class Store(ABC): { "key": "PATH", "label": "Store Location", + "group": "Storage", + "type": "path", # text|boolean|integer|float|path|secret|multiline "default": "", "required": True, - "choices": ["/mnt/media", "/srv/data"] + "choices": ["/mnt/media", "/srv/data"], + "placeholder": "/srv/data" } """ return [] diff --git a/TUI/modalscreen/config_modal.py b/TUI/modalscreen/config_modal.py index c04de87..5530851 100644 --- a/TUI/modalscreen/config_modal.py +++ b/TUI/modalscreen/config_modal.py @@ -7,7 +7,7 @@ from textual import on, work from textual.app import ComposeResult from textual.containers import Container, Horizontal, Vertical, ScrollableContainer from textual.screen import ModalScreen -from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select, Checkbox +from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select, Checkbox, TextArea from pathlib import Path from SYS.config import ( @@ -15,15 +15,24 @@ from SYS.config import ( save_config, save_config_and_verify, reload_config, - global_config, count_changed_entries, ConfigSaveConflict, coerce_config_value, ) from SYS.database import db from SYS.logger import log, debug -from Store.registry import _discover_store_classes, _required_keys_for -from ProviderCore.registry import get_plugin, list_plugins +from SYS.plugin_config import ( + build_default_provider_config, + build_default_store_config, + build_default_tool_config, + get_configurable_provider_types, + get_configurable_store_types, + get_configurable_tool_types, + get_global_schema, + get_item_schema_map, + get_required_config_keys, +) +from ProviderCore.registry import get_plugin from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker from TUI.modalscreen.selection_modal import SelectionModal import logging @@ -93,6 +102,18 @@ class ConfigModal(ModalScreen): width: 1fr; } + .config-textarea { + height: 8; + margin-bottom: 1; + } + + .config-group { + color: $accent; + text-style: bold; + margin-top: 1; + margin-bottom: 1; + } + #config-actions { height: 3; align: right middle; @@ -277,18 +298,19 @@ class ConfigModal(ModalScreen): def render_globals(self, container: ScrollableContainer) -> None: container.mount(Label("General Configuration", classes="config-label")) - - # Get global schema - schema_map = {f["key"].lower(): f for f in global_config()} + + schema = get_global_schema() + schema_map = {f["key"].lower(): f for f in schema} existing_keys_lower = set() - + render_state = {"group": None, "mounted_any": False} + idx = 0 # Show fields defined in schema first - for key_lower, field_def in schema_map.items(): + for field_def in schema: + key_lower = field_def["key"].lower() existing_keys_lower.add(key_lower) - label_text = field_def.get("label") or field_def["key"] - choices = field_def.get("choices") - + self._mount_schema_group(container, field_def, render_state) + # Find current value (case-insensitive) current_val = None found_key = field_def["key"] @@ -301,33 +323,10 @@ class ConfigModal(ModalScreen): if current_val is None: current_val = str(field_def.get("default") or "") - container.mount(Label(label_text)) inp_id = f"global-{idx}" self._input_id_map[inp_id] = found_key - - if choices: - # Normalize boolean-like choices to lowercase ('true'/'false') to avoid duplicate choices - normalized_choices = [] - for c in choices: - s = str(c) - if s.lower() in ("true", "false"): - normalized_choices.append(s.lower()) - else: - normalized_choices.append(s) - - select_options = [(str(c), str(c)) for c in normalized_choices] - # Normalize current value as well - cur_val = str(current_val) if current_val is not None else "" - if cur_val.lower() in ("true", "false"): - cur_val = cur_val.lower() - if cur_val not in normalized_choices: - select_options.insert(0, (cur_val, cur_val)) - sel = Select(select_options, value=cur_val, id=inp_id) - container.mount(sel) - else: - row = Horizontal(classes="field-row") - container.mount(row) - row.mount(Input(value=current_val, id=inp_id, classes="config-input")) + self._mount_schema_field(container, field_def, inp_id, current_val, allow_paste=True) + render_state["mounted_any"] = True idx += 1 # Show any other top-level keys not in schema @@ -345,6 +344,85 @@ class ConfigModal(ModalScreen): row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn")) idx += 1 + def _mount_schema_group(self, container: ScrollableContainer, field_def: Dict[str, Any], state: Dict[str, Any]) -> None: + group_name = str(field_def.get("group") or "").strip() + if not group_name: + return + if state.get("group") == group_name: + return + if state.get("mounted_any"): + container.mount(Rule()) + container.mount(Label(group_name, classes="config-group")) + state["group"] = group_name + + def _normalized_select_options(self, choices: Any, current_val: Any) -> tuple[list[tuple[str, str]], str]: + select_options: list[tuple[str, str]] = [] + choice_values: list[str] = [] + for choice in list(choices or []): + if isinstance(choice, tuple) and len(choice) == 2: + label = str(choice[0]) + value = str(choice[1]) + else: + label = str(choice) + value = str(choice) + if value.lower() in ("true", "false"): + value = value.lower() + label = value + select_options.append((label, value)) + choice_values.append(value) + + normalized_current = str(current_val) if current_val is not None else "" + if normalized_current.lower() in ("true", "false"): + normalized_current = normalized_current.lower() + if normalized_current not in choice_values: + select_options.insert(0, (normalized_current, normalized_current)) + return select_options, normalized_current + + def _mount_schema_field( + self, + container: ScrollableContainer, + field_def: Dict[str, Any], + widget_id: str, + current_value: Any, + *, + allow_paste: bool, + ) -> None: + label_text = str(field_def.get("label") or field_def.get("key") or widget_id) + if field_def.get("required"): + label_text += " *" + container.mount(Label(label_text)) + + choices = field_def.get("choices") + field_type = str(field_def.get("type") or "").strip().lower() + if choices: + select_options, normalized_current = self._normalized_select_options(choices, current_value) + container.mount(Select(select_options, value=normalized_current, id=widget_id)) + return + + if field_type in {"multiline", "textarea"}: + text_area = TextArea(str(current_value or ""), id=widget_id, classes="config-textarea") + placeholder = field_def.get("placeholder") + if placeholder: + try: + text_area.tooltip = str(placeholder) + except Exception: + pass + container.mount(text_area) + return + + row = Horizontal(classes="field-row") + container.mount(row) + input_widget = Input(value=str(current_value or ""), id=widget_id, classes="config-input") + if field_def.get("secret") or field_type == "secret": + input_widget.password = True + if field_def.get("placeholder"): + input_widget.placeholder = str(field_def.get("placeholder") or "") + elif field_type == "path": + input_widget.placeholder = "Path" + row.mount(input_widget) + if allow_paste: + row.mount(Button("Paste", id=f"paste-{widget_id}", classes="paste-btn")) + def render_stores(self, container: ScrollableContainer) -> None: container.mount(Label("Configured Stores", classes="config-label")) stores = self.config_data.get("store", {}) @@ -423,54 +501,18 @@ class ConfigModal(ModalScreen): def render_item_editor(self, container: ScrollableContainer) -> None: item_type = str(self.editing_item_type or "") item_name = str(self.editing_item_name or "") - - provider_schema_map = {} + item_schema_map = get_item_schema_map(item_type, item_name) + render_state = {"group": None, "mounted_any": False} # Parse item_type for store-{stype} or just provider if item_type.startswith("store-"): stype = item_type.replace("store-", "") container.mount(Label(f"Editing Store: {item_name} ({stype})", classes="config-label")) section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {}) - - # Fetch Store schema - classes = _discover_store_classes() - if stype in classes: - cls = classes[stype] - if hasattr(cls, "config_schema") and callable(cls.config_schema): - for field_def in cls.config_schema(): - k = field_def.get("key") - if k: - provider_schema_map[k.upper()] = field_def - else: container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label")) section = self.config_data.get(item_type, {}).get(item_name, {}) - - # Fetch Provider schema - if item_type == "provider": - from ProviderCore.registry import get_plugin_class - try: - pcls = get_plugin_class(item_name) - if pcls and hasattr(pcls, "config_schema") and callable(pcls.config_schema): - for field_def in pcls.config_schema(): - k = field_def.get("key") - if k: - provider_schema_map[k.upper()] = field_def - except Exception: - logger.exception("Failed to retrieve provider config_schema") - # Fetch Tool schema - if item_type == "tool": - try: - import importlib - mod = importlib.import_module(f"tool.{item_name}") - if hasattr(mod, "config_schema") and callable(mod.config_schema): - for field_def in mod.config_schema(): - k = field_def.get("key") - if k: - provider_schema_map[k.upper()] = field_def - except Exception: - logger.exception("Failed to retrieve tool config_schema") - + # Use columns for better layout of inputs with paste buttons container.mount(Label("Edit Settings")) # render_item_editor will handle the inputs for us if we set these @@ -504,144 +546,53 @@ class ConfigModal(ModalScreen): label_text = k is_secret = False choices = None - schema = provider_schema_map.get(k_upper) + schema = item_schema_map.get(k_upper) if schema: - label_text = schema.get("label") or k - if schema.get("required"): - label_text += " *" + self._mount_schema_group(container, schema, render_state) if schema.get("secret"): is_secret = True choices = schema.get("choices") - container.mount(Label(label_text)) inp_id = f"item-{idx}" self._input_id_map[inp_id] = k - - if choices: - # Select takes a list of (label, value) tuples; normalize boolean-like values - select_options = [] - choice_values = [] - for c in choices: - if isinstance(c, tuple) and len(c) == 2: - label = str(c[0]) - val = str(c[1]) - else: - label = str(c) - val = str(c) - if val.lower() in ("true", "false"): - val = val.lower() - label = val - select_options.append((label, val)) - choice_values.append(val) - - # If current value not in choices, add it or stay blank - current_val = str(v) - if current_val.lower() in ("true", "false"): - current_val = current_val.lower() - if current_val not in choice_values: - select_options.insert(0, (current_val, current_val)) - - sel = Select(select_options, value=current_val, id=inp_id) - container.mount(sel) + if schema: + self._mount_schema_field(container, schema, inp_id, v, allow_paste=True) else: + container.mount(Label(label_text)) row = Horizontal(classes="field-row") container.mount(row) inp = Input(value=str(v), id=inp_id, classes="config-input") if is_secret: inp.password = True row.mount(inp) + render_state["mounted_any"] = True idx += 1 # Add required/optional fields from schema that are missing - for k_upper, field_def in provider_schema_map.items(): + for k_upper, field_def in item_schema_map.items(): if k_upper not in existing_keys_upper: existing_keys_upper.add(k_upper) key = field_def["key"] - label_text = field_def.get("label") or key - if field_def.get("required"): - label_text += " *" - + self._mount_schema_group(container, field_def, render_state) default_val = str(field_def.get("default") or "") - choices = field_def.get("choices") - - container.mount(Label(label_text)) inp_id = f"item-{idx}" self._input_id_map[inp_id] = key - - if choices: - select_options = [] - choice_values = [] - for c in choices: - if isinstance(c, tuple) and len(c) == 2: - label = str(c[0]) - val = str(c[1]) - else: - label = str(c) - val = str(c) - if val.lower() in ("true", "false"): - val = val.lower() - label = val - select_options.append((label, val)) - choice_values.append(val) - - # Normalize default/current value - current_val = str(default_val) if default_val is not None else "" - if current_val.lower() in ("true", "false"): - current_val = current_val.lower() - if current_val not in choice_values: - select_options.insert(0, (current_val, current_val)) - - sel = Select(select_options, value=current_val, id=inp_id) - container.mount(sel) - else: - row = Horizontal(classes="field-row") - container.mount(row) - inp = Input(value=default_val, id=inp_id, classes="config-input") - if field_def.get("secret"): - inp.password = True - if field_def.get("placeholder"): - inp.placeholder = field_def.get("placeholder") - row.mount(inp) - row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn")) + self._mount_schema_field(container, field_def, inp_id, default_val, allow_paste=True) + render_state["mounted_any"] = True idx += 1 - - # If it's a store, we might have required keys (legacy check fallback) - if item_type.startswith("store-"): - stype = item_type.replace("store-", "") - classes = _discover_store_classes() - if stype in classes: - required_keys = _required_keys_for(classes[stype]) - for rk in required_keys: - # Case-insensitive deduplication (fix path vs PATH) - if rk.upper() not in existing_keys_upper: - container.mount(Label(rk)) - inp_id = f"item-{idx}" - self._input_id_map[inp_id] = rk - row = Horizontal(classes="field-row") - container.mount(row) - row.mount(Input(value="", id=inp_id, classes="config-input")) - idx += 1 - - # If it's a provider, we might have required keys (legacy check fallback) - if item_type == "provider": - # 2. Legacy required_config_keys - from ProviderCore.registry import get_plugin_class - try: - pcls = get_plugin_class(item_name) - if pcls: - required_keys = pcls.required_config_keys() - for rk in required_keys: - if rk.upper() not in existing_keys_upper: - container.mount(Label(rk)) - inp_id = f"item-{idx}" - self._input_id_map[inp_id] = rk - row = Horizontal(classes="field-row") - container.mount(row) - row.mount(Input(value="", id=inp_id, classes="config-input")) - row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn")) - idx += 1 - except Exception: - logger.exception("Failed to build required config inputs for provider/tool") + + for required_key in get_required_config_keys(item_type, item_name): + if required_key.upper() in existing_keys_upper: + continue + existing_keys_upper.add(required_key.upper()) + container.mount(Label(required_key)) + inp_id = f"item-{idx}" + self._input_id_map[inp_id] = required_key + row = Horizontal(classes="field-row") + container.mount(row) + row.mount(Input(value="", id=inp_id, classes="config-input")) + row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn")) + idx += 1 if ( item_type == "provider" @@ -875,48 +826,13 @@ class ConfigModal(ModalScreen): self.notify(f"Save failed: {exc}", severity="error", timeout=10) self.refresh_view() elif bid == "add-store-btn": - all_classes = _discover_store_classes() - options = [] - for stype, cls in all_classes.items(): - if hasattr(cls, "config_schema") and callable(cls.config_schema): - try: - if cls.config_schema(): - options.append(stype) - except Exception: - logger.exception("Failed to inspect store class config_schema for '%s'", stype) + options = get_configurable_store_types() self.app.push_screen(SelectionModal("Select Store Type", options), callback=self.on_store_type_selected) elif bid == "add-provider-btn": - provider_names = list(list_plugins().keys()) - options = [] - from ProviderCore.registry import get_plugin_class - for ptype in provider_names: - pcls = get_plugin_class(ptype) - if pcls and hasattr(pcls, "config_schema") and callable(pcls.config_schema): - try: - if pcls.config_schema(): - options.append(ptype) - except Exception: - logger.exception("Failed to inspect provider class config_schema for '%s'", ptype) + options = get_configurable_provider_types() self.app.push_screen(SelectionModal("Select Plugin Type", options), callback=self.on_provider_type_selected) elif bid == "add-tool-btn": - # Discover tool modules that advertise a config_schema() - options = [] - try: - import pkgutil - import importlib - import tool as _tool_pkg - - for _mod in pkgutil.iter_modules(_tool_pkg.__path__): - try: - mod = importlib.import_module(f"tool.{_mod.name}") - if hasattr(mod, "config_schema") and callable(mod.config_schema): - options.append(_mod.name) - except Exception: - continue - except Exception: - # Fallback to known entry - options = ["ytdlp"] - + options = get_configurable_tool_types() or ["ytdlp"] if options: options.sort() self.app.push_screen(SelectionModal("Select Tool", options), callback=self.on_tool_type_selected) @@ -1033,26 +949,7 @@ class ConfigModal(ModalScreen): self.config_data["store"][stype] = {} # Default config for the new store - new_config = {"NAME": new_name} - classes = _discover_store_classes() - if stype in classes: - cls = classes[stype] - # Use schema for defaults if present - if hasattr(cls, "config_schema") and callable(cls.config_schema): - for field_def in cls.config_schema(): - key = field_def.get("key") - if key: - val = field_def.get("default", "") - # Don't override NAME if we already set it to new_stype - if key.upper() == "NAME": - continue - new_config[key] = val - else: - # Fallback to required keys list - required = _required_keys_for(cls) - for rk in required: - if rk.upper() != "NAME": - new_config[rk] = "" + new_config = build_default_store_config(stype, new_name) self.config_data["store"][stype][new_name] = new_config self.editing_item_type = f"store-{stype}" @@ -1067,25 +964,7 @@ class ConfigModal(ModalScreen): # For providers, they are usually top-level entries in 'provider' dict if ptype not in self.config_data["provider"]: - from ProviderCore.registry import get_plugin_class - try: - pcls = get_plugin_class(ptype) - new_config = {} - if pcls: - # Use schema for defaults - if hasattr(pcls, "config_schema") and callable(pcls.config_schema): - for field_def in pcls.config_schema(): - key = field_def.get("key") - if key: - new_config[key] = field_def.get("default", "") - else: - # Fallback to legacy required keys - required = pcls.required_config_keys() - for rk in required: - new_config[rk] = "" - self.config_data["provider"][ptype] = new_config - except Exception: - self.config_data["provider"][ptype] = {} + self.config_data["provider"][ptype] = build_default_provider_config(ptype) self.editing_item_type = "provider" self.editing_item_name = ptype @@ -1099,18 +978,7 @@ class ConfigModal(ModalScreen): self.config_data["tool"] = {} if tname not in self.config_data["tool"]: - new_config = {} - try: - import importlib - mod = importlib.import_module(f"tool.{tname}") - if hasattr(mod, "config_schema") and callable(mod.config_schema): - for field_def in mod.config_schema(): - key = field_def.get("key") - if key: - new_config[key] = field_def.get("default", "") - except Exception: - logger.exception("Failed to load config_schema for tool '%s'", tname) - self.config_data["tool"][tname] = new_config + self.config_data["tool"][tname] = build_default_tool_config(tname) self.editing_item_type = "tool" self.editing_item_name = tname @@ -1182,7 +1050,7 @@ class ConfigModal(ModalScreen): def _synchronize_inputs_to_config(self) -> None: """Capture current input/select values before saving.""" - widgets = list(self.query(Input)) + list(self.query(Select)) + widgets = list(self.query(Input)) + list(self.query(Select)) + list(self.query(TextArea)) for widget in widgets: widget_id = widget.id if not widget_id or widget_id not in self._input_id_map: @@ -1192,6 +1060,8 @@ class ConfigModal(ModalScreen): if widget.value == Select.BLANK: continue value = widget.value + elif isinstance(widget, TextArea): + value = widget.text else: value = widget.value @@ -1696,6 +1566,11 @@ class ConfigModal(ModalScreen): if event.input.id: self._update_config_value(event.input.id, event.value) + @on(TextArea.Changed) + def on_text_area_changed(self, event: TextArea.Changed) -> None: + if event.text_area.id: + self._update_config_value(event.text_area.id, event.text_area.text) + @on(Select.Changed) def on_select_changed(self, event: Select.Changed) -> None: if event.select.id: @@ -1860,52 +1735,18 @@ class ConfigModal(ModalScreen): item_type = str(self.editing_item_type or "") item_name = str(self.editing_item_name or "") - - required_keys = [] section = {} if item_type.startswith("store-"): stype = item_type.replace("store-", "") - classes = _discover_store_classes() - if stype in classes: - required_keys = list(_required_keys_for(classes[stype])) section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {}) elif item_type == "provider": - from ProviderCore.registry import get_plugin_class - try: - pcls = get_plugin_class(item_name) - if pcls: - # Collect required keys from schema - if hasattr(pcls, "config_schema") and callable(pcls.config_schema): - for field_def in pcls.config_schema(): - if field_def.get("required"): - k = field_def.get("key") - if k and k not in required_keys: - required_keys.append(k) - - # Merge with legacy required keys - for rk in pcls.required_config_keys(): - if rk not in required_keys: - required_keys.append(rk) - except Exception: - logger.exception("Failed to inspect provider class '%s' for required keys", item_name) section = self.config_data.get("provider", {}).get(item_name, {}) elif item_type == "tool": - try: - import importlib - mod = importlib.import_module(f"tool.{item_name}") - if hasattr(mod, "config_schema") and callable(mod.config_schema): - for field_def in mod.config_schema(): - if field_def.get("required"): - k = field_def.get("key") - if k and k not in required_keys: - required_keys.append(k) - except Exception: - logger.exception("Failed to inspect tool module 'tool.%s' for required keys", item_name) section = self.config_data.get("tool", {}).get(item_name, {}) # Check required keys - for rk in required_keys: + for rk in get_required_config_keys(item_type, item_name): # Case-insensitive lookup for the required key in the current section val = None rk_upper = rk.upper() diff --git a/cmdlet/screen_shot.py b/cmdlet/screen_shot.py index 6c96d73..49dd3b6 100644 --- a/cmdlet/screen_shot.py +++ b/cmdlet/screen_shot.py @@ -1684,7 +1684,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: format_value = parsed.get("format") capture_mode_value = _normalize_capture_mode(parsed.get("capture_mode")) - quality_value = _normalize_quality(parsed.get("quality")) + raw_quality_value = parsed.get("quality") + quality_value: Optional[int] = None if not format_value: try: tool_cfg = config.get("tool", {}) if isinstance(config, dict) else {} @@ -1696,6 +1697,19 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: if not format_value: format_value = "webp" + if raw_quality_value not in (None, ""): + quality_value = _normalize_quality(raw_quality_value) + else: + try: + tool_cfg = config.get("tool", {}) if isinstance(config, dict) else {} + pw_cfg = tool_cfg.get("playwright") if isinstance(tool_cfg, dict) else None + if isinstance(pw_cfg, dict) and pw_cfg.get("screenshot_quality") not in (None, ""): + quality_value = _normalize_quality(pw_cfg.get("screenshot_quality")) + except Exception: + quality_value = None + if quality_value is None: + quality_value = _normalize_quality(None) + storage_value = parsed.get("storage") selector_arg = parsed.get("selector") selectors = [selector_arg] if selector_arg else [] diff --git a/tool/playwright.py b/tool/playwright.py index eb84a30..95c5bce 100644 --- a/tool/playwright.py +++ b/tool/playwright.py @@ -69,6 +69,7 @@ class PlaywrightDefaults: viewport_height: int = 1080 navigation_timeout_ms: int = 90_000 ignore_https_errors: bool = True + screenshot_quality: int = 8 ffmpeg_path: Optional[str] = None # Path to ffmpeg executable; auto-detected if None @@ -107,6 +108,7 @@ class PlaywrightTool: - playwright.viewport_height=1200 - playwright.navigation_timeout_ms=90000 - playwright.ignore_https_errors=true + - playwright.screenshot_quality=8 - playwright.ffmpeg_path="/path/to/ffmpeg" (auto-detected if not set) FFmpeg resolution (in order): @@ -176,6 +178,7 @@ class PlaywrightTool: vw = _int("viewport_width", defaults.viewport_width) vh = _int("viewport_height", defaults.viewport_height) nav_timeout = _int("navigation_timeout_ms", defaults.navigation_timeout_ms) + screenshot_quality = max(1, min(10, _int("screenshot_quality", defaults.screenshot_quality))) ignore_https = bool(_get("ignore_https_errors", defaults.ignore_https_errors)) @@ -231,6 +234,7 @@ class PlaywrightTool: viewport_height=vh, navigation_timeout_ms=nav_timeout, ignore_https_errors=ignore_https, + screenshot_quality=screenshot_quality, ffmpeg_path=ffmpeg_path, ) @@ -578,52 +582,77 @@ def config_schema() -> List[Dict[str, Any]]: { "key": "browser", "label": "Playwright browser", + "group": "Browser", "default": _defaults.browser, "choices": browser_choices, }, { "key": "headless", "label": "Headless", + "group": "Browser", + "type": "boolean", "default": str(_defaults.headless), "choices": ["true", "false"], }, { "key": "user_agent", "label": "User Agent", + "group": "Browser", "default": "default", "choices": ["default", "native", "custom"], }, { "key": "user_agent_custom", "label": "Custom User Agent (used when User Agent = custom)", + "group": "Browser", "default": "", + "placeholder": "Mozilla/5.0 ...", }, { "key": "viewport_width", "label": "Viewport width", + "group": "Capture", + "type": "integer", "default": _defaults.viewport_width, "choices": viewport_width_choices, }, { "key": "viewport_height", "label": "Viewport height", + "group": "Capture", + "type": "integer", "default": _defaults.viewport_height, "choices": viewport_height_choices, }, { "key": "navigation_timeout_ms", "label": "Navigation timeout (ms)", + "group": "Navigation", + "type": "integer", "default": _defaults.navigation_timeout_ms, }, + { + "key": "screenshot_quality", + "label": "Default screenshot quality", + "group": "Capture", + "type": "integer", + "default": _defaults.screenshot_quality, + "choices": list(range(1, 11)), + }, { "key": "ignore_https_errors", "label": "Ignore HTTPS errors", + "group": "Navigation", + "type": "boolean", "default": str(_defaults.ignore_https_errors), "choices": ["true", "false"], }, { "key": "ffmpeg_path", "label": "FFmpeg path (leave empty to use global/bundled)", + "group": "Environment", + "type": "path", "default": "", + "placeholder": "C:/path/to/ffmpeg.exe", }, ]