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(
|
def get_cmdlet_arg_choices(
|
||||||
cmd_name: str, arg_name: str, config: Optional[Dict[str, Any]] = None
|
cmd_name: str, arg_name: str, config: Optional[Dict[str, Any]] = None
|
||||||
) -> List[str]:
|
) -> 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)
|
meta = get_cmdlet_metadata(cmd_name, config=config)
|
||||||
if not meta:
|
if not meta:
|
||||||
return []
|
return []
|
||||||
target = arg_name.lstrip("-")
|
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", []):
|
for arg in meta.get("args", []):
|
||||||
if arg.get("name") == target:
|
if arg.get("name") == target:
|
||||||
return list(arg.get("choices", []) or [])
|
return list(arg.get("choices", []) or [])
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import re
|
import re
|
||||||
from copy import deepcopy
|
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 import on, work
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
||||||
from textual.screen import ModalScreen
|
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 pathlib import Path
|
||||||
|
|
||||||
from SYS.config import load_config, save_config, reload_config, global_config, count_changed_entries, ConfigSaveConflict
|
from SYS.config import load_config, save_config, reload_config, global_config, count_changed_entries, ConfigSaveConflict
|
||||||
from SYS.database import db
|
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 Store.registry import _discover_store_classes, _required_keys_for
|
||||||
from ProviderCore.registry import list_providers
|
from ProviderCore.registry import list_providers
|
||||||
from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker
|
from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker
|
||||||
@@ -81,11 +82,6 @@ class ConfigModal(ModalScreen):
|
|||||||
width: 1fr;
|
width: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paste-btn {
|
|
||||||
width: 10;
|
|
||||||
margin-left: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config-actions {
|
#config-actions {
|
||||||
height: 3;
|
height: 3;
|
||||||
align: right middle;
|
align: right middle;
|
||||||
@@ -112,6 +108,20 @@ class ConfigModal(ModalScreen):
|
|||||||
Button {
|
Button {
|
||||||
margin: 0 1;
|
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:
|
def __init__(self) -> None:
|
||||||
@@ -126,6 +136,9 @@ class ConfigModal(ModalScreen):
|
|||||||
self._matrix_status: Optional[Static] = None
|
self._matrix_status: Optional[Static] = None
|
||||||
self._matrix_test_running = False
|
self._matrix_test_running = False
|
||||||
self._editor_snapshot: Optional[Dict[str, Any]] = None
|
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)
|
# Path to the database file used by this process (for diagnostics)
|
||||||
self._db_path = str(db.db_path)
|
self._db_path = str(db.db_path)
|
||||||
|
|
||||||
@@ -279,16 +292,28 @@ class ConfigModal(ModalScreen):
|
|||||||
self._input_id_map[inp_id] = found_key
|
self._input_id_map[inp_id] = found_key
|
||||||
|
|
||||||
if choices:
|
if choices:
|
||||||
select_options = [(str(c), str(c)) for c in choices]
|
# Normalize boolean-like choices to lowercase ('true'/'false') to avoid duplicate choices
|
||||||
if current_val not in [str(c) for c in choices]:
|
normalized_choices = []
|
||||||
select_options.insert(0, (current_val, current_val))
|
for c in choices:
|
||||||
sel = Select(select_options, value=current_val, id=inp_id)
|
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)
|
container.mount(sel)
|
||||||
else:
|
else:
|
||||||
row = Horizontal(classes="field-row")
|
row = Horizontal(classes="field-row")
|
||||||
container.mount(row)
|
container.mount(row)
|
||||||
row.mount(Input(value=current_val, id=inp_id, classes="config-input"))
|
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
|
idx += 1
|
||||||
|
|
||||||
# Show any other top-level keys not in schema
|
# Show any other top-level keys not in schema
|
||||||
@@ -445,6 +470,16 @@ class ConfigModal(ModalScreen):
|
|||||||
for k, v in section.items():
|
for k, v in section.items():
|
||||||
if k.startswith("_"): continue
|
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)
|
# Deduplicate keys case-insensitively (e.g. name vs NAME vs Name)
|
||||||
k_upper = k.upper()
|
k_upper = k.upper()
|
||||||
if k_upper in existing_keys_upper:
|
if k_upper in existing_keys_upper:
|
||||||
@@ -469,19 +504,26 @@ class ConfigModal(ModalScreen):
|
|||||||
self._input_id_map[inp_id] = k
|
self._input_id_map[inp_id] = k
|
||||||
|
|
||||||
if choices:
|
if choices:
|
||||||
# Select takes a list of (label, value) tuples
|
# Select takes a list of (label, value) tuples; normalize boolean-like values
|
||||||
select_options = []
|
select_options = []
|
||||||
choice_values = []
|
choice_values = []
|
||||||
for c in choices:
|
for c in choices:
|
||||||
if isinstance(c, tuple) and len(c) == 2:
|
if isinstance(c, tuple) and len(c) == 2:
|
||||||
select_options.append((str(c[0]), str(c[1])))
|
label = str(c[0])
|
||||||
choice_values.append(str(c[1]))
|
val = str(c[1])
|
||||||
else:
|
else:
|
||||||
select_options.append((str(c), str(c)))
|
label = str(c)
|
||||||
choice_values.append(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
|
# If current value not in choices, add it or stay blank
|
||||||
current_val = str(v)
|
current_val = str(v)
|
||||||
|
if current_val.lower() in ("true", "false"):
|
||||||
|
current_val = current_val.lower()
|
||||||
if current_val not in choice_values:
|
if current_val not in choice_values:
|
||||||
select_options.insert(0, (current_val, current_val))
|
select_options.insert(0, (current_val, current_val))
|
||||||
|
|
||||||
@@ -494,7 +536,6 @@ class ConfigModal(ModalScreen):
|
|||||||
if is_secret:
|
if is_secret:
|
||||||
inp.password = True
|
inp.password = True
|
||||||
row.mount(inp)
|
row.mount(inp)
|
||||||
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
# Add required/optional fields from schema that are missing
|
# Add required/optional fields from schema that are missing
|
||||||
@@ -518,16 +559,25 @@ class ConfigModal(ModalScreen):
|
|||||||
choice_values = []
|
choice_values = []
|
||||||
for c in choices:
|
for c in choices:
|
||||||
if isinstance(c, tuple) and len(c) == 2:
|
if isinstance(c, tuple) and len(c) == 2:
|
||||||
select_options.append((str(c[0]), str(c[1])))
|
label = str(c[0])
|
||||||
choice_values.append(str(c[1]))
|
val = str(c[1])
|
||||||
else:
|
else:
|
||||||
select_options.append((str(c), str(c)))
|
label = str(c)
|
||||||
choice_values.append(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 default_val not in choice_values:
|
# Normalize default/current value
|
||||||
select_options.insert(0, (default_val, default_val))
|
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=default_val, id=inp_id)
|
sel = Select(select_options, value=current_val, id=inp_id)
|
||||||
container.mount(sel)
|
container.mount(sel)
|
||||||
else:
|
else:
|
||||||
row = Horizontal(classes="field-row")
|
row = Horizontal(classes="field-row")
|
||||||
@@ -556,7 +606,6 @@ class ConfigModal(ModalScreen):
|
|||||||
row = Horizontal(classes="field-row")
|
row = Horizontal(classes="field-row")
|
||||||
container.mount(row)
|
container.mount(row)
|
||||||
row.mount(Input(value="", id=inp_id, classes="config-input"))
|
row.mount(Input(value="", id=inp_id, classes="config-input"))
|
||||||
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
# If it's a provider, we might have required keys (legacy check fallback)
|
# 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")
|
row = Horizontal(classes="field-row")
|
||||||
container.mount(row)
|
container.mount(row)
|
||||||
row.mount(Button("Test connection", id="matrix-test-btn"))
|
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
|
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:
|
def create_field(self, name: str, value: Any, id: str) -> Vertical:
|
||||||
# This method is now unused - we mount labels and inputs directly
|
# This method is now unused - we mount labels and inputs directly
|
||||||
v = Vertical(classes="config-field")
|
v = Vertical(classes="config-field")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||||
if not event.item: return
|
# Only respond to selections from the left-hand category list. Avoid
|
||||||
if event.item.id == "cat-globals":
|
# 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"
|
self.current_category = "globals"
|
||||||
elif event.item.id == "cat-stores":
|
elif item_id == "cat-stores":
|
||||||
self.current_category = "stores"
|
self.current_category = "stores"
|
||||||
elif event.item.id == "cat-providers":
|
elif item_id == "cat-providers":
|
||||||
self.current_category = "providers"
|
self.current_category = "providers"
|
||||||
elif event.item.id == "cat-tools":
|
elif item_id == "cat-tools":
|
||||||
self.current_category = "tools"
|
self.current_category = "tools"
|
||||||
|
|
||||||
|
# Reset editor state and refresh view for the new category
|
||||||
self.editing_item_name = None
|
self.editing_item_name = None
|
||||||
self.editing_item_type = None
|
self.editing_item_type = None
|
||||||
self.refresh_view()
|
self.refresh_view()
|
||||||
@@ -745,17 +870,63 @@ class ConfigModal(ModalScreen):
|
|||||||
self.app.push_screen(SelectionModal("Select Tool", options), callback=self.on_tool_type_selected)
|
self.app.push_screen(SelectionModal("Select Tool", options), callback=self.on_tool_type_selected)
|
||||||
elif bid == "matrix-test-btn":
|
elif bid == "matrix-test-btn":
|
||||||
self._request_matrix_test()
|
self._request_matrix_test()
|
||||||
elif bid == "matrix-rooms-btn":
|
elif bid == "matrix-load-btn":
|
||||||
self._open_matrix_room_picker()
|
# Refresh the inline rooms list and cache the results (no popup)
|
||||||
# Restore UI removed: backups/audit remain available programmatically
|
self._request_matrix_load()
|
||||||
elif bid.startswith("paste-"):
|
elif bid == "matrix-inline-select-all":
|
||||||
# Programmatic paste button
|
for checkbox_id in list(self._matrix_inline_checkbox_map.keys()):
|
||||||
target_id = bid.replace("paste-", "")
|
|
||||||
try:
|
try:
|
||||||
inp = self.query_one(f"#{target_id}", Input)
|
cb = self.query_one(f"#{checkbox_id}", Checkbox)
|
||||||
self.focus_and_paste(inp)
|
cb.value = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
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:
|
async def focus_and_paste(self, inp: Input) -> None:
|
||||||
if hasattr(self.app, "paste_from_clipboard"):
|
if hasattr(self.app, "paste_from_clipboard"):
|
||||||
@@ -1025,6 +1196,20 @@ class ConfigModal(ModalScreen):
|
|||||||
if self._matrix_test_running:
|
if self._matrix_test_running:
|
||||||
return
|
return
|
||||||
self._synchronize_inputs_to_config()
|
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:
|
if self._matrix_status:
|
||||||
self._matrix_status.update("Saving configuration before testing…")
|
self._matrix_status.update("Saving configuration before testing…")
|
||||||
changed = count_changed_entries(self.config_data)
|
changed = count_changed_entries(self.config_data)
|
||||||
@@ -1050,23 +1235,215 @@ class ConfigModal(ModalScreen):
|
|||||||
rooms = provider.list_rooms()
|
rooms = provider.list_rooms()
|
||||||
self.app.call_from_thread(self._matrix_test_result, True, rooms, None)
|
self.app.call_from_thread(self._matrix_test_result, True, rooms, None)
|
||||||
except Exception as exc:
|
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(
|
msg = str(exc) or "Matrix test failed"
|
||||||
self,
|
m_lower = msg.lower()
|
||||||
success: bool,
|
if "auth" in m_lower or "authentication" in m_lower:
|
||||||
rooms: List[Dict[str, Any]],
|
msg = msg + ". Please verify your access token and try again."
|
||||||
error: Optional[str],
|
elif "homeserver" in m_lower or "missing" in m_lower:
|
||||||
) -> None:
|
msg = msg + ". Check your homeserver URL (include https://)."
|
||||||
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:
|
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:
|
if self._matrix_status:
|
||||||
self._matrix_status.update(f"Matrix test failed: {error}")
|
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 not success:
|
||||||
|
full_msg = f"Matrix load failed: {error or '(error)'}"
|
||||||
|
if self._matrix_status:
|
||||||
|
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"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(
|
def _open_matrix_room_picker(
|
||||||
self,
|
self,
|
||||||
@@ -1083,6 +1460,138 @@ class ConfigModal(ModalScreen):
|
|||||||
callback=self.on_matrix_rooms_selected,
|
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:
|
def on_matrix_rooms_selected(self, result: Any = None) -> None:
|
||||||
if not isinstance(result, list):
|
if not isinstance(result, list):
|
||||||
if self._matrix_status:
|
if self._matrix_status:
|
||||||
@@ -1113,6 +1622,30 @@ class ConfigModal(ModalScreen):
|
|||||||
self.refresh_view()
|
self.refresh_view()
|
||||||
|
|
||||||
@on(Input.Changed)
|
@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:
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
if event.input.id:
|
if event.input.id:
|
||||||
self._update_config_value(event.input.id, event.value)
|
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 typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
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.screen import ModalScreen
|
||||||
from textual.widgets import Static, Button, Checkbox
|
from textual.widgets import Static, Button, Checkbox, ListView, ListItem
|
||||||
from textual import work
|
from textual import work
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
@@ -40,13 +40,13 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
|
|||||||
margin-bottom: 1;
|
margin-bottom: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#matrix-room-checklist {
|
#matrix-room-list {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.matrix-room-row {
|
.matrix-room-row {
|
||||||
border-bottom: solid $surface;
|
border-bottom: solid $surface;
|
||||||
padding: 0.5 0;
|
padding: 1 0;
|
||||||
align: left middle;
|
align: left middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +91,17 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
|
|||||||
id="matrix-room-picker-hint",
|
id="matrix-room-picker-hint",
|
||||||
)
|
)
|
||||||
with ScrollableContainer(id="matrix-room-scroll"):
|
with ScrollableContainer(id="matrix-room-scroll"):
|
||||||
yield Vertical(id="matrix-room-checklist")
|
yield ListView(id="matrix-room-list")
|
||||||
with Horizontal(id="matrix-room-actions"):
|
with Horizontal(id="matrix-room-actions"):
|
||||||
yield Button("Cancel", variant="error", id="matrix-room-cancel")
|
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 Button("Save defaults", variant="success", id="matrix-room-save")
|
||||||
yield Static("Loading rooms...", id="matrix-room-status")
|
yield Static("Loading rooms...", id="matrix-room-status")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self._status_widget = self.query_one("#matrix-room-status", Static)
|
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)
|
self._save_button = self.query_one("#matrix-room-save", Button)
|
||||||
if self._save_button:
|
if self._save_button:
|
||||||
self._save_button.disabled = True
|
self._save_button.disabled = True
|
||||||
@@ -110,10 +112,35 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
|
|||||||
self._set_status("Loading rooms...")
|
self._set_status("Loading rooms...")
|
||||||
self._load_rooms_background()
|
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:
|
def _set_status(self, text: str) -> None:
|
||||||
if self._status_widget:
|
if self._status_widget:
|
||||||
self._status_widget.update(text)
|
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:
|
def _render_rooms(self, rooms: List[Dict[str, Any]]) -> None:
|
||||||
if not self._checklist:
|
if not self._checklist:
|
||||||
return
|
return
|
||||||
@@ -130,22 +157,20 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
|
|||||||
room_id = str(room.get("room_id") or "").strip()
|
room_id = str(room.get("room_id") or "").strip()
|
||||||
name = str(room.get("name") or "").strip()
|
name = str(room.get("name") or "").strip()
|
||||||
checkbox_id = f"matrix-room-{idx}"
|
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(
|
checkbox = Checkbox(
|
||||||
"",
|
label_text,
|
||||||
id=checkbox_id,
|
id=checkbox_id,
|
||||||
value=bool(room_id and room_id in self._existing_ids),
|
value=bool(room_id and room_id in self._existing_ids),
|
||||||
)
|
)
|
||||||
self._checkbox_map[checkbox_id] = room_id
|
self._checkbox_map[checkbox_id] = room_id
|
||||||
|
|
||||||
label = Text(name or room_id or "Matrix Room")
|
list_item = ListItem(checkbox, classes="matrix-room-row")
|
||||||
label.stylize("bold")
|
self._checklist.mount(list_item)
|
||||||
label.append("\n")
|
|
||||||
label.append(room_id or "(no id)", style="dim")
|
|
||||||
|
|
||||||
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.")
|
self._set_status("Loaded rooms. Select one or more and save.")
|
||||||
if self._save_button:
|
if self._save_button:
|
||||||
self._save_button.disabled = False
|
self._save_button.disabled = False
|
||||||
@@ -167,10 +192,40 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
|
|||||||
self._save_button.disabled = True
|
self._save_button.disabled = True
|
||||||
return
|
return
|
||||||
self._render_rooms(rooms)
|
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:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
if event.button.id == "matrix-room-cancel":
|
if event.button.id == "matrix-room-cancel":
|
||||||
self.dismiss([])
|
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":
|
elif event.button.id == "matrix-room-save":
|
||||||
selected: List[str] = []
|
selected: List[str] = []
|
||||||
for checkbox_id, room_id in self._checkbox_map.items():
|
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
|
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:
|
def _extract_text_arg(args: Sequence[str]) -> str:
|
||||||
"""Extract a `-text <value>` argument from a cmdnat args list."""
|
"""Extract a `-text <value>` argument from a cmdnat args list."""
|
||||||
if not args:
|
if not args:
|
||||||
@@ -1128,9 +1313,31 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# When piped (result has data), show rooms directly.
|
# When piped (result has data), either send directly (if -room given)
|
||||||
# When not piped (first command), show main menu.
|
# or show rooms for interactive selection.
|
||||||
if selected_items:
|
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)
|
return _show_rooms_table(config)
|
||||||
else:
|
else:
|
||||||
return _show_main_menu()
|
return _show_main_menu()
|
||||||
@@ -1185,6 +1392,12 @@ CMDLET = Cmdlet(
|
|||||||
description="Ignore config room filter and show all joined rooms",
|
description="Ignore config room filter and show all joined rooms",
|
||||||
required=False,
|
required=False,
|
||||||
),
|
),
|
||||||
|
CmdletArg(
|
||||||
|
name="room",
|
||||||
|
type="string",
|
||||||
|
description="Target room (name or id). Comma-separated values supported. Autocomplete uses configured defaults.",
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
CmdletArg(
|
CmdletArg(
|
||||||
name="text",
|
name="text",
|
||||||
type="string",
|
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