This commit is contained in:
2026-01-30 16:24:08 -08:00
parent e57dcf2190
commit 20045b8de8
5 changed files with 989 additions and 79 deletions

View File

@@ -280,11 +280,87 @@ def get_cmdlet_arg_flags(cmd_name: str, config: Optional[Dict[str, Any]] = None)
def get_cmdlet_arg_choices(
cmd_name: str, arg_name: str, config: Optional[Dict[str, Any]] = None
) -> List[str]:
"""Return declared choices for a cmdlet argument."""
"""Return declared choices for a cmdlet argument.
Special-cases dynamic choices for certain arguments (e.g., Matrix -room)
which may be populated from configuration or provider queries.
"""
meta = get_cmdlet_metadata(cmd_name, config=config)
if not meta:
return []
target = arg_name.lstrip("-")
# Dynamic handling for Matrix room choices
try:
canonical = (meta.get("name") or str(cmd_name)).replace("_", "-")
except Exception:
canonical = str(cmd_name)
if target == "room" and canonical in (".matrix", "matrix"):
# Load default room IDs from configuration and attempt to resolve display names
try:
if config is None:
from SYS.config import load_config
config = load_config()
except Exception:
config = config or {}
matrix_conf = {}
try:
providers = config.get("provider") or {}
matrix_conf = providers.get("matrix") or {}
except Exception:
matrix_conf = {}
raw = None
for key in ("room", "room_id", "rooms", "room_ids"):
if key in matrix_conf:
raw = matrix_conf.get(key)
break
ids: List[str] = []
try:
if isinstance(raw, (list, tuple, set)):
ids = [str(v).strip() for v in raw if str(v).strip()]
else:
text = str(raw or "").strip()
if text:
import re
ids = [p.strip() for p in re.split(r"[,\s]+", text) if p and p.strip()]
except Exception:
ids = []
if ids:
# Try to resolve names via Provider.matrix if config provides auth info
try:
hs = matrix_conf.get("homeserver")
token = matrix_conf.get("access_token")
if hs and token:
try:
from Provider.matrix import Matrix
try:
m = Matrix(config)
rooms = m.list_rooms(room_ids=ids)
choices = []
for r in rooms or []:
name = str(r.get("name") or "").strip()
rid = str(r.get("room_id") or "").strip()
choices.append(name or rid)
if choices:
return choices
except Exception:
pass
except Exception:
pass
except Exception:
pass
# Fallback: return raw ids as choices
return ids
# Default static choices from metadata
for arg in meta.get("args", []):
if arg.get("name") == target:
return list(arg.get("choices", []) or [])

View File

@@ -1,17 +1,18 @@
import re
from copy import deepcopy
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Iterable
import traceback
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
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select, Checkbox
from pathlib import Path
from SYS.config import load_config, save_config, reload_config, global_config, count_changed_entries, ConfigSaveConflict
from SYS.database import db
from SYS.logger import log
from SYS.logger import log, debug
from Store.registry import _discover_store_classes, _required_keys_for
from ProviderCore.registry import list_providers
from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker
@@ -81,11 +82,6 @@ class ConfigModal(ModalScreen):
width: 1fr;
}
.paste-btn {
width: 10;
margin-left: 1;
}
#config-actions {
height: 3;
align: right middle;
@@ -112,6 +108,20 @@ class ConfigModal(ModalScreen):
Button {
margin: 0 1;
}
/* Inline matrix rooms list sizing & style (larger, scrollable) */
#matrix-rooms-inline {
height: 16;
border: solid $surface;
padding: 1;
margin-bottom: 1;
}
.matrix-room-row {
border-bottom: solid $surface;
padding: 1 0;
align: left middle;
}
"""
def __init__(self) -> None:
@@ -126,6 +136,9 @@ class ConfigModal(ModalScreen):
self._matrix_status: Optional[Static] = None
self._matrix_test_running = False
self._editor_snapshot: Optional[Dict[str, Any]] = None
# Inline matrix rooms controls
self._matrix_inline_list: Optional[ListView] = None
self._matrix_inline_checkbox_map: Dict[str, str] = {}
# Path to the database file used by this process (for diagnostics)
self._db_path = str(db.db_path)
@@ -277,18 +290,30 @@ class ConfigModal(ModalScreen):
container.mount(Label(label_text))
inp_id = f"global-{idx}"
self._input_id_map[inp_id] = found_key
if choices:
select_options = [(str(c), str(c)) for c in choices]
if current_val not in [str(c) for c in choices]:
select_options.insert(0, (current_val, current_val))
sel = Select(select_options, value=current_val, id=inp_id)
# 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"))
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
# Show any other top-level keys not in schema
@@ -444,13 +469,23 @@ class ConfigModal(ModalScreen):
idx = 0
for k, v in section.items():
if k.startswith("_"): continue
# Skip low-level keys that shouldn't be editable via the form UI
if (
item_type == "provider"
and isinstance(item_name, str)
and item_name.strip().lower() == "matrix"
and str(k or "").strip().lower() in ("rooms", "cached_rooms")
):
# These are managed by the inline UI and should not be edited directly.
continue
# Deduplicate keys case-insensitively (e.g. name vs NAME vs Name)
k_upper = k.upper()
if k_upper in existing_keys_upper:
continue
existing_keys_upper.add(k_upper)
# Determine display props from schema
label_text = k
is_secret = False
@@ -469,22 +504,29 @@ class ConfigModal(ModalScreen):
self._input_id_map[inp_id] = k
if choices:
# Select takes a list of (label, value) tuples
# 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:
select_options.append((str(c[0]), str(c[1])))
choice_values.append(str(c[1]))
label = str(c[0])
val = str(c[1])
else:
select_options.append((str(c), str(c)))
choice_values.append(str(c))
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:
@@ -494,7 +536,6 @@ class ConfigModal(ModalScreen):
if is_secret:
inp.password = True
row.mount(inp)
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
# Add required/optional fields from schema that are missing
@@ -518,16 +559,25 @@ class ConfigModal(ModalScreen):
choice_values = []
for c in choices:
if isinstance(c, tuple) and len(c) == 2:
select_options.append((str(c[0]), str(c[1])))
choice_values.append(str(c[1]))
label = str(c[0])
val = str(c[1])
else:
select_options.append((str(c), str(c)))
choice_values.append(str(c))
if default_val not in choice_values:
select_options.insert(0, (default_val, default_val))
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)
sel = Select(select_options, value=default_val, id=inp_id)
# 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")
@@ -556,7 +606,6 @@ class ConfigModal(ModalScreen):
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 it's a provider, we might have required keys (legacy check fallback)
@@ -592,25 +641,101 @@ class ConfigModal(ModalScreen):
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Button("Test connection", id="matrix-test-btn"))
row.mount(Button("Choose default rooms", variant="primary", id="matrix-rooms-btn"))
# Load rooms refreshes the inline list and caches the results (no popup)
row.mount(Button("Load rooms", variant="primary", id="matrix-load-btn"))
self._matrix_status = status
# Inline rooms list for selecting default rooms (populated after a successful test)
container.mount(Label("Default Rooms", classes="config-label", id="matrix-inline-label"))
# Start with empty list; it will be filled when test loads rooms
container.mount(ListView(id="matrix-rooms-inline"))
# Inline actions
row2 = Horizontal(classes="field-row")
container.mount(row2)
row2.mount(Button("Select All", id="matrix-inline-select-all"))
row2.mount(Button("Clear All", id="matrix-inline-clear"))
save_inline = Button("Save defaults", variant="success", id="matrix-inline-save")
save_inline.disabled = True
row2.mount(save_inline)
# Local bookkeeping maps
try:
self._matrix_inline_checkbox_map = {}
self._matrix_inline_list = self.query_one("#matrix-rooms-inline", ListView)
# Do NOT auto-render cached rooms here; only show explicitly saved defaults
try:
existing_ids = self._parse_matrix_rooms_value()
cached = self._get_cached_matrix_rooms()
rooms_to_render: List[Dict[str, Any]] = []
# Start with cached rooms (from last Load). These are shown
# in the inline Default Rooms list but are unselected unless
# they are in the saved defaults list.
if cached:
rooms_to_render.extend(cached)
# Ensure saved default room ids are present and will be selected
if existing_ids:
cached_ids = {str(r.get("room_id") or "").strip() for r in rooms_to_render if isinstance(r, dict)}
need_resolve = [rid for rid in existing_ids if rid not in cached_ids]
if need_resolve:
try:
resolved = self._resolve_matrix_rooms_by_ids(need_resolve)
if resolved:
rooms_to_render.extend(resolved)
else:
rooms_to_render.extend([{"room_id": rid, "name": ""} for rid in need_resolve])
except Exception:
rooms_to_render.extend([{"room_id": rid, "name": ""} for rid in need_resolve])
# Deduplicate while preserving order
deduped: List[Dict[str, Any]] = []
seen_ids: set[str] = set()
for r in rooms_to_render:
try:
rid = str(r.get("room_id") or "").strip()
if not rid or rid in seen_ids:
continue
seen_ids.add(rid)
deduped.append(r)
except Exception:
continue
if self._matrix_inline_list is not None and deduped:
try:
self._render_matrix_rooms_inline(deduped)
except Exception:
pass
except Exception:
pass
except Exception:
self._matrix_inline_checkbox_map = {}
self._matrix_inline_list = None
def create_field(self, name: str, value: Any, id: str) -> Vertical:
# This method is now unused - we mount labels and inputs directly
v = Vertical(classes="config-field")
return v
def on_list_view_selected(self, event: ListView.Selected) -> None:
if not event.item: return
if event.item.id == "cat-globals":
# Only respond to selections from the left-hand category list. Avoid
# resetting editor state when other ListViews (like the inline rooms
# list) trigger selection events.
if not event.item:
return
item_id = getattr(event.item, "id", None)
if item_id not in ("cat-globals", "cat-stores", "cat-providers", "cat-tools"):
return
if item_id == "cat-globals":
self.current_category = "globals"
elif event.item.id == "cat-stores":
elif item_id == "cat-stores":
self.current_category = "stores"
elif event.item.id == "cat-providers":
elif item_id == "cat-providers":
self.current_category = "providers"
elif event.item.id == "cat-tools":
elif item_id == "cat-tools":
self.current_category = "tools"
# Reset editor state and refresh view for the new category
self.editing_item_name = None
self.editing_item_type = None
self.refresh_view()
@@ -745,17 +870,63 @@ class ConfigModal(ModalScreen):
self.app.push_screen(SelectionModal("Select Tool", options), callback=self.on_tool_type_selected)
elif bid == "matrix-test-btn":
self._request_matrix_test()
elif bid == "matrix-rooms-btn":
self._open_matrix_room_picker()
# Restore UI removed: backups/audit remain available programmatically
elif bid.startswith("paste-"):
# Programmatic paste button
target_id = bid.replace("paste-", "")
elif bid == "matrix-load-btn":
# Refresh the inline rooms list and cache the results (no popup)
self._request_matrix_load()
elif bid == "matrix-inline-select-all":
for checkbox_id in list(self._matrix_inline_checkbox_map.keys()):
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
cb.value = True
except Exception:
pass
try:
inp = self.query_one(f"#{target_id}", Input)
self.focus_and_paste(inp)
self.query_one("#matrix-inline-save", Button).disabled = False
except Exception:
pass
elif bid == "matrix-inline-clear":
for checkbox_id in list(self._matrix_inline_checkbox_map.keys()):
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
cb.value = False
except Exception:
pass
try:
self.query_one("#matrix-inline-save", Button).disabled = True
except Exception:
pass
elif bid == "matrix-inline-save":
selected: List[str] = []
for checkbox_id, room_id in self._matrix_inline_checkbox_map.items():
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
if cb.value and room_id:
selected.append(room_id)
except Exception:
pass
if not selected:
if self._matrix_status:
self._matrix_status.update("No default rooms were saved.")
return
matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {})
matrix_block["rooms"] = ", ".join(selected)
changed = count_changed_entries(self.config_data)
try:
entries = save_config(self.config_data)
except Exception as exc:
if self._matrix_status:
self._matrix_status.update(f"Saving default rooms failed: {exc}")
return
self.config_data = reload_config()
if self._matrix_status:
status = f"Saved {len(selected)} default room(s) ({changed} change(s)) to {db.db_path.name}."
self._matrix_status.update(status)
try:
self.query_one("#matrix-inline-save", Button).disabled = True
except Exception:
pass
self.refresh_view()
async def focus_and_paste(self, inp: Input) -> None:
if hasattr(self.app, "paste_from_clipboard"):
@@ -1025,6 +1196,20 @@ class ConfigModal(ModalScreen):
if self._matrix_test_running:
return
self._synchronize_inputs_to_config()
# Quick client-side pre-check before attempting to save/test to provide
# immediate guidance when required fields are missing.
try:
matrix_block = self.config_data.get("provider", {}).get("matrix", {})
hs = matrix_block.get("homeserver")
token = matrix_block.get("access_token")
if not hs or not token:
if self._matrix_status:
self._matrix_status.update("Matrix test skipped: please set both 'homeserver' and 'access_token' before testing.")
return
except Exception:
pass
if self._matrix_status:
self._matrix_status.update("Saving configuration before testing…")
changed = count_changed_entries(self.config_data)
@@ -1050,23 +1235,215 @@ class ConfigModal(ModalScreen):
rooms = provider.list_rooms()
self.app.call_from_thread(self._matrix_test_result, True, rooms, None)
except Exception as exc:
self.app.call_from_thread(self._matrix_test_result, False, [], str(exc))
# Log full traceback for diagnostics but present a concise, actionable
# message to the user in the UI.
tb = traceback.format_exc()
try:
debug(f"[matrix] Test connection failed: {exc}\n{tb}")
except Exception:
pass
def _matrix_test_result(
self,
success: bool,
rooms: List[Dict[str, Any]],
error: Optional[str],
) -> None:
msg = str(exc) or "Matrix test failed"
m_lower = msg.lower()
if "auth" in m_lower or "authentication" in m_lower:
msg = msg + ". Please verify your access token and try again."
elif "homeserver" in m_lower or "missing" in m_lower:
msg = msg + ". Check your homeserver URL (include https://)."
else:
msg = msg + " (see logs for details)"
self.app.call_from_thread(self._matrix_test_result, False, [], msg)
def _get_cached_matrix_rooms(self) -> List[Dict[str, Any]]:
"""Return cached rooms stored in the provider config (normalized).
The config value can be a list/dict, a JSON string, or a Python literal
string (repr). This method normalizes the input and returns a list of
dicts containing 'room_id' and 'name'. Malformed inputs are ignored.
"""
try:
block = self._get_matrix_provider_block()
raw = block.get("cached_rooms")
if not raw:
return []
# If it's already a list or tuple, normalize each element
if isinstance(raw, (list, tuple)):
return self._normalize_cached_raw(list(raw))
# If it's a dict, wrap and normalize
if isinstance(raw, dict):
return self._normalize_cached_raw([raw])
# If it's a string, try JSON -> ast.literal_eval -> regex ID extraction
if isinstance(raw, str):
s = str(raw).strip()
if not s:
return []
# Try JSON first (strict)
try:
import json
parsed = json.loads(s)
if isinstance(parsed, (list, tuple, dict)):
return self._normalize_cached_raw(parsed if isinstance(parsed, (list, tuple)) else [parsed])
except Exception:
pass
# Try Python literal eval (accepts single quotes, repr-style lists)
try:
import ast
parsed = ast.literal_eval(s)
if isinstance(parsed, (list, tuple, dict)):
return self._normalize_cached_raw(parsed if isinstance(parsed, (list, tuple)) else [parsed])
except Exception:
pass
# Try to extract dict-like pairs for room_id/name when the string looks like
# a Python repr or partial dict fragment (e.g., "'room_id': '!r1', 'name': 'Room'"
try:
import re
pair_pat = re.compile(r"[\"']room_id[\"']\s*:\s*[\"'](?P<id>[^\"']+)[\"']\s*,\s*[\"']name[\"']\s*:\s*[\"'](?P<name>[^\"']+)[\"']")
pairs = [m.groupdict() for m in pair_pat.finditer(s)]
if pairs:
out = []
for p in pairs:
rid = str(p.get("id") or "").strip()
name = str(p.get("name") or "").strip()
if rid:
out.append({"room_id": rid, "name": name})
if out:
return out
# As a last resort, extract candidate room ids via regex (look for leading '!')
ids = re.findall(r"![-A-Za-z0-9._=]+(?::[-A-Za-z0-9._=]+)?", s)
if ids:
return [{"room_id": rid, "name": ""} for rid in ids]
except Exception:
pass
return []
except Exception:
pass
return []
def _normalize_cached_raw(self, parsed: List[Any]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for it in parsed:
try:
if isinstance(it, dict):
rid = str(it.get("room_id") or "").strip()
name = str(it.get("name") or "").strip()
if rid:
out.append({"room_id": rid, "name": name})
elif isinstance(it, str):
s = str(it or "").strip()
if s:
out.append({"room_id": s, "name": ""})
except Exception:
continue
return out
def _request_matrix_load(self) -> None:
"""Save current config and request a background load of joined rooms.
This replaces the old "Choose default rooms" popup and instead refreshes
the inline default rooms list and caches the results to config.
"""
if self._matrix_test_running:
return
self._synchronize_inputs_to_config()
# Quick client-side pre-check for required fields
try:
matrix_block = self.config_data.get("provider", {}).get("matrix", {})
hs = matrix_block.get("homeserver")
token = matrix_block.get("access_token")
if not hs or not token:
if self._matrix_status:
self._matrix_status.update("Load skipped: please set both 'homeserver' and 'access_token' before loading rooms.")
return
except Exception:
pass
if self._matrix_status:
self._matrix_status.update("Saving configuration before loading rooms…")
changed = count_changed_entries(self.config_data)
try:
entries = save_config(self.config_data)
except Exception as exc:
if self._matrix_status:
self._matrix_status.update(f"Saving configuration failed: {exc}")
self._matrix_test_running = False
return
self.config_data = reload_config()
if self._matrix_status:
self._matrix_status.update(f"Saved configuration ({changed} change(s)) to {db.db_path.name}. Loading Matrix rooms…")
self._matrix_test_running = True
self._matrix_load_background()
@work(thread=True)
def _matrix_load_background(self) -> None:
try:
from Provider.matrix import Matrix
provider = Matrix(self.config_data)
rooms = provider.list_rooms()
self.app.call_from_thread(self._matrix_load_result, True, rooms, None)
except Exception as exc:
tb = traceback.format_exc()
try:
debug(f"[matrix] Load rooms failed: {exc}\n{tb}")
except Exception:
pass
msg = str(exc) or "Matrix load failed"
if "auth" in msg.lower():
msg = msg + ". Please verify your access token and try again."
self.app.call_from_thread(self._matrix_load_result, False, [], msg)
def _matrix_load_result(self, success: bool, rooms: List[Dict[str, Any]], error: Optional[str]) -> None:
# Called on the main thread via call_from_thread
self._matrix_test_running = False
if success:
msg = f"Matrix test succeeded ({len(rooms)} room(s))."
if not success:
full_msg = f"Matrix load failed: {error or '(error)'}"
if self._matrix_status:
self._matrix_status.update(msg)
self._open_matrix_room_picker(prefetched_rooms=rooms)
else:
self._matrix_status.update(full_msg)
try:
self.notify(full_msg, severity="error", timeout=8)
except Exception:
pass
return
# Populate inline list
try:
self._render_matrix_rooms_inline(rooms)
except Exception:
pass
# Persist cached rooms so they are available on next editor open
try:
matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {})
matrix_block["cached_rooms"] = rooms
# Schedule a background save of the config (non-blocking)
try:
self.save_all()
except Exception:
# Fallback to direct save when save_all is unavailable (tests)
try:
save_config(self.config_data)
except Exception:
pass
if self._matrix_status:
self._matrix_status.update(f"Matrix test failed: {error}")
self._matrix_status.update(f"Loaded and cached {len(rooms)} room(s).")
try:
self.notify(f"Loaded {len(rooms)} rooms and cached the results", timeout=5)
except Exception:
pass
except Exception:
pass
def _open_matrix_room_picker(
self,
@@ -1083,6 +1460,138 @@ class ConfigModal(ModalScreen):
callback=self.on_matrix_rooms_selected,
)
def _render_matrix_rooms_inline(self, rooms: List[Dict[str, Any]]) -> None:
"""
Populate the inline matrix rooms ListView with checkboxes based on the
list of rooms returned from a successful test. If the inline ListView
is not present in the current editor view, fall back to opening the
MatrixRoomPicker popup with the results.
"""
try:
inline_list = self._matrix_inline_list or self.query_one("#matrix-rooms-inline", ListView)
except Exception:
inline_list = None
if inline_list is None:
# Inline view isn't available in this context; cache the rooms and persist
try:
matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {})
matrix_block["cached_rooms"] = rooms
try:
self.save_all()
except Exception:
try:
save_config(self.config_data)
except Exception:
pass
if self._matrix_status:
self._matrix_status.update(f"Loaded {len(rooms)} rooms (cached)")
try:
self.notify(f"Loaded {len(rooms)} rooms and cached the results", timeout=5)
except Exception:
pass
except Exception:
pass
return
# Clear current entries
for child in list(inline_list.children):
child.remove()
self._matrix_inline_checkbox_map.clear()
self._matrix_inline_list = inline_list
# Determine existing selection from current config (so saved defaults are pre-selected)
existing = set(self._parse_matrix_rooms_value())
if not rooms:
if self._matrix_status:
self._matrix_status.update("Matrix returned no rooms.")
try:
save_btn = self.query_one("#matrix-inline-save", Button)
save_btn.disabled = True
except Exception:
pass
return
any_selected = False
# Filter and normalize rooms to avoid malformed cache entries or split-word artifacts.
normalized: List[Dict[str, str]] = []
for r in rooms:
try:
rid = str(r.get("room_id") or "").strip()
name = str(r.get("name") or "").strip()
except Exception:
continue
# Ignore obviously malformed tokens coming from bad caching/parsing
le_rid = rid.lower()
le_name = name.lower()
if "room_id" in le_rid or "room_id" in le_name:
continue
# Require a valid room id (Matrix ids usually start with '!' and often contain ':')
if not rid or (not rid.startswith("!") and ":" not in rid):
# Skip entries without a sensible ID (we rely on IDs for saving)
continue
normalized.append({"room_id": rid, "name": name})
for idx, room in enumerate(normalized):
room_id = room.get("room_id") or ""
name = room.get("name") or ""
checkbox_id = f"matrix-inline-room-{idx}"
label_text = name or room_id or "Matrix Room"
checked = bool(room_id and room_id in existing)
if checked:
any_selected = True
from textual.widgets import Checkbox as _Checkbox # local import to avoid top-level change
checkbox = _Checkbox(label_text, id=checkbox_id, value=checked)
self._matrix_inline_checkbox_map[checkbox_id] = room_id
inline_list.mount(ListItem(checkbox, classes="matrix-room-row"))
if self._matrix_status:
self._matrix_status.update("Loaded rooms. Select one or more and save.")
try:
save_btn = self.query_one("#matrix-inline-save", Button)
save_btn.disabled = not any_selected
except Exception:
pass
def _resolve_matrix_rooms_by_ids(self, ids: Iterable[str]) -> List[Dict[str, Any]]:
"""
Resolve room display names for a list of room IDs using the Matrix provider.
Returns a list of dictionaries with keys 'room_id' and 'name' on success, or an
empty list on failure.
"""
try:
ids_list = [str(i).strip() for i in ids if str(i).strip()]
except Exception:
return []
if not ids_list:
return []
# Only attempt network resolution if homeserver + token are present
block = self._get_matrix_provider_block()
hs = block.get("homeserver")
token = block.get("access_token")
if not hs or not token:
return []
try:
from Provider.matrix import Matrix
provider = Matrix(self.config_data)
rooms = provider.list_rooms(room_ids=ids_list)
return rooms or []
except Exception as exc:
try:
debug(f"[config] failed to resolve matrix room names: {exc}")
except Exception:
pass
return []
def on_matrix_rooms_selected(self, result: Any = None) -> None:
if not isinstance(result, list):
if self._matrix_status:
@@ -1113,6 +1622,30 @@ class ConfigModal(ModalScreen):
self.refresh_view()
@on(Input.Changed)
@on(Checkbox.Changed)
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
# Only respond to inline matrix checkboxes
try:
cbid = event.checkbox.id
except Exception:
cbid = None
if not cbid or cbid not in self._matrix_inline_checkbox_map:
return
any_selected = False
for checkbox_id in self._matrix_inline_checkbox_map.keys():
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
if cb.value:
any_selected = True
break
except Exception:
continue
try:
self.query_one("#matrix-inline-save", Button).disabled = not any_selected
except Exception:
pass
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id:
self._update_config_value(event.input.id, event.value)

View File

@@ -3,9 +3,9 @@ from __future__ import annotations
from typing import Any, Dict, List, Optional
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
from textual.containers import Container, Horizontal, ScrollableContainer
from textual.screen import ModalScreen
from textual.widgets import Static, Button, Checkbox
from textual.widgets import Static, Button, Checkbox, ListView, ListItem
from textual import work
from rich.text import Text
@@ -40,13 +40,13 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
margin-bottom: 1;
}
#matrix-room-checklist {
#matrix-room-list {
padding: 0;
}
.matrix-room-row {
border-bottom: solid $surface;
padding: 0.5 0;
padding: 1 0;
align: left middle;
}
@@ -91,15 +91,17 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
id="matrix-room-picker-hint",
)
with ScrollableContainer(id="matrix-room-scroll"):
yield Vertical(id="matrix-room-checklist")
yield ListView(id="matrix-room-list")
with Horizontal(id="matrix-room-actions"):
yield Button("Cancel", variant="error", id="matrix-room-cancel")
yield Button("Select All", id="matrix-room-select-all")
yield Button("Clear All", id="matrix-room-clear")
yield Button("Save defaults", variant="success", id="matrix-room-save")
yield Static("Loading rooms...", id="matrix-room-status")
def on_mount(self) -> None:
self._status_widget = self.query_one("#matrix-room-status", Static)
self._checklist = self.query_one("#matrix-room-checklist", Vertical)
self._checklist = self.query_one("#matrix-room-list", ListView)
self._save_button = self.query_one("#matrix-room-save", Button)
if self._save_button:
self._save_button.disabled = True
@@ -110,10 +112,35 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
self._set_status("Loading rooms...")
self._load_rooms_background()
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Intercept ListView.Selected events and prevent them from bubbling up
to parent components which may react (e.g., the main config modal).
Selecting an item should not implicitly close the picker or change the
outer editor state."""
try:
# Stop propagation so parent handlers (ConfigModal) don't react.
event.stop()
except Exception:
pass
def _set_status(self, text: str) -> None:
if self._status_widget:
self._status_widget.update(text)
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
# Enable Save only when at least one checkbox is selected
any_selected = False
for checkbox_id in self._checkbox_map.keys():
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
if cb.value:
any_selected = True
break
except Exception:
continue
if self._save_button:
self._save_button.disabled = not any_selected
def _render_rooms(self, rooms: List[Dict[str, Any]]) -> None:
if not self._checklist:
return
@@ -130,22 +157,20 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
room_id = str(room.get("room_id") or "").strip()
name = str(room.get("name") or "").strip()
checkbox_id = f"matrix-room-{idx}"
# Prefer display name; otherwise fall back to room id
label_text = name or room_id or "Matrix Room"
checkbox = Checkbox(
"",
label_text,
id=checkbox_id,
value=bool(room_id and room_id in self._existing_ids),
)
self._checkbox_map[checkbox_id] = room_id
label = Text(name or room_id or "Matrix Room")
label.stylize("bold")
label.append("\n")
label.append(room_id or "(no id)", style="dim")
list_item = ListItem(checkbox, classes="matrix-room-row")
self._checklist.mount(list_item)
row = Horizontal(classes="matrix-room-row")
self._checklist.mount(row)
row.mount(checkbox)
row.mount(Static(label, classes="matrix-room-meta"))
self._set_status("Loaded rooms. Select one or more and save.")
if self._save_button:
self._save_button.disabled = False
@@ -167,10 +192,40 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
self._save_button.disabled = True
return
self._render_rooms(rooms)
# Ensure save button is enabled only if at least one checkbox is selected
any_selected = False
for cbid in self._checkbox_map.keys():
try:
cb = self.query_one(f"#{cbid}", Checkbox)
if cb.value:
any_selected = True
break
except Exception:
continue
if self._save_button:
self._save_button.disabled = not any_selected
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "matrix-room-cancel":
self.dismiss([])
elif event.button.id == "matrix-room-select-all":
for checkbox_id in self._checkbox_map.keys():
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
cb.value = True
except Exception:
pass
if self._save_button:
self._save_button.disabled = False
elif event.button.id == "matrix-room-clear":
for checkbox_id in self._checkbox_map.keys():
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
cb.value = False
except Exception:
pass
if self._save_button:
self._save_button.disabled = True
elif event.button.id == "matrix-room-save":
selected: List[str] = []
for checkbox_id, room_id in self._checkbox_map.items():

View File

@@ -223,6 +223,191 @@ def _room_id_matches_filter(room_id: str, allowed_ids_canon: set[str]) -> bool:
return bool(base) and base in allowed_ids_canon
def _extract_room_arg(args: Sequence[str]) -> Optional[str]:
"""Extract the `-room <value>` argument from a cmdnat args list."""
if not args:
return None
try:
tokens = list(args)
except Exception:
return None
for i, tok in enumerate(tokens):
try:
low = str(tok or "").strip()
if not low:
continue
low_lower = low.lower()
if low_lower in ("-room", "--room") and i + 1 < len(tokens):
return str(tokens[i + 1] or "").strip()
if low_lower.startswith("-room=") or low_lower.startswith("--room="):
parts = low.split("=", 1)
if len(parts) > 1:
return parts[1].strip()
except Exception:
continue
return None
def _resolve_room_identifier(value: str, config: Dict[str, Any]) -> Optional[str]:
"""Resolve a user-provided room identifier (display name or id) to a Matrix room_id.
Returns the canonical room_id string on success, otherwise None.
"""
try:
cand = str(value or "").strip()
if not cand:
return None
# If looks like an id already (starts with '!'), accept it as-is
if cand.startswith("!"):
return cand
# First try to resolve against configured default rooms (fast, local)
conf_ids = _parse_config_room_filter_ids(config)
if conf_ids:
# Attempt to fetch names for the configured IDs
try:
from Provider.matrix import Matrix
# Avoid __init__ network failures by requiring homeserver+token to exist
block = config.get("provider", {}).get("matrix", {})
if block and block.get("homeserver") and block.get("access_token"):
try:
m = Matrix(config)
rooms = m.list_rooms(room_ids=conf_ids)
for room in rooms or []:
name = str(room.get("name") or "").strip()
rid = str(room.get("room_id") or "").strip()
if name and name.lower() == cand.lower():
return rid
if name and cand.lower() in name.lower():
return rid
except Exception:
# Best-effort; fallback below
pass
except Exception:
pass
# Last resort: attempt to ask the server for matching rooms (if possible)
try:
from Provider.matrix import Matrix
block = config.get("provider", {}).get("matrix", {})
if block and block.get("homeserver") and block.get("access_token"):
try:
m = Matrix(config)
rooms = m.list_rooms()
for room in rooms or []:
name = str(room.get("name") or "").strip()
rid = str(room.get("room_id") or "").strip()
if name and name.lower() == cand.lower():
return rid
if name and cand.lower() in name.lower():
return rid
except Exception:
pass
except Exception:
pass
return None
except Exception:
return None
def _send_pending_to_rooms(config: Dict[str, Any], room_ids: List[str], args: Sequence[str]) -> int:
"""Send currently pending items to the specified list of room_ids.
This function mirrors the existing -send behavior but accepts explicit
room_ids so the same logic can be reused for -room direct invocation.
"""
pending_items = ctx.load_value(_MATRIX_PENDING_ITEMS_KEY, default=[])
items = _normalize_to_list(pending_items)
if not items:
log("No pending items to upload (use: @N | .matrix)", file=sys.stderr)
return 1
from Provider.matrix import Matrix
try:
provider = Matrix(config)
except Exception as exc:
log(f"Matrix not available: {exc}", file=sys.stderr)
return 1
text_value = _extract_text_arg(args)
if not text_value:
try:
text_value = str(ctx.load_value(_MATRIX_PENDING_TEXT_KEY, default="") or "").strip()
except Exception:
text_value = ""
size_limit_bytes = _get_matrix_size_limit_bytes(config)
size_limit_mb = (size_limit_bytes / (1024 * 1024)) if size_limit_bytes else None
# Resolve upload paths once
upload_jobs: List[Dict[str, Any]] = []
any_failed = False
for item in items:
file_path = _resolve_upload_path(item, config)
if not file_path:
any_failed = True
log("Matrix upload requires a local file (path) or a direct URL on the selected item", file=sys.stderr)
continue
media_path = Path(file_path)
if not media_path.exists():
any_failed = True
log(f"Matrix upload file missing: {file_path}", file=sys.stderr)
continue
if size_limit_bytes is not None:
try:
byte_size = int(media_path.stat().st_size)
except Exception:
byte_size = -1
if byte_size >= 0 and byte_size > size_limit_bytes:
any_failed = True
actual_mb = byte_size / (1024 * 1024)
lim = float(size_limit_mb or 0)
log(f"ERROR: file is too big, skipping: {media_path.name} ({actual_mb:.1f} MB > {lim:.1f} MB)", file=sys.stderr)
continue
upload_jobs.append({
"path": str(media_path),
"pipe_obj": item
})
for rid in room_ids:
sent_any_for_room = False
for job in upload_jobs:
file_path = str(job.get("path") or "")
if not file_path:
continue
try:
link = provider.upload_to_room(file_path, rid, pipe_obj=job.get("pipe_obj"))
debug(f"✓ Sent {Path(file_path).name} -> {rid}")
if link:
log(link)
sent_any_for_room = True
except Exception as exc:
any_failed = True
log(f"Matrix send failed for {Path(file_path).name}: {exc}", file=sys.stderr)
if text_value and sent_any_for_room:
try:
provider.send_text_to_room(text_value, rid)
except Exception as exc:
any_failed = True
log(f"Matrix text send failed: {exc}", file=sys.stderr)
# Clear pending items once attempted
ctx.store_value(_MATRIX_PENDING_ITEMS_KEY, [])
try:
ctx.store_value(_MATRIX_PENDING_TEXT_KEY, "")
except Exception:
pass
return 1 if any_failed else 0
def _extract_text_arg(args: Sequence[str]) -> str:
"""Extract a `-text <value>` argument from a cmdnat args list."""
if not args:
@@ -1128,9 +1313,31 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
except Exception:
pass
# When piped (result has data), show rooms directly.
# When not piped (first command), show main menu.
# When piped (result has data), either send directly (if -room given)
# or show rooms for interactive selection.
if selected_items:
# If user provided a -room argument, resolve it and send to the target(s)
room_arg = _extract_room_arg(args)
if room_arg:
# Support comma-separated list of room names/ids
requested = [r.strip() for r in room_arg.split(",") if r.strip()]
resolved_ids: List[str] = []
for req in requested:
try:
rid = _resolve_room_identifier(req, config)
if rid:
resolved_ids.append(rid)
except Exception:
# Ignore unresolved entries
continue
if not resolved_ids:
log("Could not resolve specified room(s). Opening room selection table instead.", file=sys.stderr)
return _show_rooms_table(config)
# Proceed to send the pending items directly
return _send_pending_to_rooms(config, resolved_ids, args)
return _show_rooms_table(config)
else:
return _show_main_menu()
@@ -1185,6 +1392,12 @@ CMDLET = Cmdlet(
description="Ignore config room filter and show all joined rooms",
required=False,
),
CmdletArg(
name="room",
type="string",
description="Target room (name or id). Comma-separated values supported. Autocomplete uses configured defaults.",
required=False,
),
CmdletArg(
name="text",
type="string",

33
logs/log_fallback.txt Normal file
View File

@@ -0,0 +1,33 @@
2026-01-30T22:25:43.107858Z [DEBUG] logger.debug: DEBUG: [Store] Unknown store type 'debrid'
2026-01-30T22:25:59.982134Z [DEBUG] search_file.run: Backend all-debrid search failed: "Unknown store backend: all-debrid. Available: ['rpi', 'local']"
2026-01-30T22:26:16.885919Z [DEBUG] logger.debug: DEBUG: [search-file] Searching 'local'
2026-01-30T22:26:33.793917Z [DEBUG] logger.debug: DEBUG: [hydrusnetwork:local] Searching for: *
2026-01-30T22:26:50.694904Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x000001A8E2113A10>
2026-01-30T22:27:07.572758Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x000001A8E2113A10>
2026-01-30T22:27:24.447150Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x000001A8E1ECAD50>
2026-01-30T22:27:41.284396Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x000001A8E1ECAD50>
2026-01-30T22:27:58.181684Z [DEBUG] logger.debug: DEBUG: [hydrusnetwork:local] 12 result(s)
2026-01-30T22:28:15.009135Z [DEBUG] logger.debug: DEBUG: [search-file] 'local' -> 12 result(s)
2026-01-30T22:28:31.863769Z [DEBUG] logger.debug: DEBUG: [search-file] Searching 'rpi'
2026-01-30T22:28:48.711179Z [DEBUG] logger.debug: DEBUG: [hydrusnetwork:rpi] Searching for: *
2026-01-30T22:29:05.576668Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x000001A8E1EC9610>
2026-01-30T22:29:22.412766Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x000001A8E1EC9610>
2026-01-30T22:29:39.282886Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x000001A8E1EC9610>
2026-01-30T22:29:56.194705Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x000001A8E2110590>
2026-01-30T22:30:13.080953Z [DEBUG] logger.debug: DEBUG: [hydrusnetwork:rpi] 88 result(s)
2026-01-30T22:30:30.070854Z [DEBUG] logger.debug: DEBUG: [search-file] 'rpi' -> 88 result(s)
2026-01-30T23:06:37.123461Z [DEBUG] logger.debug: DEBUG: [Store] Unknown store type 'debrid'
2026-01-30T23:06:53.988994Z [DEBUG] logger.debug: DEBUG: [Store] Unknown store type 'debrid'
2026-01-30T23:07:10.875554Z [DEBUG] search_file.run: Backend all-debrid search failed: "Unknown store backend: all-debrid. Available: ['rpi', 'local']"
2026-01-30T23:07:27.718017Z [DEBUG] logger.debug: DEBUG: [search-file] Searching 'local'
2026-01-30T23:07:44.568972Z [DEBUG] logger.debug: DEBUG: [hydrusnetwork:local] Searching for: *
2026-01-30T23:08:01.458991Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x00000194016A0050>
2026-01-30T23:08:18.408113Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x00000194016A0050>
2026-01-30T23:08:35.307969Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x00000194016A0050>
2026-01-30T23:08:52.181486Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x00000194016A0590>
2026-01-30T23:09:09.049140Z [DEBUG] logger.debug: DEBUG: [hydrusnetwork:local] 12 result(s)
2026-01-30T23:09:25.957724Z [DEBUG] logger.debug: DEBUG: [search-file] 'local' -> 12 result(s)
2026-01-30T23:09:42.865060Z [DEBUG] logger.debug: DEBUG: [search-file] Searching 'rpi'
2026-01-30T23:09:59.774037Z [DEBUG] logger.debug: DEBUG: [hydrusnetwork:rpi] Searching for: *
2026-01-30T23:10:16.647306Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x0000019401393F50>
2026-01-30T23:10:33.574417Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x00000194016A0B90>