refactored config plugin defintions
This commit is contained in:
@@ -208,10 +208,13 @@ class Provider(ABC):
|
|||||||
{
|
{
|
||||||
"key": "api_key",
|
"key": "api_key",
|
||||||
"label": "API Key",
|
"label": "API Key",
|
||||||
|
"group": "Authentication",
|
||||||
|
"type": "text", # text|boolean|integer|float|path|secret|multiline
|
||||||
"default": "",
|
"default": "",
|
||||||
"required": True,
|
"required": True,
|
||||||
"secret": True,
|
"secret": True,
|
||||||
"choices": ["Option 1", "Option 2"]
|
"choices": ["Option 1", "Option 2"],
|
||||||
|
"placeholder": "Paste value here"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
return []
|
return []
|
||||||
|
|||||||
+8
-3
@@ -43,20 +43,25 @@ def global_config() -> List[Dict[str, Any]]:
|
|||||||
{
|
{
|
||||||
"key": "debug",
|
"key": "debug",
|
||||||
"label": "Debug Output",
|
"label": "Debug Output",
|
||||||
|
"group": "Runtime",
|
||||||
|
"type": "boolean",
|
||||||
"default": "false",
|
"default": "false",
|
||||||
"choices": ["true", "false"]
|
"choices": ["true", "false"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "auto_update",
|
"key": "auto_update",
|
||||||
"label": "Auto-Update",
|
"label": "Auto-Update",
|
||||||
|
"group": "Runtime",
|
||||||
|
"type": "boolean",
|
||||||
"default": "true",
|
"default": "true",
|
||||||
"choices": ["true", "false"]
|
"choices": ["true", "false"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "table_appearance",
|
"key": "table_appearance",
|
||||||
"label": "Table Appearance",
|
"label": "Table Appearance",
|
||||||
|
"group": "Display",
|
||||||
"default": "rainbow",
|
"default": "rainbow",
|
||||||
"choices": ["plain", "bw-striped", "rainbow"]
|
"choices": ["plain", "bw-striped", "rainbow"],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
+4
-1
@@ -20,9 +20,12 @@ class Store(ABC):
|
|||||||
{
|
{
|
||||||
"key": "PATH",
|
"key": "PATH",
|
||||||
"label": "Store Location",
|
"label": "Store Location",
|
||||||
|
"group": "Storage",
|
||||||
|
"type": "path", # text|boolean|integer|float|path|secret|multiline
|
||||||
"default": "",
|
"default": "",
|
||||||
"required": True,
|
"required": True,
|
||||||
"choices": ["/mnt/media", "/srv/data"]
|
"choices": ["/mnt/media", "/srv/data"],
|
||||||
|
"placeholder": "/srv/data"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
return []
|
return []
|
||||||
|
|||||||
+156
-315
@@ -7,7 +7,7 @@ from textual import on, work
|
|||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
||||||
from textual.screen import ModalScreen
|
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 pathlib import Path
|
||||||
|
|
||||||
from SYS.config import (
|
from SYS.config import (
|
||||||
@@ -15,15 +15,24 @@ from SYS.config import (
|
|||||||
save_config,
|
save_config,
|
||||||
save_config_and_verify,
|
save_config_and_verify,
|
||||||
reload_config,
|
reload_config,
|
||||||
global_config,
|
|
||||||
count_changed_entries,
|
count_changed_entries,
|
||||||
ConfigSaveConflict,
|
ConfigSaveConflict,
|
||||||
coerce_config_value,
|
coerce_config_value,
|
||||||
)
|
)
|
||||||
from SYS.database import db
|
from SYS.database import db
|
||||||
from SYS.logger import log, debug
|
from SYS.logger import log, debug
|
||||||
from Store.registry import _discover_store_classes, _required_keys_for
|
from SYS.plugin_config import (
|
||||||
from ProviderCore.registry import get_plugin, list_plugins
|
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.matrix_room_picker import MatrixRoomPicker
|
||||||
from TUI.modalscreen.selection_modal import SelectionModal
|
from TUI.modalscreen.selection_modal import SelectionModal
|
||||||
import logging
|
import logging
|
||||||
@@ -93,6 +102,18 @@ class ConfigModal(ModalScreen):
|
|||||||
width: 1fr;
|
width: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-textarea {
|
||||||
|
height: 8;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-group {
|
||||||
|
color: $accent;
|
||||||
|
text-style: bold;
|
||||||
|
margin-top: 1;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
#config-actions {
|
#config-actions {
|
||||||
height: 3;
|
height: 3;
|
||||||
align: right middle;
|
align: right middle;
|
||||||
@@ -277,18 +298,19 @@ class ConfigModal(ModalScreen):
|
|||||||
|
|
||||||
def render_globals(self, container: ScrollableContainer) -> None:
|
def render_globals(self, container: ScrollableContainer) -> None:
|
||||||
container.mount(Label("General Configuration", classes="config-label"))
|
container.mount(Label("General Configuration", classes="config-label"))
|
||||||
|
|
||||||
# Get global schema
|
schema = get_global_schema()
|
||||||
schema_map = {f["key"].lower(): f for f in global_config()}
|
schema_map = {f["key"].lower(): f for f in schema}
|
||||||
existing_keys_lower = set()
|
existing_keys_lower = set()
|
||||||
|
render_state = {"group": None, "mounted_any": False}
|
||||||
|
|
||||||
idx = 0
|
idx = 0
|
||||||
# Show fields defined in schema first
|
# 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)
|
existing_keys_lower.add(key_lower)
|
||||||
label_text = field_def.get("label") or field_def["key"]
|
self._mount_schema_group(container, field_def, render_state)
|
||||||
choices = field_def.get("choices")
|
|
||||||
|
|
||||||
# Find current value (case-insensitive)
|
# Find current value (case-insensitive)
|
||||||
current_val = None
|
current_val = None
|
||||||
found_key = field_def["key"]
|
found_key = field_def["key"]
|
||||||
@@ -301,33 +323,10 @@ class ConfigModal(ModalScreen):
|
|||||||
if current_val is None:
|
if current_val is None:
|
||||||
current_val = str(field_def.get("default") or "")
|
current_val = str(field_def.get("default") or "")
|
||||||
|
|
||||||
container.mount(Label(label_text))
|
|
||||||
inp_id = f"global-{idx}"
|
inp_id = f"global-{idx}"
|
||||||
self._input_id_map[inp_id] = found_key
|
self._input_id_map[inp_id] = found_key
|
||||||
|
self._mount_schema_field(container, field_def, inp_id, current_val, allow_paste=True)
|
||||||
if choices:
|
render_state["mounted_any"] = True
|
||||||
# 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"))
|
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
# Show any other top-level keys not in schema
|
# 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"))
|
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
||||||
idx += 1
|
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:
|
def render_stores(self, container: ScrollableContainer) -> None:
|
||||||
container.mount(Label("Configured Stores", classes="config-label"))
|
container.mount(Label("Configured Stores", classes="config-label"))
|
||||||
stores = self.config_data.get("store", {})
|
stores = self.config_data.get("store", {})
|
||||||
@@ -423,54 +501,18 @@ class ConfigModal(ModalScreen):
|
|||||||
def render_item_editor(self, container: ScrollableContainer) -> None:
|
def render_item_editor(self, container: ScrollableContainer) -> None:
|
||||||
item_type = str(self.editing_item_type or "")
|
item_type = str(self.editing_item_type or "")
|
||||||
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)
|
||||||
provider_schema_map = {}
|
render_state = {"group": None, "mounted_any": False}
|
||||||
|
|
||||||
# Parse item_type for store-{stype} or just provider
|
# Parse item_type for store-{stype} or just provider
|
||||||
if item_type.startswith("store-"):
|
if item_type.startswith("store-"):
|
||||||
stype = item_type.replace("store-", "")
|
stype = item_type.replace("store-", "")
|
||||||
container.mount(Label(f"Editing Store: {item_name} ({stype})", classes="config-label"))
|
container.mount(Label(f"Editing Store: {item_name} ({stype})", classes="config-label"))
|
||||||
section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {})
|
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:
|
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"))
|
||||||
section = self.config_data.get(item_type, {}).get(item_name, {})
|
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
|
# Use columns for better layout of inputs with paste buttons
|
||||||
container.mount(Label("Edit Settings"))
|
container.mount(Label("Edit Settings"))
|
||||||
# render_item_editor will handle the inputs for us if we set these
|
# render_item_editor will handle the inputs for us if we set these
|
||||||
@@ -504,144 +546,53 @@ class ConfigModal(ModalScreen):
|
|||||||
label_text = k
|
label_text = k
|
||||||
is_secret = False
|
is_secret = False
|
||||||
choices = None
|
choices = None
|
||||||
schema = provider_schema_map.get(k_upper)
|
schema = item_schema_map.get(k_upper)
|
||||||
if schema:
|
if schema:
|
||||||
label_text = schema.get("label") or k
|
self._mount_schema_group(container, schema, render_state)
|
||||||
if schema.get("required"):
|
|
||||||
label_text += " *"
|
|
||||||
if schema.get("secret"):
|
if schema.get("secret"):
|
||||||
is_secret = True
|
is_secret = True
|
||||||
choices = schema.get("choices")
|
choices = schema.get("choices")
|
||||||
|
|
||||||
container.mount(Label(label_text))
|
|
||||||
inp_id = f"item-{idx}"
|
inp_id = f"item-{idx}"
|
||||||
self._input_id_map[inp_id] = k
|
self._input_id_map[inp_id] = k
|
||||||
|
if schema:
|
||||||
if choices:
|
self._mount_schema_field(container, schema, inp_id, v, allow_paste=True)
|
||||||
# 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)
|
|
||||||
else:
|
else:
|
||||||
|
container.mount(Label(label_text))
|
||||||
row = Horizontal(classes="field-row")
|
row = Horizontal(classes="field-row")
|
||||||
container.mount(row)
|
container.mount(row)
|
||||||
inp = Input(value=str(v), id=inp_id, classes="config-input")
|
inp = Input(value=str(v), id=inp_id, classes="config-input")
|
||||||
if is_secret:
|
if is_secret:
|
||||||
inp.password = True
|
inp.password = True
|
||||||
row.mount(inp)
|
row.mount(inp)
|
||||||
|
render_state["mounted_any"] = True
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
# Add required/optional fields from schema that are missing
|
# 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:
|
if k_upper not in existing_keys_upper:
|
||||||
existing_keys_upper.add(k_upper)
|
existing_keys_upper.add(k_upper)
|
||||||
key = field_def["key"]
|
key = field_def["key"]
|
||||||
label_text = field_def.get("label") or key
|
self._mount_schema_group(container, field_def, render_state)
|
||||||
if field_def.get("required"):
|
|
||||||
label_text += " *"
|
|
||||||
|
|
||||||
default_val = str(field_def.get("default") or "")
|
default_val = str(field_def.get("default") or "")
|
||||||
choices = field_def.get("choices")
|
|
||||||
|
|
||||||
container.mount(Label(label_text))
|
|
||||||
inp_id = f"item-{idx}"
|
inp_id = f"item-{idx}"
|
||||||
self._input_id_map[inp_id] = key
|
self._input_id_map[inp_id] = key
|
||||||
|
self._mount_schema_field(container, field_def, inp_id, default_val, allow_paste=True)
|
||||||
if choices:
|
render_state["mounted_any"] = True
|
||||||
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"))
|
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
# If it's a store, we might have required keys (legacy check fallback)
|
for required_key in get_required_config_keys(item_type, item_name):
|
||||||
if item_type.startswith("store-"):
|
if required_key.upper() in existing_keys_upper:
|
||||||
stype = item_type.replace("store-", "")
|
continue
|
||||||
classes = _discover_store_classes()
|
existing_keys_upper.add(required_key.upper())
|
||||||
if stype in classes:
|
container.mount(Label(required_key))
|
||||||
required_keys = _required_keys_for(classes[stype])
|
inp_id = f"item-{idx}"
|
||||||
for rk in required_keys:
|
self._input_id_map[inp_id] = required_key
|
||||||
# Case-insensitive deduplication (fix path vs PATH)
|
row = Horizontal(classes="field-row")
|
||||||
if rk.upper() not in existing_keys_upper:
|
container.mount(row)
|
||||||
container.mount(Label(rk))
|
row.mount(Input(value="", id=inp_id, classes="config-input"))
|
||||||
inp_id = f"item-{idx}"
|
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
||||||
self._input_id_map[inp_id] = rk
|
idx += 1
|
||||||
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")
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
item_type == "provider"
|
item_type == "provider"
|
||||||
@@ -875,48 +826,13 @@ class ConfigModal(ModalScreen):
|
|||||||
self.notify(f"Save failed: {exc}", severity="error", timeout=10)
|
self.notify(f"Save failed: {exc}", severity="error", timeout=10)
|
||||||
self.refresh_view()
|
self.refresh_view()
|
||||||
elif bid == "add-store-btn":
|
elif bid == "add-store-btn":
|
||||||
all_classes = _discover_store_classes()
|
options = get_configurable_store_types()
|
||||||
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)
|
|
||||||
self.app.push_screen(SelectionModal("Select Store Type", options), callback=self.on_store_type_selected)
|
self.app.push_screen(SelectionModal("Select Store Type", options), callback=self.on_store_type_selected)
|
||||||
elif bid == "add-provider-btn":
|
elif bid == "add-provider-btn":
|
||||||
provider_names = list(list_plugins().keys())
|
options = get_configurable_provider_types()
|
||||||
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)
|
|
||||||
self.app.push_screen(SelectionModal("Select Plugin Type", options), callback=self.on_provider_type_selected)
|
self.app.push_screen(SelectionModal("Select Plugin Type", options), callback=self.on_provider_type_selected)
|
||||||
elif bid == "add-tool-btn":
|
elif bid == "add-tool-btn":
|
||||||
# Discover tool modules that advertise a config_schema()
|
options = get_configurable_tool_types() or ["ytdlp"]
|
||||||
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"]
|
|
||||||
|
|
||||||
if options:
|
if options:
|
||||||
options.sort()
|
options.sort()
|
||||||
self.app.push_screen(SelectionModal("Select Tool", options), callback=self.on_tool_type_selected)
|
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] = {}
|
self.config_data["store"][stype] = {}
|
||||||
|
|
||||||
# Default config for the new store
|
# Default config for the new store
|
||||||
new_config = {"NAME": new_name}
|
new_config = build_default_store_config(stype, 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] = ""
|
|
||||||
|
|
||||||
self.config_data["store"][stype][new_name] = new_config
|
self.config_data["store"][stype][new_name] = new_config
|
||||||
self.editing_item_type = f"store-{stype}"
|
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
|
# For providers, they are usually top-level entries in 'provider' dict
|
||||||
if ptype not in self.config_data["provider"]:
|
if ptype not in self.config_data["provider"]:
|
||||||
from ProviderCore.registry import get_plugin_class
|
self.config_data["provider"][ptype] = build_default_provider_config(ptype)
|
||||||
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.editing_item_type = "provider"
|
self.editing_item_type = "provider"
|
||||||
self.editing_item_name = ptype
|
self.editing_item_name = ptype
|
||||||
@@ -1099,18 +978,7 @@ class ConfigModal(ModalScreen):
|
|||||||
self.config_data["tool"] = {}
|
self.config_data["tool"] = {}
|
||||||
|
|
||||||
if tname not in self.config_data["tool"]:
|
if tname not in self.config_data["tool"]:
|
||||||
new_config = {}
|
self.config_data["tool"][tname] = build_default_tool_config(tname)
|
||||||
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.editing_item_type = "tool"
|
self.editing_item_type = "tool"
|
||||||
self.editing_item_name = tname
|
self.editing_item_name = tname
|
||||||
@@ -1182,7 +1050,7 @@ class ConfigModal(ModalScreen):
|
|||||||
|
|
||||||
def _synchronize_inputs_to_config(self) -> None:
|
def _synchronize_inputs_to_config(self) -> None:
|
||||||
"""Capture current input/select values before saving."""
|
"""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:
|
for widget in widgets:
|
||||||
widget_id = widget.id
|
widget_id = widget.id
|
||||||
if not widget_id or widget_id not in self._input_id_map:
|
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:
|
if widget.value == Select.BLANK:
|
||||||
continue
|
continue
|
||||||
value = widget.value
|
value = widget.value
|
||||||
|
elif isinstance(widget, TextArea):
|
||||||
|
value = widget.text
|
||||||
else:
|
else:
|
||||||
value = widget.value
|
value = widget.value
|
||||||
|
|
||||||
@@ -1696,6 +1566,11 @@ class ConfigModal(ModalScreen):
|
|||||||
if event.input.id:
|
if event.input.id:
|
||||||
self._update_config_value(event.input.id, event.value)
|
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)
|
@on(Select.Changed)
|
||||||
def on_select_changed(self, event: Select.Changed) -> None:
|
def on_select_changed(self, event: Select.Changed) -> None:
|
||||||
if event.select.id:
|
if event.select.id:
|
||||||
@@ -1860,52 +1735,18 @@ class ConfigModal(ModalScreen):
|
|||||||
|
|
||||||
item_type = str(self.editing_item_type or "")
|
item_type = str(self.editing_item_type or "")
|
||||||
item_name = str(self.editing_item_name or "")
|
item_name = str(self.editing_item_name or "")
|
||||||
|
|
||||||
required_keys = []
|
|
||||||
section = {}
|
section = {}
|
||||||
|
|
||||||
if item_type.startswith("store-"):
|
if item_type.startswith("store-"):
|
||||||
stype = item_type.replace("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, {})
|
section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {})
|
||||||
elif item_type == "provider":
|
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, {})
|
section = self.config_data.get("provider", {}).get(item_name, {})
|
||||||
elif item_type == "tool":
|
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, {})
|
section = self.config_data.get("tool", {}).get(item_name, {})
|
||||||
|
|
||||||
# Check required keys
|
# 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
|
# Case-insensitive lookup for the required key in the current section
|
||||||
val = None
|
val = None
|
||||||
rk_upper = rk.upper()
|
rk_upper = rk.upper()
|
||||||
|
|||||||
+15
-1
@@ -1684,7 +1684,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
|
|
||||||
format_value = parsed.get("format")
|
format_value = parsed.get("format")
|
||||||
capture_mode_value = _normalize_capture_mode(parsed.get("capture_mode"))
|
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:
|
if not format_value:
|
||||||
try:
|
try:
|
||||||
tool_cfg = config.get("tool", {}) if isinstance(config, dict) else {}
|
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:
|
if not format_value:
|
||||||
format_value = "webp"
|
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")
|
storage_value = parsed.get("storage")
|
||||||
selector_arg = parsed.get("selector")
|
selector_arg = parsed.get("selector")
|
||||||
selectors = [selector_arg] if selector_arg else []
|
selectors = [selector_arg] if selector_arg else []
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class PlaywrightDefaults:
|
|||||||
viewport_height: int = 1080
|
viewport_height: int = 1080
|
||||||
navigation_timeout_ms: int = 90_000
|
navigation_timeout_ms: int = 90_000
|
||||||
ignore_https_errors: bool = True
|
ignore_https_errors: bool = True
|
||||||
|
screenshot_quality: int = 8
|
||||||
ffmpeg_path: Optional[str] = None # Path to ffmpeg executable; auto-detected if None
|
ffmpeg_path: Optional[str] = None # Path to ffmpeg executable; auto-detected if None
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ class PlaywrightTool:
|
|||||||
- playwright.viewport_height=1200
|
- playwright.viewport_height=1200
|
||||||
- playwright.navigation_timeout_ms=90000
|
- playwright.navigation_timeout_ms=90000
|
||||||
- playwright.ignore_https_errors=true
|
- playwright.ignore_https_errors=true
|
||||||
|
- playwright.screenshot_quality=8
|
||||||
- playwright.ffmpeg_path="/path/to/ffmpeg" (auto-detected if not set)
|
- playwright.ffmpeg_path="/path/to/ffmpeg" (auto-detected if not set)
|
||||||
|
|
||||||
FFmpeg resolution (in order):
|
FFmpeg resolution (in order):
|
||||||
@@ -176,6 +178,7 @@ class PlaywrightTool:
|
|||||||
vw = _int("viewport_width", defaults.viewport_width)
|
vw = _int("viewport_width", defaults.viewport_width)
|
||||||
vh = _int("viewport_height", defaults.viewport_height)
|
vh = _int("viewport_height", defaults.viewport_height)
|
||||||
nav_timeout = _int("navigation_timeout_ms", defaults.navigation_timeout_ms)
|
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))
|
ignore_https = bool(_get("ignore_https_errors", defaults.ignore_https_errors))
|
||||||
|
|
||||||
@@ -231,6 +234,7 @@ class PlaywrightTool:
|
|||||||
viewport_height=vh,
|
viewport_height=vh,
|
||||||
navigation_timeout_ms=nav_timeout,
|
navigation_timeout_ms=nav_timeout,
|
||||||
ignore_https_errors=ignore_https,
|
ignore_https_errors=ignore_https,
|
||||||
|
screenshot_quality=screenshot_quality,
|
||||||
ffmpeg_path=ffmpeg_path,
|
ffmpeg_path=ffmpeg_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -578,52 +582,77 @@ def config_schema() -> List[Dict[str, Any]]:
|
|||||||
{
|
{
|
||||||
"key": "browser",
|
"key": "browser",
|
||||||
"label": "Playwright browser",
|
"label": "Playwright browser",
|
||||||
|
"group": "Browser",
|
||||||
"default": _defaults.browser,
|
"default": _defaults.browser,
|
||||||
"choices": browser_choices,
|
"choices": browser_choices,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "headless",
|
"key": "headless",
|
||||||
"label": "Headless",
|
"label": "Headless",
|
||||||
|
"group": "Browser",
|
||||||
|
"type": "boolean",
|
||||||
"default": str(_defaults.headless),
|
"default": str(_defaults.headless),
|
||||||
"choices": ["true", "false"],
|
"choices": ["true", "false"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "user_agent",
|
"key": "user_agent",
|
||||||
"label": "User Agent",
|
"label": "User Agent",
|
||||||
|
"group": "Browser",
|
||||||
"default": "default",
|
"default": "default",
|
||||||
"choices": ["default", "native", "custom"],
|
"choices": ["default", "native", "custom"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "user_agent_custom",
|
"key": "user_agent_custom",
|
||||||
"label": "Custom User Agent (used when User Agent = custom)",
|
"label": "Custom User Agent (used when User Agent = custom)",
|
||||||
|
"group": "Browser",
|
||||||
"default": "",
|
"default": "",
|
||||||
|
"placeholder": "Mozilla/5.0 ...",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "viewport_width",
|
"key": "viewport_width",
|
||||||
"label": "Viewport width",
|
"label": "Viewport width",
|
||||||
|
"group": "Capture",
|
||||||
|
"type": "integer",
|
||||||
"default": _defaults.viewport_width,
|
"default": _defaults.viewport_width,
|
||||||
"choices": viewport_width_choices,
|
"choices": viewport_width_choices,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "viewport_height",
|
"key": "viewport_height",
|
||||||
"label": "Viewport height",
|
"label": "Viewport height",
|
||||||
|
"group": "Capture",
|
||||||
|
"type": "integer",
|
||||||
"default": _defaults.viewport_height,
|
"default": _defaults.viewport_height,
|
||||||
"choices": viewport_height_choices,
|
"choices": viewport_height_choices,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "navigation_timeout_ms",
|
"key": "navigation_timeout_ms",
|
||||||
"label": "Navigation timeout (ms)",
|
"label": "Navigation timeout (ms)",
|
||||||
|
"group": "Navigation",
|
||||||
|
"type": "integer",
|
||||||
"default": _defaults.navigation_timeout_ms,
|
"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",
|
"key": "ignore_https_errors",
|
||||||
"label": "Ignore HTTPS errors",
|
"label": "Ignore HTTPS errors",
|
||||||
|
"group": "Navigation",
|
||||||
|
"type": "boolean",
|
||||||
"default": str(_defaults.ignore_https_errors),
|
"default": str(_defaults.ignore_https_errors),
|
||||||
"choices": ["true", "false"],
|
"choices": ["true", "false"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "ffmpeg_path",
|
"key": "ffmpeg_path",
|
||||||
"label": "FFmpeg path (leave empty to use global/bundled)",
|
"label": "FFmpeg path (leave empty to use global/bundled)",
|
||||||
|
"group": "Environment",
|
||||||
|
"type": "path",
|
||||||
"default": "",
|
"default": "",
|
||||||
|
"placeholder": "C:/path/to/ffmpeg.exe",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user