f
This commit is contained in:
@@ -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 [])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
217
cmdnat/matrix.py
217
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 <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
33
logs/log_fallback.txt
Normal 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>
|
||||
Reference in New Issue
Block a user