fdsfdsd
This commit is contained in:
@@ -7,8 +7,10 @@ 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 pathlib import Path
|
||||
|
||||
from SYS.config import load_config, save_config, reload_config, global_config
|
||||
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 Store.registry import _discover_store_classes, _required_keys_for
|
||||
from ProviderCore.registry import list_providers
|
||||
@@ -124,6 +126,8 @@ class ConfigModal(ModalScreen):
|
||||
self._matrix_status: Optional[Static] = None
|
||||
self._matrix_test_running = False
|
||||
self._editor_snapshot: Optional[Dict[str, Any]] = None
|
||||
# Path to the database file used by this process (for diagnostics)
|
||||
self._db_path = str(db.db_path)
|
||||
|
||||
def _capture_editor_snapshot(self) -> None:
|
||||
self._editor_snapshot = deepcopy(self.config_data)
|
||||
@@ -141,8 +145,10 @@ class ConfigModal(ModalScreen):
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="config-container"):
|
||||
yield Static("CONFIGURATION EDITOR", classes="section-title")
|
||||
yield Static(f"DB: {self._db_path}", classes="config-label", id="config-db-path")
|
||||
yield Static("Last saved: unknown", classes="config-label", id="config-last-save")
|
||||
with Horizontal():
|
||||
with Vertical(id="config-sidebar"):
|
||||
with Vertical(id="config-sidebar"):
|
||||
yield Label("Categories", classes="config-label")
|
||||
with ListView(id="category-list"):
|
||||
yield ListItem(Label("Global Settings"), id="cat-globals")
|
||||
@@ -156,11 +162,28 @@ class ConfigModal(ModalScreen):
|
||||
yield Button("Add Store", variant="primary", id="add-store-btn")
|
||||
yield Button("Add Provider", variant="primary", id="add-provider-btn")
|
||||
yield Button("Back", id="back-btn")
|
||||
yield Button("Restore Backup", id="restore-backup-btn")
|
||||
yield Button("Copy DB Path", id="copy-db-btn")
|
||||
yield Button("Close", variant="error", id="cancel-btn")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#add-store-btn", Button).display = False
|
||||
self.query_one("#add-provider-btn", Button).display = False
|
||||
# Update DB path and last-saved on mount
|
||||
try:
|
||||
self.query_one("#config-db-path", Static).update(self._db_path)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mtime = None
|
||||
try:
|
||||
mtime = db.db_path.stat().st_mtime
|
||||
mtime = __import__('datetime').datetime.utcfromtimestamp(mtime).isoformat() + "Z"
|
||||
except Exception:
|
||||
mtime = None
|
||||
self.query_one("#config-last-save", Static).update(f"Last saved: {mtime or '(unknown)'}")
|
||||
except Exception:
|
||||
pass
|
||||
self.refresh_view()
|
||||
|
||||
def refresh_view(self) -> None:
|
||||
@@ -568,19 +591,37 @@ class ConfigModal(ModalScreen):
|
||||
if not self.validate_current_editor():
|
||||
return
|
||||
if self.editing_item_name and not self._editor_has_changes():
|
||||
self.notify("No changes to save", severity="warning")
|
||||
self.notify("No changes to save", severity="warning", timeout=3)
|
||||
return
|
||||
try:
|
||||
saved = self.save_all()
|
||||
msg = f"Configuration saved ({saved} entries)"
|
||||
if saved == 0:
|
||||
msg = "Configuration saved (no rows changed)"
|
||||
self.notify(msg)
|
||||
msg = f"Configuration saved (no rows changed) to {db.db_path.name}"
|
||||
else:
|
||||
msg = f"Configuration saved ({saved} change(s)) to {db.db_path.name}"
|
||||
# Make the success notification visible a bit longer so it's not missed
|
||||
self.notify(msg, timeout=5)
|
||||
# Return to the main list view within the current category
|
||||
self.editing_item_name = None
|
||||
self.editing_item_type = None
|
||||
self.refresh_view()
|
||||
self._editor_snapshot = None
|
||||
except ConfigSaveConflict as exc:
|
||||
# A concurrent on-disk change was detected; do not overwrite it.
|
||||
self.notify(
|
||||
"Save aborted: configuration changed on disk. The editor will refresh.",
|
||||
severity="error",
|
||||
timeout=10,
|
||||
)
|
||||
# Refresh our in-memory view from disk and drop the editor snapshot
|
||||
try:
|
||||
self.config_data = reload_config()
|
||||
except Exception:
|
||||
pass
|
||||
self._editor_snapshot = None
|
||||
self.editing_item_name = None
|
||||
self.editing_item_type = None
|
||||
self.refresh_view()
|
||||
except Exception as exc:
|
||||
self.notify(f"Save failed: {exc}", severity="error", timeout=10)
|
||||
elif bid in self._button_id_map:
|
||||
@@ -602,13 +643,12 @@ class ConfigModal(ModalScreen):
|
||||
if "provider" in self.config_data and name in self.config_data["provider"]:
|
||||
del self.config_data["provider"][name]
|
||||
removed = True
|
||||
if str(name).strip().lower() == "alldebrid":
|
||||
self._remove_alldebrid_store_entry()
|
||||
if removed:
|
||||
try:
|
||||
saved = self.save_all()
|
||||
msg = f"Configuration saved ({saved} entries)"
|
||||
if saved == 0:
|
||||
msg = "Configuration saved (no rows changed)"
|
||||
self.notify(msg)
|
||||
self.notify("Saving configuration...", timeout=3)
|
||||
except Exception as exc:
|
||||
self.notify(f"Save failed: {exc}", severity="error", timeout=10)
|
||||
self.refresh_view()
|
||||
@@ -640,6 +680,27 @@ class ConfigModal(ModalScreen):
|
||||
self._request_matrix_test()
|
||||
elif bid == "matrix-rooms-btn":
|
||||
self._open_matrix_room_picker()
|
||||
elif bid == "restore-backup-btn":
|
||||
try:
|
||||
backup = self._get_last_backup_path()
|
||||
if not backup:
|
||||
self.notify("No backups available", severity="warning")
|
||||
else:
|
||||
# Ask for confirmation via a simple notification and perform restore
|
||||
self.notify(f"Restoring {backup.name}...", timeout=2)
|
||||
self._restore_backup_background(str(backup))
|
||||
except Exception as exc:
|
||||
self.notify(f"Restore failed: {exc}", severity="error")
|
||||
elif bid == "copy-db-btn":
|
||||
try:
|
||||
if hasattr(self.app, "copy_to_clipboard"):
|
||||
self.app.copy_to_clipboard(str(db.db_path))
|
||||
self.notify("DB path copied to clipboard")
|
||||
else:
|
||||
# Fall back to a visible notification
|
||||
self.notify(str(db.db_path))
|
||||
except Exception:
|
||||
self.notify("Failed to copy DB path", severity="warning")
|
||||
elif bid.startswith("paste-"):
|
||||
# Programmatic paste button
|
||||
target_id = bid.replace("paste-", "")
|
||||
@@ -674,6 +735,65 @@ class ConfigModal(ModalScreen):
|
||||
else:
|
||||
self.notify("Clipboard not supported in this terminal", severity="warning")
|
||||
|
||||
def _get_last_backup_path(self):
|
||||
try:
|
||||
backup_dir = Path(db.db_path).with_name("config_backups")
|
||||
if not backup_dir.exists():
|
||||
return None
|
||||
files = sorted(backup_dir.glob("medios-backup-*.db"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return files[0] if files else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@work(thread=True)
|
||||
def _restore_backup_background(self, backup_path: str) -> None:
|
||||
try:
|
||||
import sqlite3, json
|
||||
cfg = {}
|
||||
with sqlite3.connect(backup_path) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT category, subtype, item_name, key, value FROM config")
|
||||
rows = cur.fetchall()
|
||||
for cat, sub, name, key, val in rows:
|
||||
try:
|
||||
parsed = json.loads(val)
|
||||
except Exception:
|
||||
parsed = val
|
||||
if cat == 'global':
|
||||
cfg[key] = parsed
|
||||
elif cat in ('provider', 'tool'):
|
||||
cd = cfg.setdefault(cat, {})
|
||||
sd = cd.setdefault(sub, {})
|
||||
sd[key] = parsed
|
||||
elif cat == 'store':
|
||||
cd = cfg.setdefault('store', {})
|
||||
sd = cd.setdefault(sub, {})
|
||||
nd = sd.setdefault(name, {})
|
||||
nd[key] = parsed
|
||||
else:
|
||||
cfg.setdefault(cat, {})[key] = parsed
|
||||
|
||||
# Persist restored config using save_config
|
||||
from SYS.config import save_config, reload_config
|
||||
saved = save_config(cfg)
|
||||
# Reload and update UI from main thread
|
||||
self.app.call_from_thread(self._on_restore_complete, True, backup_path, saved)
|
||||
except Exception as exc:
|
||||
self.app.call_from_thread(self._on_restore_complete, False, backup_path, str(exc))
|
||||
|
||||
def _on_restore_complete(self, success: bool, backup_path: str, saved_or_error):
|
||||
if success:
|
||||
# Refresh our in-memory view and UI
|
||||
try:
|
||||
from SYS.config import reload_config
|
||||
self.config_data = reload_config()
|
||||
self.refresh_view()
|
||||
except Exception:
|
||||
pass
|
||||
self.notify(f"Restore complete: re-saved {saved_or_error} entries from {Path(backup_path).name}")
|
||||
else:
|
||||
self.notify(f"Restore failed: {saved_or_error}", severity="error")
|
||||
|
||||
def on_store_type_selected(self, stype: str) -> None:
|
||||
if not stype:
|
||||
return
|
||||
@@ -847,6 +967,28 @@ class ConfigModal(ModalScreen):
|
||||
|
||||
self._update_config_value(widget_id, value)
|
||||
|
||||
def _remove_alldebrid_store_entry(self) -> bool:
|
||||
"""Remove the mirrored AllDebrid store entry that would recreate the provider."""
|
||||
store_block = self.config_data.get("store")
|
||||
if not isinstance(store_block, dict):
|
||||
return False
|
||||
debrid = store_block.get("debrid")
|
||||
if not isinstance(debrid, dict):
|
||||
return False
|
||||
|
||||
removed = False
|
||||
for key in list(debrid.keys()):
|
||||
if str(key or "").strip().lower() == "all-debrid":
|
||||
debrid.pop(key, None)
|
||||
removed = True
|
||||
|
||||
if not debrid:
|
||||
store_block.pop("debrid", None)
|
||||
if not store_block:
|
||||
self.config_data.pop("store", None)
|
||||
|
||||
return removed
|
||||
|
||||
def _get_matrix_provider_block(self) -> Dict[str, Any]:
|
||||
providers = self.config_data.get("provider")
|
||||
if not isinstance(providers, dict):
|
||||
@@ -870,6 +1012,7 @@ class ConfigModal(ModalScreen):
|
||||
self._synchronize_inputs_to_config()
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update("Saving configuration before testing…")
|
||||
changed = count_changed_entries(self.config_data)
|
||||
try:
|
||||
entries = save_config(self.config_data)
|
||||
except Exception as exc:
|
||||
@@ -879,7 +1022,7 @@ class ConfigModal(ModalScreen):
|
||||
return
|
||||
self.config_data = reload_config()
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update(f"Saved configuration ({entries} entries). Testing Matrix connection…")
|
||||
self._matrix_status.update(f"Saved configuration ({changed} change(s)) to {db.db_path.name}. Testing Matrix connection…")
|
||||
self._matrix_test_running = True
|
||||
self._matrix_test_background()
|
||||
|
||||
@@ -941,6 +1084,7 @@ class ConfigModal(ModalScreen):
|
||||
return
|
||||
matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {})
|
||||
matrix_block["rooms"] = ", ".join(cleaned)
|
||||
changed = count_changed_entries(self.config_data)
|
||||
try:
|
||||
entries = save_config(self.config_data)
|
||||
except Exception as exc:
|
||||
@@ -949,7 +1093,7 @@ class ConfigModal(ModalScreen):
|
||||
return
|
||||
self.config_data = reload_config()
|
||||
if self._matrix_status:
|
||||
status = f"Saved {len(cleaned)} default room(s) ({entries} rows persisted)."
|
||||
status = f"Saved {len(cleaned)} default room(s) ({changed} change(s)) to {db.db_path.name}."
|
||||
self._matrix_status.update(status)
|
||||
self.refresh_view()
|
||||
|
||||
@@ -967,10 +1111,139 @@ class ConfigModal(ModalScreen):
|
||||
|
||||
def save_all(self) -> int:
|
||||
self._synchronize_inputs_to_config()
|
||||
entries = save_config(self.config_data)
|
||||
self.config_data = reload_config()
|
||||
log(f"ConfigModal saved {entries} configuration entries")
|
||||
return entries
|
||||
# Compute change count prior to persisting so callers can report the number of
|
||||
# actual changes rather than the total number of rows written to the DB.
|
||||
changed = count_changed_entries(self.config_data)
|
||||
# Snapshot config for background save
|
||||
snapshot = deepcopy(self.config_data)
|
||||
# Schedule the save to run on a background worker so the UI doesn't block.
|
||||
try:
|
||||
# Prefer Textual's worker when running inside the app
|
||||
self._save_background(snapshot, changed)
|
||||
except Exception:
|
||||
# Fallback: start a plain thread that runs the underlying task body
|
||||
import threading
|
||||
func = getattr(self._save_background, "__wrapped__", None)
|
||||
if func:
|
||||
thread = threading.Thread(target=lambda: func(self, snapshot, changed), daemon=True)
|
||||
else:
|
||||
thread = threading.Thread(target=lambda: self._save_background(snapshot, changed), daemon=True)
|
||||
thread.start()
|
||||
|
||||
# Ensure DB path indicator is current and show saving status
|
||||
self._db_path = str(db.db_path)
|
||||
try:
|
||||
self.query_one("#config-db-path", Static).update(self._db_path)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.query_one("#config-last-save", Static).update("Last saved: (saving...)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log(f"ConfigModal scheduled save (changed={changed})")
|
||||
return changed
|
||||
|
||||
@work(thread=True)
|
||||
def _save_background(self, cfg: Dict[str, Any], changed: int) -> None:
|
||||
try:
|
||||
saved_entries = save_config(cfg)
|
||||
try:
|
||||
appobj = self.app
|
||||
except Exception:
|
||||
appobj = None
|
||||
if appobj and hasattr(appobj, 'call_from_thread'):
|
||||
appobj.call_from_thread(self._on_save_complete, True, None, changed, saved_entries)
|
||||
else:
|
||||
# If no app (e.g., running under tests), call completion directly
|
||||
self._on_save_complete(True, None, changed, saved_entries)
|
||||
except ConfigSaveConflict as exc:
|
||||
try:
|
||||
appobj = self.app
|
||||
except Exception:
|
||||
appobj = None
|
||||
if appobj and hasattr(appobj, 'call_from_thread'):
|
||||
appobj.call_from_thread(self._on_save_complete, False, str(exc), changed, 0)
|
||||
else:
|
||||
self._on_save_complete(False, str(exc), changed, 0)
|
||||
except Exception as exc:
|
||||
try:
|
||||
appobj = self.app
|
||||
except Exception:
|
||||
appobj = None
|
||||
if appobj and hasattr(appobj, 'call_from_thread'):
|
||||
appobj.call_from_thread(self._on_save_complete, False, str(exc), changed, 0)
|
||||
else:
|
||||
self._on_save_complete(False, str(exc), changed, 0)
|
||||
|
||||
def _on_save_complete(self, success: bool, error: Optional[str], changed: int, saved_entries: int) -> None:
|
||||
# Safely determine whether a Textual app context is available. Accessing
|
||||
# `self.app` can raise when not running inside the TUI; handle that.
|
||||
try:
|
||||
appobj = self.app
|
||||
except Exception:
|
||||
appobj = None
|
||||
|
||||
if success:
|
||||
try:
|
||||
self.config_data = reload_config()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update last-saved label with file timestamp for visibility
|
||||
db_mtime = None
|
||||
try:
|
||||
db_mtime = db.db_path.stat().st_mtime
|
||||
db_mtime = __import__('datetime').datetime.utcfromtimestamp(db_mtime).isoformat() + "Z"
|
||||
except Exception:
|
||||
db_mtime = None
|
||||
|
||||
if appobj:
|
||||
try:
|
||||
if changed == 0:
|
||||
label_text = f"Last saved: (no changes)"
|
||||
else:
|
||||
label_text = f"Last saved: {changed} change(s) at {db_mtime or '(unknown)'}"
|
||||
try:
|
||||
self.query_one("#config-last-save", Static).update(label_text)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.refresh_view()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.notify(f"Configuration saved ({changed} change(s)) to {db.db_path.name}", timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# No TUI available; log instead of updating UI
|
||||
log(f"Configuration saved ({changed} change(s)) to {db.db_path.name}")
|
||||
|
||||
log(f"ConfigModal saved {saved_entries} configuration entries (changed={changed})")
|
||||
else:
|
||||
# Save failed; notify via UI if available, otherwise log
|
||||
if appobj:
|
||||
try:
|
||||
self.notify(f"Save failed: {error}", severity="error", timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.config_data = reload_config()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.refresh_view()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
log(f"Save failed: {error}")
|
||||
|
||||
def validate_current_editor(self) -> bool:
|
||||
"""Ensure all required fields for the current item are filled."""
|
||||
|
||||
Reference in New Issue
Block a user