diff --git a/CLI.py b/CLI.py index 9961c06..7180de2 100644 --- a/CLI.py +++ b/CLI.py @@ -1207,6 +1207,8 @@ class CmdletExecutor: "get_url", "search-file", "search_file", + "add-file", + "add_file", } if cmd_name in self_managing_commands: diff --git a/Provider/matrix.py b/Provider/matrix.py index 9f5eee8..daf6679 100644 --- a/Provider/matrix.py +++ b/Provider/matrix.py @@ -249,12 +249,6 @@ class Matrix(TableProviderMixin, Provider): "default": "", "secret": True }, - { - "key": "password", - "label": "Password (fallback)", - "default": "", - "secret": True - } ] def __init__(self, config: Optional[Dict[str, Any]] = None): @@ -270,12 +264,10 @@ class Matrix(TableProviderMixin, Provider): ) homeserver = matrix_conf.get("homeserver") access_token = matrix_conf.get("access_token") - password = matrix_conf.get("password") - # Not configured: keep instance but mark invalid via validate(). # Note: `room_id` is intentionally NOT required, since the CLI can prompt # the user to select a room dynamically. - if not (homeserver and (access_token or password)): + if not (homeserver and access_token): self._init_ok = None self._init_reason = None return @@ -305,7 +297,7 @@ class Matrix(TableProviderMixin, Provider): {}) return bool( matrix_conf.get("homeserver") - and (matrix_conf.get("access_token") or matrix_conf.get("password")) + and matrix_conf.get("access_token") ) def search( diff --git a/TUI/modalscreen/config_modal.py b/TUI/modalscreen/config_modal.py index 539b71b..d65b531 100644 --- a/TUI/modalscreen/config_modal.py +++ b/TUI/modalscreen/config_modal.py @@ -1,14 +1,17 @@ -from textual.app import ComposeResult -from textual.screen import ModalScreen -from textual.containers import Container, Horizontal, Vertical, ScrollableContainer -from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select +import re +from typing import Any, Dict, List, Optional + from textual import on, work -from typing import Any +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 SYS.config import load_config, save_config, reload_config, global_config from SYS.logger import log from Store.registry import _discover_store_classes, _required_keys_for from ProviderCore.registry import list_providers +from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker from TUI.modalscreen.selection_modal import SelectionModal class ConfigModal(ModalScreen): @@ -117,6 +120,8 @@ class ConfigModal(ModalScreen): self.editing_item_name = None self._button_id_map = {} self._input_id_map = {} + self._matrix_status: Optional[Static] = None + self._matrix_test_running = False def compose(self) -> ComposeResult: with Container(id="config-container"): @@ -491,13 +496,28 @@ class ConfigModal(ModalScreen): inp_id = f"item-{idx}" self._input_id_map[inp_id] = rk row = Horizontal(classes="field-row") + container.mount(row) row.mount(Input(value="", id=inp_id, classes="config-input")) row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn")) - container.mount(row) idx += 1 except Exception: pass + if ( + item_type == "provider" + and isinstance(item_name, str) + and item_name.strip().lower() == "matrix" + ): + container.mount(Rule()) + container.mount(Label("Matrix helpers", classes="config-label")) + status = Static("Set homeserver + token, then test before saving", id="matrix-status") + container.mount(status) + 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")) + self._matrix_status = status + 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") @@ -594,6 +614,10 @@ class ConfigModal(ModalScreen): except Exception: pass self.app.push_screen(SelectionModal("Select Provider Type", options), callback=self.on_provider_type_selected) + elif bid == "matrix-test-btn": + self._request_matrix_test() + elif bid == "matrix-rooms-btn": + self._open_matrix_room_picker() elif bid.startswith("paste-"): # Programmatic paste button target_id = bid.replace("paste-", "") @@ -777,6 +801,94 @@ class ConfigModal(ModalScreen): self._update_config_value(widget_id, value) + def _get_matrix_provider_block(self) -> Dict[str, Any]: + providers = self.config_data.get("provider") + if not isinstance(providers, dict): + return {} + block = providers.get("matrix") + return block if isinstance(block, dict) else {} + + def _parse_matrix_rooms_value(self) -> List[str]: + block = self._get_matrix_provider_block() + raw = block.get("rooms") + if isinstance(raw, (list, tuple, set)): + return [str(item).strip() for item in raw if str(item).strip()] + text = str(raw or "").strip() + if not text: + return [] + return [segment for segment in re.split(r"[\s,]+", text) if segment] + + def _request_matrix_test(self) -> None: + if self._matrix_test_running: + return + self._synchronize_inputs_to_config() + if self._matrix_status: + self._matrix_status.update("Testing Matrix connection…") + self._matrix_test_running = True + self._matrix_test_background() + + @work(thread=True) + def _matrix_test_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_test_result, True, rooms, None) + except Exception as exc: + self.app.call_from_thread(self._matrix_test_result, False, [], str(exc)) + + def _matrix_test_result( + self, + success: bool, + rooms: List[Dict[str, Any]], + error: Optional[str], + ) -> None: + self._matrix_test_running = False + if success: + msg = f"Matrix test succeeded ({len(rooms)} room(s))." + if self._matrix_status: + self._matrix_status.update(msg) + self._open_matrix_room_picker(prefetched_rooms=rooms) + else: + if self._matrix_status: + self._matrix_status.update(f"Matrix test failed: {error}") + + def _open_matrix_room_picker( + self, + *, + prefetched_rooms: Optional[List[Dict[str, Any]]] = None, + ) -> None: + existing = self._parse_matrix_rooms_value() + self.app.push_screen( + MatrixRoomPicker( + self.config_data, + existing=existing, + rooms=prefetched_rooms, + ), + callback=self.on_matrix_rooms_selected, + ) + + def on_matrix_rooms_selected(self, result: Any = None) -> None: + if not isinstance(result, list): + if self._matrix_status: + self._matrix_status.update("Room selection cancelled.") + return + cleaned: List[str] = [] + for item in result: + candidate = str(item or "").strip() + if candidate and candidate not in cleaned: + cleaned.append(candidate) + if not cleaned: + 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(cleaned) + if self._matrix_status: + self._matrix_status.update(f"Saved {len(cleaned)} default room(s).") + self.refresh_view() + @on(Input.Changed) def on_input_changed(self, event: Input.Changed) -> None: if event.input.id: diff --git a/TUI/modalscreen/matrix_room_picker.py b/TUI/modalscreen/matrix_room_picker.py new file mode 100644 index 0000000..8e6af20 --- /dev/null +++ b/TUI/modalscreen/matrix_room_picker.py @@ -0,0 +1,197 @@ +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.screen import ModalScreen +from textual.widgets import Static, Button, Checkbox +from textual import work + + +class MatrixRoomPicker(ModalScreen[List[str]]): + """Modal that lists Matrix rooms and returns selected defaults.""" + + CSS = """ + MatrixRoomPicker { + align: center middle; + background: rgba(0, 0, 0, 0.45); + } + + #matrix-room-picker { + width: 70%; + height: 70%; + background: $panel; + border: thick $primary; + padding: 1; + } + + #matrix-room-picker-hint { + text-style: italic; + color: $text-muted; + margin-bottom: 1; + } + + #matrix-room-scroll { + height: 1fr; + border: solid $surface; + padding: 1; + margin-bottom: 1; + } + + #matrix-room-checklist { + padding: 0; + } + + .matrix-room-row { + border-bottom: solid $surface; + padding: 1 0; + align: left middle; + background: $surface; + } + + .matrix-room-checkbox { + width: 3; + padding: 0; + margin-right: 1; + } + + .matrix-room-meta { + padding: 0 1; + } + + .matrix-room-name { + content-align: left middle; + color: $text; + } + + .matrix-room-id { + content-align: left middle; + text-style: dim; + color: $text-muted; + } + + #matrix-room-actions { + height: 3; + align: right middle; + } + + #matrix-room-status { + height: 3; + text-style: bold; + } + """ + + def __init__( + self, + config: Dict[str, Any], + *, + existing: Optional[List[str]] = None, + rooms: Optional[List[Dict[str, Any]]] = None, + ) -> None: + super().__init__() + self.config = config + self._prefetched_rooms = rooms + self._existing_ids = {str(r).strip() for r in (existing or []) if str(r).strip()} + self._checkbox_map: Dict[str, str] = {} + self._rooms: List[Dict[str, Any]] = [] + self._status_widget: Optional[Static] = None + self._checklist: Optional[Vertical] = None + self._save_button: Optional[Button] = None + + def compose(self) -> ComposeResult: + with Container(id="matrix-room-picker"): + yield Static("Matrix Default Rooms", classes="section-title") + yield Static( + "Choose rooms to keep in the sharing defaults and autocomplete.", + id="matrix-room-picker-hint", + ) + with ScrollableContainer(id="matrix-room-scroll"): + yield Vertical(id="matrix-room-checklist") + with Horizontal(id="matrix-room-actions"): + yield Button("Cancel", variant="error", id="matrix-room-cancel") + 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._save_button = self.query_one("#matrix-room-save", Button) + if self._save_button: + self._save_button.disabled = True + if self._prefetched_rooms is not None: + self._apply_room_results(self._prefetched_rooms, None) + else: + if self._status_widget: + self._set_status("Loading rooms...") + self._load_rooms_background() + + def _set_status(self, text: str) -> None: + if self._status_widget: + self._status_widget.update(text) + + def _render_rooms(self, rooms: List[Dict[str, Any]]) -> None: + if not self._checklist: + return + for child in list(self._checklist.children): + child.remove() + self._checkbox_map.clear() + self._rooms = rooms + if not rooms: + self._set_status("Matrix returned no rooms.") + if self._save_button: + self._save_button.disabled = True + return + for idx, room in enumerate(rooms): + room_id = str(room.get("room_id") or "").strip() + name = str(room.get("name") or "").strip() + checkbox_id = f"matrix-room-{idx}" + checkbox = Checkbox( + "", + id=checkbox_id, + classes="matrix-room-checkbox", + value=bool(room_id and room_id in self._existing_ids), + ) + self._checkbox_map[checkbox_id] = room_id + info = Vertical(classes="matrix-room-meta") + info.mount(Static(name or room_id or "Matrix Room", classes="matrix-room-name")) + info.mount(Static(room_id or "(no id)", classes="matrix-room-id")) + row = Horizontal(classes="matrix-room-row") + self._checklist.mount(row) + row.mount(checkbox) + row.mount(info) + self._set_status("Loaded rooms. Select one or more and save.") + if self._save_button: + self._save_button.disabled = False + + @work(thread=True) + def _load_rooms_background(self) -> None: + try: + from Provider.matrix import Matrix + provider = Matrix(self.config) + rooms = provider.list_rooms() + self.app.call_from_thread(self._apply_room_results, rooms, None) + except Exception as exc: + self.app.call_from_thread(self._apply_room_results, [], str(exc)) + + def _apply_room_results(self, rooms: List[Dict[str, Any]], error: Optional[str]) -> None: + if error: + self._set_status(f"Failed to load Matrix rooms: {error}") + if self._save_button: + self._save_button.disabled = True + return + self._render_rooms(rooms) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "matrix-room-cancel": + self.dismiss([]) + elif event.button.id == "matrix-room-save": + selected: List[str] = [] + for checkbox_id, room_id in self._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 + self.dismiss(selected) diff --git a/cmdnat/matrix.py b/cmdnat/matrix.py index 053d05e..35c54a2 100644 --- a/cmdnat/matrix.py +++ b/cmdnat/matrix.py @@ -9,6 +9,7 @@ import uuid from urllib.parse import parse_qs, urlparse from cmdlet._shared import Cmdlet, CmdletArg +from SYS.config import load_config, save_config from SYS.logger import log, debug from SYS.result_table import Table from SYS import pipeline as ctx @@ -78,55 +79,43 @@ def _extract_set_value_arg(args: Sequence[str]) -> Optional[str]: def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool: - """Update a Matrix config value and write to config file. - - Returns True if successful, False otherwise. + """Update the Matrix provider block in the shared config. + + This method writes to the unified config store so changes persist between + sessions. """ try: - from SYS.config import get_config_path - from configparser import ConfigParser - if not isinstance(config, dict): return False - - # Ensure provider.matrix section exists - providers = config.get("provider", {}) + + value_str = str(value) + + current_cfg = load_config() or {} + providers = current_cfg.setdefault("provider", {}) if not isinstance(providers, dict): providers = {} - config["provider"] = providers - - matrix_conf = providers.get("matrix", {}) - if not isinstance(matrix_conf, dict): - matrix_conf = {} - providers["matrix"] = matrix_conf - - # Update the in-memory config - matrix_conf[key] = value - - # Try to write to config file using configparser - try: - config_path = get_config_path() - if not config_path: - return False - - parser = ConfigParser() - if Path(config_path).exists(): - parser.read(config_path) - - section_name = "provider=matrix" - if not parser.has_section(section_name): - parser.add_section(section_name) - - parser.set(section_name, key, str(value)) - - with open(config_path, "w") as f: - parser.write(f) - - return True - except Exception as exc: - debug(f"[matrix] Failed to write config file: {exc}") - # Config was updated in memory at least - return True + current_cfg["provider"] = providers + + matrix_cfg = providers.setdefault("matrix", {}) + if not isinstance(matrix_cfg, dict): + matrix_cfg = {} + providers["matrix"] = matrix_cfg + + matrix_cfg[key] = value_str + + save_config(current_cfg) + + # Keep the supplied config dict in sync for the running CLI + target_providers = config.setdefault("provider", {}) + if not isinstance(target_providers, dict): + target_providers = {} + config["provider"] = target_providers + target_matrix = target_providers.setdefault("matrix", {}) + if not isinstance(target_matrix, dict): + target_matrix = {} + target_providers["matrix"] = target_matrix + target_matrix[key] = value_str + return True except Exception as exc: debug(f"[matrix] Failed to update Matrix config: {exc}") return False @@ -628,14 +617,14 @@ def _show_settings_table(config: Dict[str, Any]) -> int: settings_items = [] if isinstance(matrix_conf, dict): + sensitive_keys = {"access_token", "password"} for key in sorted(matrix_conf.keys()): value = matrix_conf[key] - # Skip sensitive/complex values - if key in ("password",): - value = "***" + display_value = "***" if key in sensitive_keys else str(value) settings_items.append({ "key": key, - "value": str(value), + "label": key, + "value": display_value, "original_value": value, }) @@ -643,10 +632,19 @@ def _show_settings_table(config: Dict[str, Any]) -> int: log("No Matrix settings configured. Edit config.conf manually.", file=sys.stderr) return 0 + settings_items.append({ + "action": "test", + "label": "Test connection", + "value": "Verify the homeserver and token before picking rooms", + "description": "Runs a health check and then prompts for default rooms", + }) + for item in settings_items: row = table.add_row() - row.add_column("Key", item["key"]) - row.add_column("Value", item["value"]) + label = item.get("label") or item.get("key") or "Setting" + value_text = item.get("value") or item.get("description") or "" + row.add_column("Key", label) + row.add_column("Value", value_text) ctx.set_last_result_table_overlay(table, settings_items) ctx.set_current_stage_table(table) @@ -712,6 +710,15 @@ def _handle_settings_edit(result: Any, args: Sequence[str], config: Dict[str, An return 1 selected_item = last_items[idx] + selected_action = None + if isinstance(selected_item, dict): + selected_action = selected_item.get("action") + else: + selected_action = getattr(selected_item, "action", None) + + if selected_action == "test": + return _handle_settings_test(config) + key = None if isinstance(selected_item, dict): key = selected_item.get("key") @@ -742,6 +749,132 @@ def _handle_settings_edit(result: Any, args: Sequence[str], config: Dict[str, An return 1 +def _handle_settings_test(config: Dict[str, Any]) -> int: + """Test Matrix credentials and prompt for default rooms upon success.""" + from Provider.matrix import Matrix + + try: + provider = Matrix(config) + except Exception as exc: + log(f"Matrix test failed: {exc}", file=sys.stderr) + return 1 + + log("Matrix configuration validated. Select default rooms to share.") + return _show_default_room_picker(config, provider=provider) + + +def _show_default_room_picker(config: Dict[str, Any], *, provider: Optional["Matrix"] = None) -> int: + """Display joined rooms so the user can select defaults for sharing.""" + from Provider.matrix import Matrix + + try: + if provider is None: + provider = Matrix(config) + except Exception as exc: + log(f"Matrix not available: {exc}", file=sys.stderr) + return 1 + + try: + rooms = provider.list_rooms() + except Exception as exc: + log(f"Failed to list Matrix rooms: {exc}", file=sys.stderr) + return 1 + + if not rooms: + log("No joined rooms found.", file=sys.stderr) + return 0 + + default_ids = { + str(v).strip() + for v in _parse_config_room_filter_ids(config) + if str(v).strip() + } + + table = Table("Matrix Rooms (select defaults with @N)") + table.set_table("matrix") + table.set_source_command(".matrix", ["-settings"]) + + room_items: List[Dict[str, Any]] = [] + for room in rooms: + if isinstance(room, dict): + room_id = str(room.get("room_id") or "").strip() + name = str(room.get("name") or "").strip() + else: + room_id = "" + name = "" + + row = table.add_row() + row.add_column("Name", name) + row.add_column("Room", room_id) + row.add_column("Default", "✓" if room_id and room_id in default_ids else "") + + room_items.append({ + **(room if isinstance(room, dict) else {}), + "room_id": room_id, + "name": name, + "store": "matrix", + "title": name or room_id or "Matrix Room", + }) + + ctx.set_last_result_table_overlay(table, room_items) + ctx.set_current_stage_table(table) + ctx.set_pending_pipeline_tail([[".matrix", "-settings-rooms"]], ".matrix") + log("Select default rooms to share (used by @N and autocomplete).") + return 0 + + +def _handle_settings_rooms(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: + """Store the selected rooms list as the default sharing target.""" + selection_indices = [] + try: + selection_indices = ctx.get_last_selection() or [] + except Exception: + pass + + last_items = [] + try: + last_items = ctx.get_last_result_items() or [] + except Exception: + pass + + if not selection_indices or not last_items: + log("No Matrix room selected. Use @N on the rooms table.", file=sys.stderr) + return 1 + + room_ids: List[str] = [] + for idx in selection_indices: + if not isinstance(idx, int): + continue + if idx < 0 or idx >= len(last_items): + continue + item = last_items[idx] + candidate = None + if isinstance(item, dict): + candidate = item.get("room_id") or item.get("id") + else: + candidate = getattr(item, "room_id", None) or getattr(item, "id", None) + if candidate: + room_ids.append(str(candidate).strip()) + + cleaned: List[str] = [] + for rid in room_ids: + clean = str(rid or "").strip() + if clean and clean not in cleaned: + cleaned.append(clean) + + if not cleaned: + log("No valid Matrix room selected.", file=sys.stderr) + return 1 + + value = ", ".join(cleaned) + if not _update_matrix_config(config, "rooms", value): + log("✗ Failed to save default rooms", file=sys.stderr) + return 1 + + log(f"✓ Default rooms saved: {value}") + return _show_settings_table(config) + + def _show_rooms_table(config: Dict[str, Any]) -> int: """Display rooms (refactored original behavior).""" from Provider.matrix import Matrix @@ -855,6 +988,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: if _has_flag(args, "-settings-edit"): return _handle_settings_edit(result, args, config) + if _has_flag(args, "-settings-rooms"): + return _handle_settings_rooms(result, args, config) + # Internal stage: send previously selected items to selected rooms. if _has_flag(args, "-send"): # Ensure we don't re-print the rooms picker table on the send stage. @@ -1031,6 +1167,12 @@ CMDLET = Cmdlet( description="(internal) Handle settings modification", required=False, ), + CmdletArg( + name="settings-rooms", + type="bool", + description="(internal) Save selected default rooms", + required=False, + ), CmdletArg( name="set-value", type="string",