1051 lines
35 KiB
Python
1051 lines
35 KiB
Python
import datetime
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any, Optional, Sequence
|
|
|
|
from SYS.cmdlet_spec import Cmdlet, CmdletArg
|
|
from SYS.config import (
|
|
load_config,
|
|
save_config,
|
|
save_config_and_verify,
|
|
set_nested_config_value,
|
|
)
|
|
from SYS.database import LOG_DB_PATH, db
|
|
from SYS.logger import log
|
|
from SYS.plugin_config import (
|
|
build_default_plugin_config,
|
|
get_configurable_plugin_types,
|
|
get_configurable_store_types,
|
|
)
|
|
from SYS import pipeline as ctx
|
|
from SYS.result_table import Table
|
|
from cmdnat._parsing import (
|
|
VALUE_ARG_FLAGS,
|
|
extract_piped_value as _extract_piped_value,
|
|
extract_arg_value as _extract_arg_value,
|
|
extract_value_arg as _extract_value_arg,
|
|
has_flag as _has_flag,
|
|
)
|
|
|
|
|
|
_PREFERENCES_BROWSE_PATH = "__preferences__"
|
|
_PLUGINS_BROWSE_PATH = "__plugins__"
|
|
_PLUGIN_CATEGORY_KEYS = ("plugin",)
|
|
_CREATE_INSTANCE_FLAG = "-create-instance"
|
|
_KNOWN_SECTION_LABELS = {
|
|
"plugin": "Plugins",
|
|
}
|
|
_KNOWN_SECTION_DESCRIPTIONS = {
|
|
_PREFERENCES_BROWSE_PATH: "Global preferences and simple values",
|
|
_PLUGINS_BROWSE_PATH: "All configured plugins and plugin instances",
|
|
"plugin": "Plugin configuration",
|
|
}
|
|
_SENSITIVE_CONFIG_KEYS = {
|
|
"access_key",
|
|
"access_token",
|
|
"api",
|
|
"api_key",
|
|
"apikey",
|
|
"authorization",
|
|
"bearer_token",
|
|
"cookie",
|
|
"cookies",
|
|
"password",
|
|
"secret",
|
|
"token",
|
|
}
|
|
_CONFIG_ITEM_FIELDS = (
|
|
"kind",
|
|
"key",
|
|
"title",
|
|
"browse_path",
|
|
"name",
|
|
"value",
|
|
"value_display",
|
|
"type",
|
|
"display_path",
|
|
"instance_target",
|
|
)
|
|
|
|
CMDLET = Cmdlet(
|
|
name=".config",
|
|
summary="Manage configuration settings",
|
|
usage=".config [key] [value] | .config -log [count]",
|
|
arg=[
|
|
CmdletArg(
|
|
name="key",
|
|
description="Configuration key to update (dot-separated)",
|
|
required=False
|
|
),
|
|
CmdletArg(
|
|
name="value",
|
|
description="New value for the configuration key",
|
|
required=False
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
def _extract_log_limit(args: Sequence[str], default: int = 30) -> int:
|
|
try:
|
|
tokens = [str(arg).strip() for arg in (args or []) if str(arg).strip()]
|
|
except Exception:
|
|
return default
|
|
|
|
for idx, token in enumerate(tokens):
|
|
lowered = token.lower()
|
|
if lowered in {"-log", "--log"}:
|
|
if idx + 1 < len(tokens):
|
|
candidate = tokens[idx + 1]
|
|
if candidate and not candidate.startswith("-"):
|
|
try:
|
|
return max(1, min(200, int(candidate)))
|
|
except Exception:
|
|
return default
|
|
return default
|
|
if lowered.startswith("-log=") or lowered.startswith("--log="):
|
|
_, value = lowered.split("=", 1)
|
|
try:
|
|
return max(1, min(200, int(value)))
|
|
except Exception:
|
|
return default
|
|
return default
|
|
|
|
|
|
def _fallback_log_path() -> Path:
|
|
return Path(db.db_path).with_name("logs") / "log_fallback.txt"
|
|
|
|
|
|
def _load_recent_config_logs(limit: int = 30) -> List[Dict[str, str]]:
|
|
rows: List[Dict[str, str]] = []
|
|
sql = """
|
|
SELECT timestamp, level, module, message
|
|
FROM logs
|
|
WHERE lower(module) LIKE ?
|
|
OR lower(message) LIKE ?
|
|
OR lower(message) LIKE ?
|
|
OR lower(message) LIKE ?
|
|
ORDER BY id DESC
|
|
LIMIT ?
|
|
"""
|
|
params = (
|
|
"%config%",
|
|
"%config%",
|
|
"%save failed%",
|
|
"%saving configuration failed%",
|
|
int(limit),
|
|
)
|
|
|
|
try:
|
|
with sqlite3.connect(str(LOG_DB_PATH), timeout=5.0) as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
cur = conn.cursor()
|
|
cur.execute(sql, params)
|
|
fetched = cur.fetchall()
|
|
cur.close()
|
|
for row in fetched:
|
|
rows.append(
|
|
{
|
|
"timestamp": str(row["timestamp"] or ""),
|
|
"level": str(row["level"] or ""),
|
|
"module": str(row["module"] or ""),
|
|
"message": str(row["message"] or ""),
|
|
}
|
|
)
|
|
except Exception:
|
|
rows = []
|
|
|
|
if rows:
|
|
return rows
|
|
|
|
fallback = _fallback_log_path()
|
|
try:
|
|
if not fallback.exists():
|
|
return []
|
|
lines = fallback.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
matches = [
|
|
line for line in lines
|
|
if any(term in line.lower() for term in ("config", "save failed", "saving configuration failed"))
|
|
]
|
|
for line in reversed(matches[-limit:]):
|
|
rows.append(
|
|
{
|
|
"timestamp": "",
|
|
"level": "FALLBACK",
|
|
"module": "fallback",
|
|
"message": line,
|
|
}
|
|
)
|
|
except Exception:
|
|
return []
|
|
return rows
|
|
|
|
|
|
def _format_log_timestamp_local(raw_value: str) -> str:
|
|
text = str(raw_value or "").strip()
|
|
if not text:
|
|
return ""
|
|
for pattern in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"):
|
|
try:
|
|
parsed = datetime.datetime.strptime(text, pattern).replace(tzinfo=datetime.timezone.utc)
|
|
return parsed.astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
|
except Exception:
|
|
continue
|
|
return text
|
|
|
|
|
|
def _show_config_logs(args: Sequence[str]) -> int:
|
|
limit = _extract_log_limit(args)
|
|
rows = _load_recent_config_logs(limit=limit)
|
|
if not rows:
|
|
print(
|
|
f"No recent config/save logs found in {LOG_DB_PATH.name} or {_fallback_log_path().name}."
|
|
)
|
|
return 0
|
|
|
|
table = Table("Configuration Logs")
|
|
table.set_table("config.logs")
|
|
table.set_source_command(".config", ["-log", str(limit)])
|
|
|
|
for row_data in rows:
|
|
row = table.add_row()
|
|
row.add_column("Time (local)", _format_log_timestamp_local(row_data.get("timestamp", "")))
|
|
row.add_column("Level", row_data.get("level", ""))
|
|
row.add_column("Module", row_data.get("module", ""))
|
|
row.add_column("Message", row_data.get("message", ""))
|
|
|
|
ctx.set_last_result_table_overlay(table, rows)
|
|
ctx.set_current_stage_table(table)
|
|
print(f"Showing {len(rows)} recent configuration log entries.")
|
|
return 0
|
|
|
|
|
|
def set_nested_config(config: Dict[str, Any], key: str, value: str) -> bool:
|
|
return set_nested_config_value(config, key, value, on_error=print)
|
|
|
|
|
|
def _visible_config_entries(config_data: Any) -> List[tuple[str, Any]]:
|
|
if not isinstance(config_data, dict):
|
|
return []
|
|
return [
|
|
(str(key), value)
|
|
for key, value in config_data.items()
|
|
if isinstance(key, str) and not key.startswith("_")
|
|
]
|
|
|
|
|
|
def _format_config_label(value: Any) -> str:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return "Configuration"
|
|
if text == _PREFERENCES_BROWSE_PATH:
|
|
return "Preferences"
|
|
if text == _PLUGINS_BROWSE_PATH:
|
|
return "Plugins"
|
|
return text.replace("_", " ").replace("-", " ").strip().title()
|
|
|
|
|
|
def _format_config_path_label(browse_path: Optional[str]) -> str:
|
|
text = str(browse_path or "").strip()
|
|
if not text:
|
|
return "Root"
|
|
if text == _PREFERENCES_BROWSE_PATH:
|
|
return "Preferences"
|
|
if text == _PLUGINS_BROWSE_PATH:
|
|
return "Plugins"
|
|
parts = [part for part in text.split(".") if part]
|
|
formatted: List[str] = []
|
|
for idx, part in enumerate(parts):
|
|
if idx == 0 and part in _PLUGIN_CATEGORY_KEYS:
|
|
formatted.append("Plugins")
|
|
else:
|
|
formatted.append(_format_config_label(part))
|
|
return " / ".join(formatted)
|
|
|
|
|
|
def _format_config_value(value: Any) -> str:
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
if value is None:
|
|
return "null"
|
|
if isinstance(value, (list, tuple, set)):
|
|
return ", ".join(str(item) for item in value)
|
|
return str(value)
|
|
|
|
|
|
def _is_sensitive_config_key(key_path: str) -> bool:
|
|
leaf = str(key_path or "").split(".")[-1].strip().lower()
|
|
return leaf in _SENSITIVE_CONFIG_KEYS
|
|
|
|
|
|
def _format_config_entry_count(value: Any) -> str:
|
|
count = len(_visible_config_entries(value)) if isinstance(value, dict) else 0
|
|
if count == 1:
|
|
return "1 entry"
|
|
return f"{count} entries"
|
|
|
|
|
|
def _get_configurable_plugin_names() -> List[str]:
|
|
try:
|
|
return [
|
|
str(name).strip().lower()
|
|
for name in (get_configurable_plugin_types() or [])
|
|
if str(name).strip()
|
|
]
|
|
except Exception:
|
|
return []
|
|
def _get_multi_instance_plugin_names() -> set[str]:
|
|
try:
|
|
return {
|
|
str(name).strip().lower()
|
|
for name in (get_configurable_store_types() or [])
|
|
if str(name).strip()
|
|
}
|
|
except Exception:
|
|
return set()
|
|
|
|
|
|
def _split_config_path(value: Optional[str]) -> List[str]:
|
|
return [part for part in str(value or "").split(".") if part]
|
|
|
|
|
|
def _is_multi_instance_plugin_name(name: str) -> bool:
|
|
return str(name or "").strip().lower() in _get_multi_instance_plugin_names()
|
|
|
|
|
|
def _is_multi_instance_plugin_root_path(browse_path: Optional[str]) -> bool:
|
|
parts = _split_config_path(browse_path)
|
|
return (
|
|
len(parts) == 2
|
|
and parts[0] == "plugin"
|
|
and _is_multi_instance_plugin_name(parts[1])
|
|
)
|
|
|
|
|
|
def _plugin_schema_field_keys(plugin_name: str) -> set[str]:
|
|
defaults = build_default_plugin_config(plugin_name)
|
|
if not isinstance(defaults, dict):
|
|
return set()
|
|
return {
|
|
str(key or "").strip().lower()
|
|
for key in defaults.keys()
|
|
if str(key or "").strip()
|
|
}
|
|
|
|
|
|
def _looks_like_single_instance_branch(plugin_name: str, branch: Any) -> bool:
|
|
if not isinstance(branch, dict) or not branch:
|
|
return False
|
|
|
|
schema_keys = _plugin_schema_field_keys(plugin_name)
|
|
entry_keys = {str(key or "").strip().lower() for key in branch.keys()}
|
|
looks_like_single = bool(schema_keys and entry_keys.intersection(schema_keys))
|
|
if not looks_like_single:
|
|
looks_like_single = not all(isinstance(value, dict) for value in branch.values())
|
|
return looks_like_single
|
|
|
|
|
|
def _normalize_multi_instance_branch(plugin_name: str, branch: Any) -> Dict[str, Any]:
|
|
if not isinstance(branch, dict):
|
|
return {}
|
|
if _looks_like_single_instance_branch(plugin_name, branch):
|
|
return {"default": dict(branch)}
|
|
return {
|
|
str(key): value
|
|
for key, value in _visible_config_entries(branch)
|
|
if isinstance(value, dict)
|
|
}
|
|
|
|
|
|
def _build_create_instance_item(category: str, plugin_name: str) -> Dict[str, Any]:
|
|
target = f"{category}.{plugin_name}"
|
|
return {
|
|
"kind": "create_instance",
|
|
"key": f"{target}.__new_instance__",
|
|
"title": "Add Instance",
|
|
"name": "add_instance",
|
|
"value": None,
|
|
"value_display": "Create with @N | .config <name>",
|
|
"display_path": f"{_format_config_path_label(target)} / Add Instance",
|
|
"type": "action",
|
|
"instance_target": target,
|
|
}
|
|
|
|
|
|
def _build_synthetic_plugin_branch(category: str, name: str) -> Optional[Dict[str, Any]]:
|
|
normalized_category = str(category or "").strip().lower()
|
|
normalized_name = str(name or "").strip().lower()
|
|
if not normalized_name:
|
|
return None
|
|
|
|
branch = build_default_plugin_config(normalized_name)
|
|
if not isinstance(branch, dict):
|
|
return None
|
|
if normalized_name in _get_multi_instance_plugin_names():
|
|
return {"default": dict(branch)}
|
|
return dict(branch)
|
|
|
|
|
|
def _find_configured_plugin_branch(
|
|
config_data: Dict[str, Any],
|
|
category: str,
|
|
name: str,
|
|
) -> Optional[tuple[str, Dict[str, Any]]]:
|
|
category_block = config_data.get(category)
|
|
if not isinstance(category_block, dict):
|
|
return None
|
|
|
|
target = str(name or "").strip().lower()
|
|
for raw_name, raw_value in _visible_config_entries(category_block):
|
|
if str(raw_name or "").strip().lower() != target or not isinstance(raw_value, dict):
|
|
continue
|
|
return raw_name, raw_value
|
|
return None
|
|
|
|
|
|
def _resolve_plugin_branch(
|
|
config_data: Dict[str, Any],
|
|
category: str,
|
|
name: str,
|
|
) -> Optional[tuple[str, Dict[str, Any], bool]]:
|
|
found = _find_configured_plugin_branch(config_data, category, name)
|
|
if found is not None:
|
|
resolved_name, resolved_value = found
|
|
return resolved_name, resolved_value, True
|
|
|
|
normalized_category = str(category or "").strip().lower()
|
|
normalized_name = str(name or "").strip().lower()
|
|
if not normalized_name:
|
|
return None
|
|
|
|
if normalized_name not in _get_configurable_plugin_names():
|
|
return None
|
|
|
|
synthetic = _build_synthetic_plugin_branch(normalized_category, normalized_name)
|
|
if synthetic is None:
|
|
return None
|
|
return normalized_name, synthetic, False
|
|
|
|
|
|
def _iter_plugin_branches(config_data: Dict[str, Any]) -> List[tuple[str, str, Any]]:
|
|
branches: List[tuple[str, str, Any]] = []
|
|
if not isinstance(config_data, dict):
|
|
return branches
|
|
|
|
for category in _PLUGIN_CATEGORY_KEYS:
|
|
category_block = config_data.get(category)
|
|
if not isinstance(category_block, dict):
|
|
continue
|
|
for name, value in _visible_config_entries(category_block):
|
|
branches.append((category, name, value))
|
|
return branches
|
|
|
|
|
|
def _iter_available_plugin_branches(config_data: Dict[str, Any]) -> List[tuple[str, str, Any, bool]]:
|
|
branches: List[tuple[str, str, Any, bool]] = []
|
|
seen: set[str] = set()
|
|
|
|
for category, name, value in _iter_plugin_branches(config_data):
|
|
normalized_name = str(name or "").strip().lower()
|
|
if not normalized_name:
|
|
continue
|
|
branches.append((category, name, value, True))
|
|
seen.add(normalized_name)
|
|
|
|
for name in _get_configurable_plugin_names():
|
|
if name in seen:
|
|
continue
|
|
synthetic = _build_synthetic_plugin_branch("plugin", name)
|
|
if synthetic is None:
|
|
continue
|
|
branches.append(("plugin", name, synthetic, False))
|
|
seen.add(name)
|
|
|
|
for name in _get_configurable_tool_names():
|
|
if name in seen:
|
|
continue
|
|
synthetic = _build_synthetic_plugin_branch("tool", name)
|
|
if synthetic is None:
|
|
continue
|
|
branches.append(("tool", name, synthetic, False))
|
|
seen.add(name)
|
|
|
|
return branches
|
|
|
|
|
|
def _collect_plugin_root_items(config_data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
plugin_items: Dict[str, Dict[str, Any]] = {}
|
|
for category, name, value, is_configured in _iter_available_plugin_branches(config_data):
|
|
key = str(name or "").strip().lower()
|
|
if not key:
|
|
continue
|
|
existing = plugin_items.get(key)
|
|
if existing is None:
|
|
plugin_items[key] = {
|
|
"kind": "section",
|
|
"title": _format_config_label(name),
|
|
"browse_path": f"{category}.{name}",
|
|
"summary": _format_config_entry_count(value),
|
|
"type": "section",
|
|
"description": "Plugin configuration" if is_configured else "Plugin configuration (available to configure)",
|
|
}
|
|
continue
|
|
|
|
if str(category) == "plugin" and not str(existing.get("browse_path") or "").startswith("plugin."):
|
|
existing["browse_path"] = f"{category}.{name}"
|
|
try:
|
|
current_count = int(str(existing.get("summary") or "0").split()[0])
|
|
except Exception:
|
|
current_count = 0
|
|
extra_count = len(_visible_config_entries(value)) if isinstance(value, dict) else 0
|
|
merged_count = current_count + extra_count
|
|
existing["summary"] = "1 entry" if merged_count == 1 else f"{merged_count} entries"
|
|
|
|
return sorted(plugin_items.values(), key=lambda item: str(item.get("title") or "").lower())
|
|
|
|
|
|
def _resolve_config_branch(
|
|
config_data: Dict[str, Any],
|
|
browse_path: Optional[str],
|
|
) -> Optional[Dict[str, Any]]:
|
|
text = str(browse_path or "").strip()
|
|
if not text:
|
|
return config_data if isinstance(config_data, dict) else None
|
|
|
|
if text == _PREFERENCES_BROWSE_PATH:
|
|
return {
|
|
key: value
|
|
for key, value in _visible_config_entries(config_data)
|
|
if not isinstance(value, dict)
|
|
}
|
|
|
|
if text == _PLUGINS_BROWSE_PATH:
|
|
return {
|
|
str(item.get("title") or ""): item
|
|
for item in _collect_plugin_root_items(config_data)
|
|
}
|
|
|
|
parts = [part for part in text.split(".") if part]
|
|
if len(parts) >= 2 and parts[0] in _PLUGIN_CATEGORY_KEYS:
|
|
resolved = _resolve_plugin_branch(config_data, parts[0], parts[1])
|
|
if resolved is None:
|
|
return None
|
|
_, current, _ = resolved
|
|
if parts[0] == "plugin" and _is_multi_instance_plugin_name(parts[1]):
|
|
current = _normalize_multi_instance_branch(parts[1], current)
|
|
for part in parts[2:]:
|
|
if not isinstance(current, dict):
|
|
return None
|
|
current = current.get(part)
|
|
return current if isinstance(current, dict) else None
|
|
|
|
current: Any = config_data
|
|
for part in parts:
|
|
if not isinstance(current, dict):
|
|
return None
|
|
current = current.get(part)
|
|
return current if isinstance(current, dict) else None
|
|
|
|
|
|
def _build_section_item(
|
|
*,
|
|
title: str,
|
|
browse_path: str,
|
|
value: Any,
|
|
description: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
return {
|
|
"kind": "section",
|
|
"title": title,
|
|
"browse_path": browse_path,
|
|
"summary": _format_config_entry_count(value),
|
|
"type": "section",
|
|
"description": str(description or "").strip() or _KNOWN_SECTION_DESCRIPTIONS.get(browse_path, ""),
|
|
}
|
|
|
|
|
|
def _build_value_item(
|
|
*,
|
|
key_path: str,
|
|
name: str,
|
|
value: Any,
|
|
) -> 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]
|
|
display_path = " / ".join(
|
|
[_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)]
|
|
)
|
|
return {
|
|
"kind": "value",
|
|
"key": key_path,
|
|
"name": name,
|
|
"title": _format_config_label(name),
|
|
"value": value,
|
|
"value_display": display_value,
|
|
"display_path": display_path,
|
|
"type": type(value).__name__,
|
|
}
|
|
|
|
|
|
def _create_or_get_plugin_instance(
|
|
config_data: Dict[str, Any],
|
|
instance_target: str,
|
|
instance_name: str,
|
|
) -> tuple[str, bool]:
|
|
parts = _split_config_path(instance_target)
|
|
if len(parts) != 2 or parts[0] != "plugin":
|
|
raise ValueError(f"Unsupported instance target '{instance_target}'")
|
|
|
|
category, plugin_name = parts
|
|
raw_instance_name = str(instance_name or "").strip()
|
|
if not raw_instance_name:
|
|
raise ValueError("Instance name is required")
|
|
if raw_instance_name.startswith("_"):
|
|
raise ValueError("Instance names cannot start with '_' characters")
|
|
|
|
category_block = config_data.get(category)
|
|
if not isinstance(category_block, dict):
|
|
category_block = {}
|
|
config_data[category] = category_block
|
|
|
|
plugin_block = category_block.get(plugin_name)
|
|
if not isinstance(plugin_block, dict):
|
|
plugin_block = {}
|
|
category_block[plugin_name] = plugin_block
|
|
|
|
if _looks_like_single_instance_branch(plugin_name, plugin_block):
|
|
existing_default = dict(plugin_block)
|
|
plugin_block.clear()
|
|
plugin_block["default"] = existing_default
|
|
|
|
target_key = None
|
|
lowered_target = raw_instance_name.lower()
|
|
for existing_key in plugin_block.keys():
|
|
if str(existing_key or "").strip().lower() == lowered_target:
|
|
target_key = str(existing_key)
|
|
break
|
|
|
|
if target_key is not None and isinstance(plugin_block.get(target_key), dict):
|
|
return f"{category}.{plugin_name}.{target_key}", False
|
|
|
|
plugin_block[raw_instance_name] = dict(build_default_plugin_config(plugin_name))
|
|
return f"{category}.{plugin_name}.{raw_instance_name}", True
|
|
|
|
|
|
def _resolve_update_key(config_data: Dict[str, Any], selection_key: str) -> str:
|
|
parts = _split_config_path(selection_key)
|
|
if (
|
|
len(parts) >= 4
|
|
and parts[0] == "plugin"
|
|
and parts[2].lower() == "default"
|
|
and _is_multi_instance_plugin_name(parts[1])
|
|
):
|
|
category_block = config_data.get(parts[0])
|
|
plugin_block = category_block.get(parts[1]) if isinstance(category_block, dict) else None
|
|
if _looks_like_single_instance_branch(parts[1], plugin_block):
|
|
return ".".join([parts[0], parts[1], *parts[3:]])
|
|
return selection_key
|
|
|
|
|
|
def _build_root_config_items(config_data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
items: List[Dict[str, Any]] = []
|
|
visible_entries = _visible_config_entries(config_data)
|
|
|
|
preferences = {
|
|
key: value
|
|
for key, value in visible_entries
|
|
if not isinstance(value, dict)
|
|
}
|
|
if preferences:
|
|
items.append(
|
|
_build_section_item(
|
|
title="Preferences",
|
|
browse_path=_PREFERENCES_BROWSE_PATH,
|
|
value=preferences,
|
|
)
|
|
)
|
|
|
|
plugin_items = _collect_plugin_root_items(config_data)
|
|
if plugin_items:
|
|
items.append(
|
|
_build_section_item(
|
|
title="Plugins",
|
|
browse_path=_PLUGINS_BROWSE_PATH,
|
|
value={item["title"]: item for item in plugin_items},
|
|
)
|
|
)
|
|
|
|
other_sections: List[Dict[str, Any]] = []
|
|
for key, value in visible_entries:
|
|
if key in set(_PLUGIN_CATEGORY_KEYS) or not isinstance(value, dict):
|
|
continue
|
|
other_sections.append(
|
|
_build_section_item(
|
|
title=_format_config_label(key),
|
|
browse_path=key,
|
|
value=value,
|
|
)
|
|
)
|
|
|
|
other_sections.sort(key=lambda item: str(item.get("title") or "").lower())
|
|
items.extend(other_sections)
|
|
return items
|
|
|
|
|
|
def _build_nested_config_items(
|
|
config_data: Dict[str, Any],
|
|
browse_path: str,
|
|
) -> List[Dict[str, Any]]:
|
|
if browse_path == _PLUGINS_BROWSE_PATH:
|
|
return _collect_plugin_root_items(config_data)
|
|
|
|
branch = _resolve_config_branch(config_data, browse_path)
|
|
if branch is None:
|
|
return []
|
|
|
|
section_items: List[Dict[str, Any]] = []
|
|
value_items: List[Dict[str, Any]] = []
|
|
action_items: List[Dict[str, Any]] = []
|
|
is_preferences_view = browse_path == _PREFERENCES_BROWSE_PATH
|
|
parts = _split_config_path(browse_path)
|
|
is_multi_instance_root = _is_multi_instance_plugin_root_path(browse_path)
|
|
|
|
if is_multi_instance_root:
|
|
branch = _normalize_multi_instance_branch(parts[1], branch)
|
|
|
|
for key, value in _visible_config_entries(branch):
|
|
full_key = key if is_preferences_view else f"{browse_path}.{key}"
|
|
if isinstance(value, dict):
|
|
section_items.append(
|
|
_build_section_item(
|
|
title=_format_config_label(key),
|
|
browse_path=full_key,
|
|
value=value,
|
|
)
|
|
)
|
|
else:
|
|
value_items.append(
|
|
_build_value_item(
|
|
key_path=full_key,
|
|
name=key,
|
|
value=value,
|
|
)
|
|
)
|
|
|
|
section_items.sort(key=lambda item: str(item.get("title") or "").lower())
|
|
value_items.sort(key=lambda item: str(item.get("name") or "").lower())
|
|
if is_multi_instance_root:
|
|
action_items.append(_build_create_instance_item(parts[0], parts[1]))
|
|
return section_items + value_items + action_items
|
|
|
|
|
|
def _build_config_items(
|
|
config_data: Dict[str, Any],
|
|
browse_path: Optional[str] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
text = str(browse_path or "").strip()
|
|
if not text:
|
|
return _build_root_config_items(config_data)
|
|
return _build_nested_config_items(config_data, text)
|
|
|
|
|
|
def _build_config_table_title(browse_path: Optional[str]) -> str:
|
|
text = str(browse_path or "").strip()
|
|
if not text:
|
|
return "Configuration"
|
|
return f"Configuration: {_format_config_path_label(text)}"
|
|
|
|
|
|
def _build_config_header_lines(browse_path: Optional[str]) -> List[str]:
|
|
text = str(browse_path or "").strip()
|
|
if not text:
|
|
return [
|
|
"Use @N on a section to drill in. Use @.. to go back.",
|
|
]
|
|
if _is_multi_instance_plugin_root_path(text):
|
|
return [
|
|
f"Path: {_format_config_path_label(text)}",
|
|
"Use @N on an instance to drill in. Use @N | .config <name> on Add Instance to create a new instance, then update its fields in the table that opens. Use @.. to go back.",
|
|
]
|
|
parts = _split_config_path(text)
|
|
if (
|
|
len(parts) == 3
|
|
and parts[0] == "plugin"
|
|
and _is_multi_instance_plugin_name(parts[1])
|
|
):
|
|
return [
|
|
f"Path: {_format_config_path_label(text)}",
|
|
"Use @N | .config <value> to update a setting. After creating an instance, set its path, credentials, or other fields here. Use @.. to go back.",
|
|
]
|
|
return [
|
|
f"Path: {_format_config_path_label(text)}",
|
|
"Use @N on a section to drill in. Use @N | .config <value> to update a setting. Use @.. to go back.",
|
|
]
|
|
|
|
|
|
def _extract_create_instance_target(args: Sequence[str]) -> Optional[str]:
|
|
return _extract_arg_value(args, flags={_CREATE_INSTANCE_FLAG, "--create-instance"}, allow_positional=False)
|
|
|
|
|
|
def _extract_browse_arg(args: Sequence[str]) -> Optional[str]:
|
|
return _extract_arg_value(args, flags={"-browse", "--browse"}, allow_positional=False)
|
|
|
|
|
|
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:
|
|
return explicit
|
|
|
|
tokens = [str(arg).strip() for arg in (args or []) if str(arg).strip()]
|
|
positional = [token for token in tokens if not token.startswith("-")]
|
|
if len(positional) == 1:
|
|
return positional[0]
|
|
return None
|
|
|
|
|
|
def _get_selected_config_item() -> Optional[Dict[str, Any]]:
|
|
try:
|
|
indices = ctx.get_last_selection() or []
|
|
except Exception:
|
|
indices = []
|
|
try:
|
|
items = ctx.get_last_result_items() or []
|
|
except Exception:
|
|
items = []
|
|
if not indices or not items:
|
|
return None
|
|
idx = indices[0]
|
|
if idx < 0 or idx >= len(items):
|
|
return None
|
|
return _normalize_config_item(items[idx])
|
|
|
|
|
|
def _normalize_config_item(candidate: Any) -> Optional[Dict[str, Any]]:
|
|
if candidate is None:
|
|
return None
|
|
|
|
normalized: Dict[str, Any] = {}
|
|
sources: List[Any] = [candidate]
|
|
|
|
if isinstance(candidate, dict):
|
|
extra = candidate.get("extra")
|
|
if isinstance(extra, dict):
|
|
sources.append(extra)
|
|
else:
|
|
try:
|
|
extra = getattr(candidate, "extra", None)
|
|
except Exception:
|
|
extra = None
|
|
if isinstance(extra, dict):
|
|
sources.append(extra)
|
|
|
|
for source in sources:
|
|
if isinstance(source, dict):
|
|
getter = source.get
|
|
for key in _CONFIG_ITEM_FIELDS:
|
|
if key in normalized:
|
|
continue
|
|
value = getter(key)
|
|
if value is not None:
|
|
normalized[key] = value
|
|
continue
|
|
|
|
for key in _CONFIG_ITEM_FIELDS:
|
|
if key in normalized:
|
|
continue
|
|
try:
|
|
value = getattr(source, key, None)
|
|
except Exception:
|
|
value = None
|
|
if value is not None:
|
|
normalized[key] = value
|
|
|
|
return normalized or None
|
|
|
|
|
|
def _show_config_table(
|
|
config_data: Dict[str, Any],
|
|
*,
|
|
browse_path: Optional[str] = None,
|
|
) -> int:
|
|
items = _build_config_items(config_data, browse_path=browse_path)
|
|
if not items:
|
|
path_text = _format_config_path_label(browse_path)
|
|
print(f"No configuration entries available for {path_text}.")
|
|
return 0
|
|
|
|
table = Table(_build_config_table_title(browse_path), preserve_order=True)
|
|
table.set_table("config")
|
|
if browse_path:
|
|
table.set_source_command(".config", ["-browse", str(browse_path)])
|
|
else:
|
|
table.set_source_command(".config", [])
|
|
table.set_header_lines(_build_config_header_lines(browse_path))
|
|
|
|
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", ""))
|
|
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") == "create_instance" and item.get("instance_target"):
|
|
table.set_row_selection_action(
|
|
idx,
|
|
[".config", _CREATE_INSTANCE_FLAG, str(item.get("instance_target"))],
|
|
)
|
|
|
|
ctx.set_last_result_table(table, items)
|
|
ctx.set_current_stage_table(table)
|
|
return 0
|
|
|
|
|
|
def _save_updated_config(config_data: Dict[str, Any], key_path: str) -> None:
|
|
try:
|
|
key_l = str(key_path or "").lower()
|
|
except Exception:
|
|
key_l = ""
|
|
if "alldebrid" in key_l or "all-debrid" in key_l:
|
|
save_config_and_verify(config_data)
|
|
return
|
|
save_config(config_data)
|
|
|
|
|
|
def _resolve_direct_browse_path(
|
|
config_data: Dict[str, Any],
|
|
token: str,
|
|
) -> Optional[str]:
|
|
text = str(token or "").strip()
|
|
if not text:
|
|
return None
|
|
lowered = text.lower()
|
|
if lowered in {"preferences", "prefs"}:
|
|
return _PREFERENCES_BROWSE_PATH
|
|
if lowered in {"plugins", "plugin"}:
|
|
return _PLUGINS_BROWSE_PATH
|
|
|
|
plugin_branch = _resolve_plugin_branch(config_data, "plugin", lowered)
|
|
if plugin_branch is not None:
|
|
return f"plugin.{plugin_branch[0]}"
|
|
|
|
branch = _resolve_config_branch(config_data, text)
|
|
if isinstance(branch, dict):
|
|
return text
|
|
return None
|
|
|
|
|
|
def _strip_value_quotes(value: str) -> str:
|
|
if not value:
|
|
return value
|
|
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
|
|
return value[1:-1]
|
|
return value
|
|
|
|
|
|
def _run(piped_result: Any, args: List[str], config: Dict[str, Any]) -> int:
|
|
import sys
|
|
|
|
if _has_flag(args, "-log") or _has_flag(args, "--log"):
|
|
return _show_config_logs(args)
|
|
|
|
# Load configuration from the database
|
|
current_config = load_config()
|
|
|
|
browse_path = _extract_browse_arg(args)
|
|
if browse_path:
|
|
return _show_config_table(current_config, browse_path=browse_path)
|
|
|
|
selection_item = _get_selected_config_item() or _normalize_config_item(piped_result)
|
|
|
|
create_instance_target = _extract_create_instance_target(args)
|
|
if create_instance_target:
|
|
print(
|
|
f"Use @N | .config <instance_name> to create a new instance under '{_format_config_path_label(create_instance_target)}', then set its fields in the table that opens."
|
|
)
|
|
return 0
|
|
|
|
value_from_pipe = _extract_piped_value(piped_result)
|
|
selection_kind = str((selection_item or {}).get("kind") or "").strip().lower()
|
|
selection_key = str((selection_item or {}).get("key") or "").strip() or None
|
|
selection_browse_path = str((selection_item or {}).get("browse_path") or "").strip() or None
|
|
selection_display_path = str((selection_item or {}).get("display_path") or selection_key or "").strip() or selection_key
|
|
selection_instance_target = str((selection_item or {}).get("instance_target") or "").strip() or None
|
|
|
|
if selection_kind == "section" and selection_browse_path and not args and value_from_pipe is None:
|
|
return _show_config_table(current_config, browse_path=selection_browse_path)
|
|
|
|
if selection_kind == "create_instance" and selection_instance_target:
|
|
new_instance_name = value_from_pipe or _extract_selected_update_value(args)
|
|
if new_instance_name is None:
|
|
print(
|
|
f"Use @N | .config <instance_name> to create a new instance under '{_format_config_path_label(selection_instance_target)}', then set its fields in the table that opens."
|
|
)
|
|
return 0
|
|
new_instance_name = _strip_value_quotes(new_instance_name)
|
|
try:
|
|
new_browse_path, created = _create_or_get_plugin_instance(
|
|
current_config,
|
|
selection_instance_target,
|
|
new_instance_name,
|
|
)
|
|
_save_updated_config(current_config, new_browse_path)
|
|
status_text = "Created" if created else "Using existing"
|
|
print(
|
|
f"{status_text} instance '{new_instance_name}' at '{_format_config_path_label(new_browse_path)}'. "
|
|
"Configure its fields in the table below."
|
|
)
|
|
return _show_config_table(current_config, browse_path=new_browse_path)
|
|
except Exception as exc:
|
|
log(f"Error creating config instance '{selection_instance_target}': {exc}")
|
|
print(f"Error creating config instance: {exc}")
|
|
return 1
|
|
|
|
if selection_kind == "value" and selection_key:
|
|
new_value = value_from_pipe or _extract_selected_update_value(args)
|
|
if new_value is not None:
|
|
new_value = _strip_value_quotes(new_value)
|
|
target_key = _resolve_update_key(current_config, selection_key)
|
|
try:
|
|
set_nested_config(current_config, target_key, new_value)
|
|
_save_updated_config(current_config, target_key)
|
|
print(f"Updated '{selection_display_path}' to '{new_value}'")
|
|
return 0
|
|
except Exception as exc:
|
|
log(f"Error updating config '{target_key}': {exc}")
|
|
print(f"Error updating config: {exc}")
|
|
return 1
|
|
|
|
if not args:
|
|
if sys.stdin.isatty() and not piped_result:
|
|
print(
|
|
"Interactive TUI config editor has been discontinued. "
|
|
"Showing configuration table instead."
|
|
)
|
|
return _show_config_table(current_config)
|
|
|
|
key = args[0]
|
|
if len(args) < 2:
|
|
browse_target = _resolve_direct_browse_path(current_config, key)
|
|
if browse_target:
|
|
return _show_config_table(current_config, browse_path=browse_target)
|
|
print(f"Error: Value required for key '{key}'")
|
|
return 1
|
|
|
|
value = _strip_value_quotes(" ".join(args[1:]))
|
|
try:
|
|
set_nested_config(current_config, key, value)
|
|
_save_updated_config(current_config, key)
|
|
print(f"Updated '{key}' to '{value}'")
|
|
return 0
|
|
except Exception as exc:
|
|
log(f"Error updating config '{key}': {exc}")
|
|
print(f"Error updating config: {exc}")
|
|
return 1
|
|
|
|
|
|
CMDLET.exec = _run
|