From 20045b8de877f03c92bd6ad1d5c88a1b1cfd1070 Mon Sep 17 00:00:00 2001 From: Nose Date: Fri, 30 Jan 2026 16:24:08 -0800 Subject: [PATCH] f --- SYS/cmdlet_catalog.py | 78 ++- TUI/modalscreen/config_modal.py | 655 +++++++++++++++++++++++--- TUI/modalscreen/matrix_room_picker.py | 85 +++- cmdnat/matrix.py | 217 ++++++++- logs/log_fallback.txt | 33 ++ 5 files changed, 989 insertions(+), 79 deletions(-) create mode 100644 logs/log_fallback.txt diff --git a/SYS/cmdlet_catalog.py b/SYS/cmdlet_catalog.py index edc865a..eef9a62 100644 --- a/SYS/cmdlet_catalog.py +++ b/SYS/cmdlet_catalog.py @@ -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 []) diff --git a/TUI/modalscreen/config_modal.py b/TUI/modalscreen/config_modal.py index 173a718..3d363a7 100644 --- a/TUI/modalscreen/config_modal.py +++ b/TUI/modalscreen/config_modal.py @@ -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[^\"']+)[\"']\s*,\s*[\"']name[\"']\s*:\s*[\"'](?P[^\"']+)[\"']") + 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) diff --git a/TUI/modalscreen/matrix_room_picker.py b/TUI/modalscreen/matrix_room_picker.py index c2e7162..b4c4134 100644 --- a/TUI/modalscreen/matrix_room_picker.py +++ b/TUI/modalscreen/matrix_room_picker.py @@ -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(): diff --git a/cmdnat/matrix.py b/cmdnat/matrix.py index 35c54a2..d33ae3e 100644 --- a/cmdnat/matrix.py +++ b/cmdnat/matrix.py @@ -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 ` 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 ` 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", diff --git a/logs/log_fallback.txt b/logs/log_fallback.txt new file mode 100644 index 0000000..da97598 --- /dev/null +++ b/logs/log_fallback.txt @@ -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: +2026-01-30T22:27:07.572758Z [DEBUG] logger.debug: DEBUG: +2026-01-30T22:27:24.447150Z [DEBUG] logger.debug: DEBUG: +2026-01-30T22:27:41.284396Z [DEBUG] logger.debug: DEBUG: +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: +2026-01-30T22:29:22.412766Z [DEBUG] logger.debug: DEBUG: +2026-01-30T22:29:39.282886Z [DEBUG] logger.debug: DEBUG: +2026-01-30T22:29:56.194705Z [DEBUG] logger.debug: DEBUG: +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: +2026-01-30T23:08:18.408113Z [DEBUG] logger.debug: DEBUG: +2026-01-30T23:08:35.307969Z [DEBUG] logger.debug: DEBUG: +2026-01-30T23:08:52.181486Z [DEBUG] logger.debug: DEBUG: +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: +2026-01-30T23:10:33.574417Z [DEBUG] logger.debug: DEBUG: