fix configuration container
This commit is contained in:
parent
add35ccd1f
commit
1cf5463da6
8 changed files with 937 additions and 1069 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -41,4 +41,9 @@ test_*.py
|
||||||
test_release.zip
|
test_release.zip
|
||||||
test_release/
|
test_release/
|
||||||
|
|
||||||
|
REAPER-*/
|
||||||
|
Reaper-*/
|
||||||
|
DISCORD-*/
|
||||||
|
FLUXER-*/
|
||||||
|
REAPER-*/
|
||||||
EXPORT-*/
|
EXPORT-*/
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from pathlib import Path
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
class MigrationSettings(BaseModel):
|
class MigrationSettings(BaseModel):
|
||||||
batch_size: int = Field(default=50)
|
batch_size: int = Field(default=100)
|
||||||
rate_limit_delay_seconds: int = Field(default=2)
|
rate_limit_delay_seconds: int = Field(default=2)
|
||||||
log_level: str = Field(default="INFO")
|
log_level: str = Field(default="INFO")
|
||||||
|
|
||||||
|
|
|
||||||
297
src/ui/backup_ops.py
Normal file
297
src/ui/backup_ops.py
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
"""
|
||||||
|
BackupPane – self-contained backup-operations widget.
|
||||||
|
Embedded inside ModeScreen's "Backup" tab.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import discord
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container, Vertical, VerticalScroll
|
||||||
|
from textual.widgets import Button, Label, Rule
|
||||||
|
from textual import work
|
||||||
|
|
||||||
|
from src.core.configuration import load_config
|
||||||
|
from src.core.base import MigrationContext
|
||||||
|
from src.disco_reaper.exporter import DiscordExporter
|
||||||
|
from src.ui.modals import ProgressModal, ChannelSelectModal
|
||||||
|
|
||||||
|
|
||||||
|
class BackupPane(Container):
|
||||||
|
"""Backup operations pane — profile, messages, sync."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
BackupPane { height: auto; width: 100%; }
|
||||||
|
BackupPane #bp_info {
|
||||||
|
height: auto; border: tall cyan; padding: 1; margin-bottom: 1;
|
||||||
|
}
|
||||||
|
BackupPane #bp_actions { height: auto; }
|
||||||
|
BackupPane #bp_actions Button { width: 100%; margin-bottom: 1; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cfg_name: str, cfg_path: Path, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.cfg_name = cfg_name
|
||||||
|
self.config_path = cfg_path
|
||||||
|
self.config = load_config(cfg_path)
|
||||||
|
self.engine = MigrationContext(self.config, target_platform=self.config.target_platform or "fluxer")
|
||||||
|
self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=f"Reaper-{self.cfg_name}")
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with VerticalScroll():
|
||||||
|
with Vertical(id="bp_info"):
|
||||||
|
yield Label("Loading...", id="bp_lbl_server")
|
||||||
|
yield Label("", id="bp_lbl_bot")
|
||||||
|
yield Label("", id="bp_lbl_backup")
|
||||||
|
with Vertical(id="bp_actions"):
|
||||||
|
yield Button("Backup Server Profile", id="bp_backup_profile", disabled=True)
|
||||||
|
yield Button("Backup Channel Messages", id="bp_backup_msgs", disabled=True)
|
||||||
|
yield Button("Update & Sync Backup", id="bp_backup_sync", disabled=True)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._validate()
|
||||||
|
|
||||||
|
def reload_config(self) -> None:
|
||||||
|
self.config = load_config(self.config_path)
|
||||||
|
self.engine = MigrationContext(self.config, target_platform=self.config.target_platform or "fluxer")
|
||||||
|
self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=f"Reaper-{self.cfg_name}")
|
||||||
|
self._validate()
|
||||||
|
|
||||||
|
# ── validation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=True)
|
||||||
|
async def _validate(self) -> None:
|
||||||
|
fillers = ["DISCORD_BOT_TOKEN", "000000000000000000", "DISCORD_SERVER_ID", "", None]
|
||||||
|
d_token = self.config.discord_bot_token
|
||||||
|
if d_token in fillers or self.config.discord_server_id in fillers:
|
||||||
|
self.app.call_from_thread(self._update_ui, "[red]NOT CONFIGURED[/red]", "", "", False)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
res = await self.engine.discord_reader.validate()
|
||||||
|
valid = res.get("token", False) and res.get("server", False)
|
||||||
|
server_name = res.get("server_name", "Unknown")
|
||||||
|
bot_name = res.get("bot_name", "Unknown")
|
||||||
|
s_text = f'[green]"{server_name}"[/green]' if valid else "[red]INVALID[/red]"
|
||||||
|
b_text = f"[green]{bot_name}[/green]" if valid else "[red]INVALID[/red]"
|
||||||
|
|
||||||
|
backup_text = ""
|
||||||
|
info = self._get_backup_info()
|
||||||
|
if info:
|
||||||
|
backup_text = f"Last backup: [cyan]{info}[/cyan]"
|
||||||
|
|
||||||
|
self.app.call_from_thread(self._update_ui, s_text, b_text, backup_text, valid)
|
||||||
|
except Exception as e:
|
||||||
|
self.app.call_from_thread(self._update_ui, f"[red]Error: {e}[/red]", "", "", False)
|
||||||
|
|
||||||
|
def _get_backup_info(self) -> str | None:
|
||||||
|
profile_file = Path(f"Reaper-{self.cfg_name}") / "server_profile.json"
|
||||||
|
if profile_file.exists():
|
||||||
|
try:
|
||||||
|
with open(profile_file, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
ts_str = data.get("last_backup")
|
||||||
|
if ts_str:
|
||||||
|
dt = datetime.fromisoformat(ts_str)
|
||||||
|
return dt.strftime("%d-%b-%Y %H:%M")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _update_ui(self, server_text, bot_text, backup_text, enabled):
|
||||||
|
self.query_one("#bp_lbl_server", Label).update(f"Source Server: {server_text}")
|
||||||
|
self.query_one("#bp_lbl_bot", Label).update(f"Bot: {bot_text}")
|
||||||
|
self.query_one("#bp_lbl_backup", Label).update(backup_text)
|
||||||
|
for bid in ("#bp_backup_profile", "#bp_backup_msgs", "#bp_backup_sync"):
|
||||||
|
self.query_one(bid, Button).disabled = not enabled
|
||||||
|
|
||||||
|
# ── button routing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
bid = event.button.id
|
||||||
|
if bid == "bp_backup_profile":
|
||||||
|
self.run_backup_profile()
|
||||||
|
elif bid == "bp_backup_msgs":
|
||||||
|
self.run_backup_messages()
|
||||||
|
elif bid == "bp_backup_sync":
|
||||||
|
self.run_backup_sync()
|
||||||
|
|
||||||
|
# ── workers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=True)
|
||||||
|
async def run_backup_profile(self) -> None:
|
||||||
|
modal = ProgressModal()
|
||||||
|
self.app.call_from_thread(self.app.push_screen, modal)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.app.call_from_thread(modal.set_status, "Starting readers...")
|
||||||
|
await self.engine.discord_reader.start()
|
||||||
|
await self.exporter.setup()
|
||||||
|
|
||||||
|
self.app.call_from_thread(modal.write, "[yellow]Backing up server profile & skeleton...[/yellow]")
|
||||||
|
await self.exporter.export_metadata()
|
||||||
|
await self.exporter.download_server_assets()
|
||||||
|
|
||||||
|
self.app.call_from_thread(modal.write, "Exporting structure...")
|
||||||
|
_, cat_count, chan_count = await self.exporter.export_channels_structure()
|
||||||
|
|
||||||
|
self.app.call_from_thread(modal.write, "Exporting assets...")
|
||||||
|
e_count, s_count = await self.exporter.export_assets()
|
||||||
|
|
||||||
|
self.app.call_from_thread(modal.write, f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]")
|
||||||
|
self.app.call_from_thread(modal.write, f"- {cat_count} categories & {chan_count} channels")
|
||||||
|
self.app.call_from_thread(modal.write, f"- {e_count} emojis, {s_count} stickers.")
|
||||||
|
|
||||||
|
except discord.Forbidden as e:
|
||||||
|
self.app.call_from_thread(modal.write, f"[bold red]Backup failed: {e}[/bold red]")
|
||||||
|
except Exception as e:
|
||||||
|
self.app.call_from_thread(modal.write, f"[bold red]Error: {e}[/bold red]")
|
||||||
|
finally:
|
||||||
|
await self.engine.close_connections()
|
||||||
|
self.app.call_from_thread(modal.set_status, "Finished.")
|
||||||
|
self.app.call_from_thread(modal.allow_close)
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=True)
|
||||||
|
async def run_backup_messages(self) -> None:
|
||||||
|
modal_prog = ProgressModal()
|
||||||
|
self.app.call_from_thread(self.app.push_screen, modal_prog)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.app.call_from_thread(modal_prog.set_status, "Fetching channels...")
|
||||||
|
await self.engine.discord_reader.start()
|
||||||
|
await self.exporter.setup()
|
||||||
|
|
||||||
|
await self.exporter.export_channels_structure()
|
||||||
|
all_channels = await self.engine.discord_reader.get_channels()
|
||||||
|
all_categories = await self.engine.discord_reader.get_categories()
|
||||||
|
cat_map = {c.id: c.name for c in all_categories}
|
||||||
|
|
||||||
|
eligible_channels = [
|
||||||
|
c for c in all_channels
|
||||||
|
if c.type in [discord.ChannelType.text, discord.ChannelType.news, discord.ChannelType.forum]
|
||||||
|
]
|
||||||
|
|
||||||
|
if not eligible_channels:
|
||||||
|
self.app.call_from_thread(modal_prog.write, "[yellow]No text/news channels found to backup.[/yellow]")
|
||||||
|
self.app.call_from_thread(modal_prog.allow_close)
|
||||||
|
return
|
||||||
|
|
||||||
|
any_found = False
|
||||||
|
backed_up_ids = set()
|
||||||
|
for chan in eligible_channels:
|
||||||
|
if (self.exporter.export_path / "message_backup" / f"{chan.id}.json").exists():
|
||||||
|
any_found = True
|
||||||
|
backed_up_ids.add(chan.id)
|
||||||
|
|
||||||
|
self.app.call_from_thread(self.app.pop_screen)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
future = loop.create_future()
|
||||||
|
|
||||||
|
def check_channels(reply: dict | None) -> None:
|
||||||
|
if not future.done():
|
||||||
|
future.set_result(reply)
|
||||||
|
|
||||||
|
self.app.call_from_thread(
|
||||||
|
self.app.push_screen,
|
||||||
|
ChannelSelectModal(eligible_channels, cat_map, backed_up_ids, any_found),
|
||||||
|
check_channels,
|
||||||
|
)
|
||||||
|
|
||||||
|
reply = await future
|
||||||
|
if not reply:
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_ids = reply["channels"]
|
||||||
|
force_overwrite = reply["force"]
|
||||||
|
|
||||||
|
selected_channels = [c for c in eligible_channels if c.id in selected_ids]
|
||||||
|
|
||||||
|
self.app.call_from_thread(self.app.push_screen, modal_prog)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
total_chans = len(selected_channels)
|
||||||
|
self.app.call_from_thread(modal_prog.set_status, "Backing up messages...")
|
||||||
|
self.app.call_from_thread(modal_prog.write, f"[yellow]Starting backup for {total_chans} channels...[/yellow]")
|
||||||
|
|
||||||
|
for chan in selected_channels:
|
||||||
|
backup_exists = (self.exporter.export_path / "message_backup" / f"{chan.id}.json").exists()
|
||||||
|
is_sync = backup_exists and not force_overwrite
|
||||||
|
|
||||||
|
label = "Syncing Backup" if is_sync else "Backing up"
|
||||||
|
self.app.call_from_thread(modal_prog.write, f"[cyan]{label}: {chan.name}[/cyan]")
|
||||||
|
|
||||||
|
async def update_msg_count(name, count):
|
||||||
|
self.app.call_from_thread(modal_prog.set_status, f"{name}: {count} messages")
|
||||||
|
|
||||||
|
await self.exporter.export_channel_messages(chan.id, progress_callback=update_msg_count, force=force_overwrite)
|
||||||
|
await self.exporter.export_threads(chan.id, progress_callback=update_msg_count, force=force_overwrite)
|
||||||
|
|
||||||
|
self.app.call_from_thread(modal_prog.write, f"[green]Completed: {chan.name}[/green]")
|
||||||
|
|
||||||
|
await self.exporter.export_metadata()
|
||||||
|
self.app.call_from_thread(modal_prog.write, "[bold green]Message backup complete![/bold green]")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.app.call_from_thread(modal_prog.write, f"[bold red]Message backup failed: {e}[/bold red]")
|
||||||
|
finally:
|
||||||
|
await self.engine.close_connections()
|
||||||
|
self.app.call_from_thread(modal_prog.set_status, "Finished.")
|
||||||
|
self.app.call_from_thread(modal_prog.allow_close)
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=True)
|
||||||
|
async def run_backup_sync(self) -> None:
|
||||||
|
modal_prog = ProgressModal()
|
||||||
|
self.app.call_from_thread(self.app.push_screen, modal_prog)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.app.call_from_thread(modal_prog.set_status, "Starting sync...")
|
||||||
|
await self.engine.discord_reader.start()
|
||||||
|
await self.exporter.setup()
|
||||||
|
|
||||||
|
self.app.call_from_thread(modal_prog.write, "Updating structure...")
|
||||||
|
await self.exporter.export_metadata()
|
||||||
|
await self.exporter.download_server_assets()
|
||||||
|
await self.exporter.export_channels_structure()
|
||||||
|
await self.exporter.export_assets()
|
||||||
|
|
||||||
|
all_channels = await self.engine.discord_reader.get_channels()
|
||||||
|
eligible_channels = [
|
||||||
|
c for c in all_channels
|
||||||
|
if c.type in [discord.ChannelType.text, discord.ChannelType.news, discord.ChannelType.forum]
|
||||||
|
]
|
||||||
|
|
||||||
|
selected_channels = [
|
||||||
|
c for c in eligible_channels
|
||||||
|
if (self.exporter.export_path / "message_backup" / f"{c.id}.json").exists()
|
||||||
|
]
|
||||||
|
|
||||||
|
if not selected_channels:
|
||||||
|
self.app.call_from_thread(modal_prog.write, "[yellow]No existing backups found to sync.[/yellow]")
|
||||||
|
else:
|
||||||
|
self.app.call_from_thread(modal_prog.write, f"[yellow]Syncing {len(selected_channels)} channels...[/yellow]")
|
||||||
|
for chan in selected_channels:
|
||||||
|
self.app.call_from_thread(modal_prog.write, f"[cyan]Syncing: {chan.name}[/cyan]")
|
||||||
|
|
||||||
|
async def update_msg_count(name, count):
|
||||||
|
self.app.call_from_thread(modal_prog.set_status, f"{name}: {count} messages")
|
||||||
|
|
||||||
|
await self.exporter.export_channel_messages(chan.id, progress_callback=update_msg_count, force=False)
|
||||||
|
await self.exporter.export_threads(chan.id, progress_callback=update_msg_count, force=False)
|
||||||
|
self.app.call_from_thread(modal_prog.write, f"[green]Synced: {chan.name}[/green]")
|
||||||
|
|
||||||
|
await self.exporter.export_metadata()
|
||||||
|
self.app.call_from_thread(modal_prog.write, "[bold green]Sync operation complete![/bold green]")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.app.call_from_thread(modal_prog.write, f"[bold red]Sync failed: {e}[/bold red]")
|
||||||
|
finally:
|
||||||
|
await self.engine.close_connections()
|
||||||
|
self.app.call_from_thread(modal_prog.set_status, "Finished.")
|
||||||
|
self.app.call_from_thread(modal_prog.allow_close)
|
||||||
|
|
@ -1,567 +0,0 @@
|
||||||
import sys
|
|
||||||
import asyncio
|
|
||||||
import discord
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
|
||||||
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
|
||||||
from textual.widgets import Header, Footer, Button, Static, Label, Input, Checkbox, RadioButton, ProgressBar, RichLog, Rule
|
|
||||||
from textual.screen import Screen, ModalScreen
|
|
||||||
from textual import work
|
|
||||||
from textual.worker import Worker, WorkerState
|
|
||||||
|
|
||||||
from src.core.configuration import load_config, save_config
|
|
||||||
from src.core.base import MigrationContext
|
|
||||||
from src.disco_reaper.exporter import DiscordExporter
|
|
||||||
|
|
||||||
class ConfigModal(ModalScreen[dict]):
|
|
||||||
"""Modal for configuring token and server ID."""
|
|
||||||
|
|
||||||
def __init__(self, current_token: str, current_server: str):
|
|
||||||
super().__init__()
|
|
||||||
self.current_token = current_token
|
|
||||||
self.current_server = current_server
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Vertical(id="config_dialog"):
|
|
||||||
yield Label("Configuration", id="config_title")
|
|
||||||
yield Label("Discord Bot Token:")
|
|
||||||
yield Input(value=self.current_token, id="token_input")
|
|
||||||
yield Label("Discord Server ID:")
|
|
||||||
yield Input(value=self.current_server, id="server_input")
|
|
||||||
with Horizontal(id="config_buttons"):
|
|
||||||
yield Button("Save", variant="success", id="btn_save")
|
|
||||||
yield Button("Cancel", variant="primary", id="btn_cancel")
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "btn_save":
|
|
||||||
token = self.query_one("#token_input", Input).value
|
|
||||||
server = self.query_one("#server_input", Input).value
|
|
||||||
self.dismiss({"token": token, "server": server})
|
|
||||||
elif event.button.id == "btn_cancel":
|
|
||||||
self.dismiss(None)
|
|
||||||
|
|
||||||
class ChannelSelectModal(ModalScreen[dict]):
|
|
||||||
"""Modal for selecting channels using a simple checkbox list."""
|
|
||||||
|
|
||||||
def __init__(self, channels: list, categories: dict, backed_up_ids: set, any_found: bool):
|
|
||||||
super().__init__()
|
|
||||||
# Group channels by category
|
|
||||||
self.channels_by_category = {}
|
|
||||||
for c in channels:
|
|
||||||
cat_id = getattr(c, 'category_id', None)
|
|
||||||
if cat_id not in self.channels_by_category:
|
|
||||||
self.channels_by_category[cat_id] = []
|
|
||||||
self.channels_by_category[cat_id].append(c)
|
|
||||||
|
|
||||||
self.categories = categories # id -> name
|
|
||||||
self.backed_up_ids = backed_up_ids
|
|
||||||
self.any_found = any_found
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Vertical(id="channel_dialog"):
|
|
||||||
yield Label(f"Select Channels to Backup", id="chan_title")
|
|
||||||
|
|
||||||
with VerticalScroll(id="channel_list_scroll"):
|
|
||||||
# Group by category
|
|
||||||
cat_ids = sorted([k for k in self.channels_by_category.keys() if k is not None],
|
|
||||||
key=lambda k: self.categories.get(k, ""))
|
|
||||||
|
|
||||||
# No category channels
|
|
||||||
if None in self.channels_by_category:
|
|
||||||
for c in sorted(self.channels_by_category[None], key=lambda x: x.position if hasattr(x, 'position') else 0):
|
|
||||||
label = f"{c.name}"
|
|
||||||
color = "green" if c.id in self.backed_up_ids else "white"
|
|
||||||
yield RadioButton(f"[{color}]{label}[/]", value=False, id=f"chan_{c.id}")
|
|
||||||
|
|
||||||
for cat_id in cat_ids:
|
|
||||||
cat_name = self.categories.get(cat_id, "Unknown Category")
|
|
||||||
yield Label(f"[cyan]{cat_name}[/cyan]", classes="category_header")
|
|
||||||
for c in sorted(self.channels_by_category[cat_id], key=lambda x: x.position if hasattr(x, 'position') else 0):
|
|
||||||
label = f"{c.name}"
|
|
||||||
color = "green" if c.id in self.backed_up_ids else "white"
|
|
||||||
yield RadioButton(f"[{color}]{label}[/]", value=False, id=f"chan_{c.id}")
|
|
||||||
|
|
||||||
with Horizontal(id="select_all_buttons"):
|
|
||||||
yield Button("Select All", id="btn_all")
|
|
||||||
yield Button("Deselect All", id="btn_none")
|
|
||||||
|
|
||||||
with Horizontal(id="confirm_buttons"):
|
|
||||||
if self.any_found:
|
|
||||||
yield Label("Existing backups found:", classes="label_warning")
|
|
||||||
yield Button("Sync", variant="success", id="btn_sync")
|
|
||||||
yield Button("Force Overwrite", variant="error", id="btn_force")
|
|
||||||
else:
|
|
||||||
yield Button("Backup", variant="success", id="btn_backup")
|
|
||||||
yield Button("Cancel", id="btn_cancel_chan")
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "btn_all":
|
|
||||||
for cb in self.query(RadioButton):
|
|
||||||
cb.value = True
|
|
||||||
elif event.button.id == "btn_none":
|
|
||||||
for cb in self.query(RadioButton):
|
|
||||||
cb.value = False
|
|
||||||
elif event.button.id in ["btn_sync", "btn_force", "btn_backup"]:
|
|
||||||
selected = []
|
|
||||||
for cb in self.query(RadioButton):
|
|
||||||
if cb.value and cb.id and cb.id.startswith("chan_"):
|
|
||||||
chan_id = int(cb.id.split("_")[1])
|
|
||||||
selected.append(chan_id)
|
|
||||||
if not selected:
|
|
||||||
return
|
|
||||||
|
|
||||||
force = event.button.id == "btn_force"
|
|
||||||
self.dismiss({"channels": selected, "force": force})
|
|
||||||
elif event.button.id == "btn_cancel_chan":
|
|
||||||
self.dismiss(None)
|
|
||||||
|
|
||||||
class ProgressModal(ModalScreen[None]):
|
|
||||||
"""Modal to show progress of backup."""
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Vertical(id="progress_dialog"):
|
|
||||||
yield Label("Operation Status", id="progress_status")
|
|
||||||
yield ProgressBar(total=None, show_eta=False, id="progress_bar")
|
|
||||||
yield RichLog(id="progress_log", highlight=True, markup=True)
|
|
||||||
yield Button("Close", id="btn_close_progress", disabled=True)
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed):
|
|
||||||
if event.button.id == "btn_close_progress":
|
|
||||||
self.dismiss(None)
|
|
||||||
|
|
||||||
def write_to_log(self, message: str):
|
|
||||||
self.query_one("#progress_log", RichLog).write(message)
|
|
||||||
|
|
||||||
def set_status(self, status: str):
|
|
||||||
self.query_one("#progress_status", Label).update(status)
|
|
||||||
|
|
||||||
def allow_close(self):
|
|
||||||
btn = self.query_one("#btn_close_progress", Button)
|
|
||||||
btn.disabled = False
|
|
||||||
btn.variant = "success"
|
|
||||||
self.query_one("#progress_bar", ProgressBar).update(total=100, progress=100)
|
|
||||||
|
|
||||||
class BackupScreen(Screen):
|
|
||||||
"""Main screen of the application."""
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
#backup_scroll {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#main_container {
|
|
||||||
width: 80%;
|
|
||||||
height: auto;
|
|
||||||
min-height: 20;
|
|
||||||
border: solid green;
|
|
||||||
padding: 1 2;
|
|
||||||
margin: 2 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#info_container {
|
|
||||||
height: auto;
|
|
||||||
margin-bottom: 2;
|
|
||||||
border: tall cyan;
|
|
||||||
padding: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#actions_container {
|
|
||||||
height: auto;
|
|
||||||
layout: vertical;
|
|
||||||
align: center top;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config_dialog {
|
|
||||||
width: 60%;
|
|
||||||
height: 60%;
|
|
||||||
border: thick $background 80%;
|
|
||||||
background: $surface;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
#config_title { text-style: bold; margin-bottom: 1; }
|
|
||||||
#config_buttons { height: auto; margin-top: 1; }
|
|
||||||
#config_buttons Button { width: 1fr; margin: 0 1; }
|
|
||||||
|
|
||||||
#channel_dialog {
|
|
||||||
width: 80%;
|
|
||||||
height: 80%;
|
|
||||||
border: thick $background 80%;
|
|
||||||
background: $surface;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
#chan_title { text-style: bold; margin-bottom: 1; }
|
|
||||||
|
|
||||||
.category_header {
|
|
||||||
margin-top: 1;
|
|
||||||
background: $primary 10%;
|
|
||||||
text-style: bold;
|
|
||||||
padding-left: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#channel_list_scroll {
|
|
||||||
height: 1fr;
|
|
||||||
border: solid $primary;
|
|
||||||
margin-bottom: 1;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#select_all_buttons { height: auto; margin-bottom: 1; }
|
|
||||||
#select_all_buttons Button { width: auto; margin-right: 1; }
|
|
||||||
|
|
||||||
#confirm_buttons { height: auto; margin-top: 1; }
|
|
||||||
#confirm_buttons Button { width: auto; margin: 0 1; }
|
|
||||||
|
|
||||||
RadioButton:focus {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
RadioButton > .radio-button--label {
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
RadioButton:focus > .radio-button--label {
|
|
||||||
background: transparent;
|
|
||||||
text-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#progress_dialog {
|
|
||||||
width: 80%;
|
|
||||||
height: 80%;
|
|
||||||
border: thick $background 80%;
|
|
||||||
background: $surface;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
#progress_status { text-style: bold; margin-bottom: 1; }
|
|
||||||
#progress_bar { margin-bottom: 1; }
|
|
||||||
#progress_log { height: 1fr; margin-bottom: 1; border: solid $primary; }
|
|
||||||
.label_warning { color: yellow; margin-right: 1; content-align: center middle;}
|
|
||||||
"""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("q", "app.exit", "Quit"),
|
|
||||||
("c", "config", "Config"),
|
|
||||||
("b", "app.pop_screen", "Back"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, cfg_name: str, cfg_path: Path, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.cfg_name = cfg_name
|
|
||||||
self.config_path = cfg_path
|
|
||||||
self.config = load_config(self.config_path)
|
|
||||||
self.engine = MigrationContext(self.config, target_platform="fluxer")
|
|
||||||
self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=f"Reaper-{self.cfg_name}")
|
|
||||||
self.validation_results = {}
|
|
||||||
self.tokens_valid = False
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
yield Header(show_clock=True)
|
|
||||||
with VerticalScroll(id="backup_scroll"):
|
|
||||||
with Container(id="main_container"):
|
|
||||||
yield Label("Disco Reaper - Server Backup Tool", id="title_label")
|
|
||||||
with Vertical(id="info_container"):
|
|
||||||
yield Label("Loading...", id="lbl_server")
|
|
||||||
yield Label("", id="lbl_bot")
|
|
||||||
yield Label("", id="lbl_backup")
|
|
||||||
with Vertical(id="actions_container"):
|
|
||||||
yield Button("Backup Server Profile", id="btn_backup_profile", disabled=True)
|
|
||||||
yield Button("Backup Channel Messages", id="btn_backup_msgs", disabled=True)
|
|
||||||
yield Button("Update & Sync Backup", id="btn_backup_sync", disabled=True)
|
|
||||||
yield Rule()
|
|
||||||
yield Button("Configuration", id="btn_config")
|
|
||||||
yield Button("Back", id="btn_back")
|
|
||||||
yield Button("Exit", id="btn_exit", variant="error")
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.validate_config()
|
|
||||||
|
|
||||||
@work(exclusive=True, thread=True)
|
|
||||||
async def validate_config(self) -> None:
|
|
||||||
def update_ui(server_text, bot_text, backup_text, buttons_enabled):
|
|
||||||
self.query_one("#lbl_server", Label).update(f"Source Server: {server_text}")
|
|
||||||
self.query_one("#lbl_bot", Label).update(f"Bot name: {bot_text}")
|
|
||||||
self.query_one("#lbl_backup", Label).update(backup_text)
|
|
||||||
for btn_id in ["#btn_backup_profile", "#btn_backup_msgs", "#btn_backup_sync"]:
|
|
||||||
self.query_one(btn_id, Button).disabled = not buttons_enabled
|
|
||||||
|
|
||||||
d_token = self.config.discord_bot_token
|
|
||||||
fillers = ["DISCORD_BOT_TOKEN", "000000000000000000", "DISCORD_SERVER_ID", "", None]
|
|
||||||
if d_token in fillers or self.config.discord_server_id in fillers:
|
|
||||||
self.app.call_from_thread(update_ui, "[red]NOT CONNECTED[/red]", "[red]UNKNOWN[/red]", "", False)
|
|
||||||
self.tokens_valid = False
|
|
||||||
return
|
|
||||||
|
|
||||||
self.app.call_from_thread(update_ui, "[yellow]Validating...[/yellow]", "[yellow]Validating...[/yellow]", "", False)
|
|
||||||
|
|
||||||
try:
|
|
||||||
res = await self.engine.discord_reader.validate()
|
|
||||||
except Exception as e:
|
|
||||||
res = {}
|
|
||||||
self.app.call_from_thread(update_ui, f"[red]Validation Error: {e}[/red]", "", "", False)
|
|
||||||
self.tokens_valid = False
|
|
||||||
return
|
|
||||||
|
|
||||||
self.validation_results["discord_token"] = res.get("token", False)
|
|
||||||
self.validation_results["discord_bot_name"] = res.get("bot_name")
|
|
||||||
self.validation_results["discord_server"] = res.get("server", False)
|
|
||||||
self.validation_results["discord_server_name"] = res.get("server_name")
|
|
||||||
|
|
||||||
self.tokens_valid = res.get("token") and res.get("server")
|
|
||||||
|
|
||||||
d_name = self.validation_results.get("discord_server_name")
|
|
||||||
server_display = f"[green]\"{d_name}\"[/green]" if d_name else "[red]NOT CONNECTED[/red]"
|
|
||||||
|
|
||||||
b_name = self.validation_results.get("discord_bot_name")
|
|
||||||
bot_display = f"[green]\"{b_name}\"[/green]" if b_name else "[red]UNKNOWN[/red]"
|
|
||||||
|
|
||||||
backup_text = ""
|
|
||||||
if self.tokens_valid:
|
|
||||||
backup_ts = self.get_backup_info()
|
|
||||||
if backup_ts:
|
|
||||||
backup_text = f"Backup Found: [yellow]{backup_ts}[/yellow]"
|
|
||||||
|
|
||||||
self.app.call_from_thread(update_ui, server_display, bot_display, backup_text, self.tokens_valid)
|
|
||||||
|
|
||||||
def get_backup_info(self):
|
|
||||||
d_name = self.validation_results.get("discord_server_name")
|
|
||||||
d_id = self.config.discord_server_id
|
|
||||||
if not d_name or not d_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
export_path = Path(f"Reaper-{self.cfg_name}") / f"DISCORD-{d_id}"
|
|
||||||
profile_file = export_path / "server_profile.json"
|
|
||||||
|
|
||||||
if profile_file.exists():
|
|
||||||
try:
|
|
||||||
with open(profile_file, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
ts_str = data.get("last_backup")
|
|
||||||
if ts_str:
|
|
||||||
dt = datetime.fromisoformat(ts_str)
|
|
||||||
return dt.strftime("%d-%b-%Y %H:%M")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
def action_config(self) -> None:
|
|
||||||
self.open_config()
|
|
||||||
|
|
||||||
def open_config(self) -> None:
|
|
||||||
def check_reply(reply: dict | None) -> None:
|
|
||||||
if reply is not None:
|
|
||||||
self.config.discord_bot_token = reply["token"]
|
|
||||||
self.config.discord_server_id = reply["server"]
|
|
||||||
save_config(self.config, self.config_path)
|
|
||||||
self.engine = MigrationContext(self.config, target_platform="fluxer")
|
|
||||||
self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=f"Reaper-{self.cfg_name}")
|
|
||||||
self.validate_config()
|
|
||||||
|
|
||||||
self.app.push_screen(
|
|
||||||
ConfigModal(self.config.discord_bot_token, self.config.discord_server_id),
|
|
||||||
check_reply
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "btn_exit":
|
|
||||||
self.app.exit()
|
|
||||||
elif event.button.id == "btn_back":
|
|
||||||
self.app.pop_screen()
|
|
||||||
elif event.button.id == "btn_config":
|
|
||||||
self.open_config()
|
|
||||||
elif event.button.id == "btn_backup_profile":
|
|
||||||
self.run_backup_profile()
|
|
||||||
elif event.button.id == "btn_backup_msgs":
|
|
||||||
self.run_backup_messages()
|
|
||||||
elif event.button.id == "btn_backup_sync":
|
|
||||||
self.run_backup_sync()
|
|
||||||
|
|
||||||
@work(exclusive=True, thread=True)
|
|
||||||
async def run_backup_profile(self) -> None:
|
|
||||||
modal = ProgressModal()
|
|
||||||
self.app.call_from_thread(self.app.push_screen, modal)
|
|
||||||
await asyncio.sleep(0.1) # Wait for mount
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.app.call_from_thread(modal.set_status, "Starting readers...")
|
|
||||||
await self.engine.discord_reader.start()
|
|
||||||
meta = await self.exporter.setup()
|
|
||||||
|
|
||||||
self.app.call_from_thread(modal.write_to_log, "[yellow]Backing up server profile & skeleton...[/yellow]")
|
|
||||||
await self.exporter.export_metadata()
|
|
||||||
await self.exporter.download_server_assets()
|
|
||||||
|
|
||||||
self.app.call_from_thread(modal.write_to_log, "Exporting structure...")
|
|
||||||
_, cat_count, chan_count = await self.exporter.export_channels_structure()
|
|
||||||
|
|
||||||
self.app.call_from_thread(modal.write_to_log, "Exporting assets...")
|
|
||||||
e_count, s_count = await self.exporter.export_assets()
|
|
||||||
|
|
||||||
self.app.call_from_thread(modal.write_to_log, f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]")
|
|
||||||
self.app.call_from_thread(modal.write_to_log, f"- {cat_count} categories & {chan_count} channels")
|
|
||||||
self.app.call_from_thread(modal.write_to_log, f"- {e_count} emojis, {s_count} stickers.")
|
|
||||||
|
|
||||||
except discord.Forbidden as e:
|
|
||||||
self.app.call_from_thread(modal.write_to_log, f"[bold red]Backup failed: {e}[/bold red]")
|
|
||||||
except Exception as e:
|
|
||||||
self.app.call_from_thread(modal.write_to_log, f"[bold red]Error: {e}[/bold red]")
|
|
||||||
finally:
|
|
||||||
await self.engine.close_connections()
|
|
||||||
self.app.call_from_thread(modal.set_status, "Finished.")
|
|
||||||
self.app.call_from_thread(modal.allow_close)
|
|
||||||
|
|
||||||
@work(exclusive=True, thread=True)
|
|
||||||
async def run_backup_messages(self) -> None:
|
|
||||||
modal_prog = ProgressModal()
|
|
||||||
self.app.call_from_thread(self.app.push_screen, modal_prog)
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.app.call_from_thread(modal_prog.set_status, "Fetching channels...")
|
|
||||||
await self.engine.discord_reader.start()
|
|
||||||
await self.exporter.setup()
|
|
||||||
|
|
||||||
await self.exporter.export_channels_structure()
|
|
||||||
all_channels = await self.engine.discord_reader.get_channels()
|
|
||||||
all_categories = await self.engine.discord_reader.get_categories()
|
|
||||||
cat_map = {c.id: c.name for c in all_categories}
|
|
||||||
|
|
||||||
eligible_channels = [
|
|
||||||
c for c in all_channels
|
|
||||||
if c.type in [discord.ChannelType.text, discord.ChannelType.news, discord.ChannelType.forum]
|
|
||||||
]
|
|
||||||
|
|
||||||
if not eligible_channels:
|
|
||||||
self.app.call_from_thread(modal_prog.write_to_log, "[yellow]No text/news channels found to backup.[/yellow]")
|
|
||||||
self.app.call_from_thread(modal_prog.allow_close)
|
|
||||||
return
|
|
||||||
|
|
||||||
any_found = False
|
|
||||||
backed_up_ids = set()
|
|
||||||
for chan in eligible_channels:
|
|
||||||
if (self.exporter.export_path / "message_backup" / f"{chan.id}.json").exists():
|
|
||||||
any_found = True
|
|
||||||
backed_up_ids.add(chan.id)
|
|
||||||
|
|
||||||
# Need to ask for channels. We temporarily pop the progress modal, push channel select, wait for result, push progress again.
|
|
||||||
self.app.call_from_thread(self.app.pop_screen)
|
|
||||||
|
|
||||||
# Setup a future to await modal response
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
future = loop.create_future()
|
|
||||||
|
|
||||||
def check_channels(reply: dict | None) -> None:
|
|
||||||
if not future.done():
|
|
||||||
future.set_result(reply)
|
|
||||||
|
|
||||||
self.app.call_from_thread(
|
|
||||||
self.app.push_screen,
|
|
||||||
ChannelSelectModal(eligible_channels, cat_map, backed_up_ids, any_found),
|
|
||||||
check_channels
|
|
||||||
)
|
|
||||||
|
|
||||||
reply = await future
|
|
||||||
if not reply:
|
|
||||||
# Cancelled
|
|
||||||
return
|
|
||||||
|
|
||||||
selected_ids = reply["channels"]
|
|
||||||
force_overwrite = reply["force"]
|
|
||||||
|
|
||||||
selected_channels = [c for c in eligible_channels if c.id in selected_ids]
|
|
||||||
|
|
||||||
self.app.call_from_thread(self.app.push_screen, modal_prog)
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
total_chans = len(selected_channels)
|
|
||||||
self.app.call_from_thread(modal_prog.set_status, "Backing up messages...")
|
|
||||||
self.app.call_from_thread(modal_prog.write_to_log, f"[yellow]Starting backup for {total_chans} channels...[/yellow]")
|
|
||||||
|
|
||||||
for chan in selected_channels:
|
|
||||||
backup_exists = (self.exporter.export_path / "message_backup" / f"{chan.id}.json").exists()
|
|
||||||
is_sync = backup_exists and not force_overwrite
|
|
||||||
|
|
||||||
label = "Syncing Backup" if is_sync else "Backing up"
|
|
||||||
self.app.call_from_thread(modal_prog.write_to_log, f"[cyan]{label}: {chan.name}[/cyan]")
|
|
||||||
|
|
||||||
async def update_msg_count(name, count):
|
|
||||||
self.app.call_from_thread(modal_prog.set_status, f"{name}: {count} messages")
|
|
||||||
|
|
||||||
await self.exporter.export_channel_messages(chan.id, progress_callback=update_msg_count, force=force_overwrite)
|
|
||||||
await self.exporter.export_threads(chan.id, progress_callback=update_msg_count, force=force_overwrite)
|
|
||||||
|
|
||||||
self.app.call_from_thread(modal_prog.write_to_log, f"[green]Completed: {chan.name}[/green]")
|
|
||||||
|
|
||||||
await self.exporter.export_metadata()
|
|
||||||
self.app.call_from_thread(modal_prog.write_to_log, "[bold green]Message backup complete![/bold green]")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.app.call_from_thread(modal_prog.write_to_log, f"[bold red]Message backup failed: {e}[/bold red]")
|
|
||||||
finally:
|
|
||||||
await self.engine.close_connections()
|
|
||||||
self.app.call_from_thread(modal_prog.set_status, "Finished.")
|
|
||||||
self.app.call_from_thread(modal_prog.allow_close)
|
|
||||||
|
|
||||||
@work(exclusive=True, thread=True)
|
|
||||||
async def run_backup_sync(self) -> None:
|
|
||||||
modal_prog = ProgressModal()
|
|
||||||
self.app.call_from_thread(self.app.push_screen, modal_prog)
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.app.call_from_thread(modal_prog.set_status, "Starting sync...")
|
|
||||||
await self.engine.discord_reader.start()
|
|
||||||
await self.exporter.setup()
|
|
||||||
|
|
||||||
self.app.call_from_thread(modal_prog.write_to_log, "Updating structure...")
|
|
||||||
await self.exporter.export_metadata()
|
|
||||||
await self.exporter.download_server_assets()
|
|
||||||
await self.exporter.export_channels_structure()
|
|
||||||
await self.exporter.export_assets()
|
|
||||||
|
|
||||||
all_channels = await self.engine.discord_reader.get_channels()
|
|
||||||
eligible_channels = [
|
|
||||||
c for c in all_channels
|
|
||||||
if c.type in [discord.ChannelType.text, discord.ChannelType.news, discord.ChannelType.forum]
|
|
||||||
]
|
|
||||||
|
|
||||||
selected_channels = [
|
|
||||||
c for c in eligible_channels
|
|
||||||
if (self.exporter.export_path / "message_backup" / f"{c.id}.json").exists()
|
|
||||||
]
|
|
||||||
|
|
||||||
if not selected_channels:
|
|
||||||
self.app.call_from_thread(modal_prog.write_to_log, "[yellow]No existing backups found to sync.[/yellow]")
|
|
||||||
else:
|
|
||||||
self.app.call_from_thread(modal_prog.write_to_log, f"[yellow]Syncing {len(selected_channels)} channels...[/yellow]")
|
|
||||||
for chan in selected_channels:
|
|
||||||
self.app.call_from_thread(modal_prog.write_to_log, f"[cyan]Syncing: {chan.name}[/cyan]")
|
|
||||||
|
|
||||||
async def update_msg_count(name, count):
|
|
||||||
self.app.call_from_thread(modal_prog.set_status, f"{name}: {count} messages")
|
|
||||||
|
|
||||||
await self.exporter.export_channel_messages(chan.id, progress_callback=update_msg_count, force=False)
|
|
||||||
await self.exporter.export_threads(chan.id, progress_callback=update_msg_count, force=False)
|
|
||||||
self.app.call_from_thread(modal_prog.write_to_log, f"[green]Synced: {chan.name}[/green]")
|
|
||||||
|
|
||||||
await self.exporter.export_metadata()
|
|
||||||
self.app.call_from_thread(modal_prog.write_to_log, "[bold green]Sync operation complete![/bold green]")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.app.call_from_thread(modal_prog.write_to_log, f"[bold red]Sync failed: {e}[/bold red]")
|
|
||||||
finally:
|
|
||||||
await self.engine.close_connections()
|
|
||||||
self.app.call_from_thread(modal_prog.set_status, "Finished.")
|
|
||||||
self.app.call_from_thread(modal_prog.allow_close)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -114,7 +114,8 @@ class ConfigSelectionScreen(Screen):
|
||||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||||
cfg_name = event.item.name
|
cfg_name = event.item.name
|
||||||
cfg_path = Path(f"Reaper-{cfg_name}") / "config.yaml"
|
cfg_path = Path(f"Reaper-{cfg_name}") / "config.yaml"
|
||||||
self.app.push_screen(ConfigScreen(cfg_name, cfg_path))
|
from src.ui.mode_screen import ModeScreen
|
||||||
|
self.app.push_screen(ModeScreen(cfg_name, cfg_path))
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
if event.button.id == "btn_new_config":
|
if event.button.id == "btn_new_config":
|
||||||
|
|
@ -157,11 +158,12 @@ class ConfigScreen(Screen):
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
ConfigScreen { align: center middle; }
|
ConfigScreen { align: center middle; }
|
||||||
#cfg_scroll { width: 100%; height: 100%; align: center middle; }
|
#cfg_outer { width: 100%; height: 100%; align: center top; }
|
||||||
#cfg_container {
|
#cfg_container {
|
||||||
width: 70; height: auto;
|
width: 80%; height: 100%; layout: vertical;
|
||||||
border: solid green; padding: 1 2; margin: 1 0;
|
border: solid green; padding: 1 2; margin: 2 0;
|
||||||
}
|
}
|
||||||
|
#cfg_scroll { width: 100%; height: 1fr; margin-bottom: 1; }
|
||||||
#cfg_title {
|
#cfg_title {
|
||||||
text-style: bold; color: green; margin-bottom: 1;
|
text-style: bold; color: green; margin-bottom: 1;
|
||||||
content-align: center middle; width: 100%;
|
content-align: center middle; width: 100%;
|
||||||
|
|
@ -175,7 +177,7 @@ class ConfigScreen(Screen):
|
||||||
height: auto; margin: 0 0 0 2;
|
height: auto; margin: 0 0 0 2;
|
||||||
}
|
}
|
||||||
#target_section { height: auto; }
|
#target_section { height: auto; }
|
||||||
#cfg_actions { height: auto; margin-top: 1; }
|
#cfg_actions { height: auto; margin-top: 1; margin-bottom: 0; dock: bottom; }
|
||||||
#cfg_actions Button { width: 1fr; margin: 0 1; }
|
#cfg_actions Button { width: 1fr; margin: 0 1; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -189,78 +191,80 @@ class ConfigScreen(Screen):
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header(show_clock=True)
|
yield Header(show_clock=True)
|
||||||
with VerticalScroll(id="cfg_scroll"):
|
with Container(id="cfg_outer"):
|
||||||
with Container(id="cfg_container"):
|
with Container(id="cfg_container"):
|
||||||
yield Label(f"Configuration — {self.cfg_name}", id="cfg_title")
|
yield Label(f"Configuration — {self.cfg_name}", id="cfg_title")
|
||||||
|
|
||||||
# ── Discord ──────────────────────────────────────────────
|
with VerticalScroll(id="cfg_scroll"):
|
||||||
yield Label("Discord", classes="section_title")
|
# ── Discord ──────────────────────────────────────────────
|
||||||
yield Rule()
|
yield Label("Discord", classes="section_title")
|
||||||
yield Label("Bot Token:", classes="field_label")
|
|
||||||
yield Input(
|
|
||||||
value=self.config.discord_bot_token or "",
|
|
||||||
id="inp_discord_token",
|
|
||||||
password=True,
|
|
||||||
)
|
|
||||||
yield Label("Server ID:", classes="field_label")
|
|
||||||
yield Input(
|
|
||||||
value=self.config.discord_server_id or "",
|
|
||||||
id="inp_discord_server",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Reaper Mode ──────────────────────────────────────────
|
|
||||||
yield Label("Reaper Mode", classes="section_title")
|
|
||||||
yield Rule()
|
|
||||||
cur_mode = self.config.tool_mode or "backup_only"
|
|
||||||
with RadioSet(id="mode_radio"):
|
|
||||||
yield RadioButton(
|
|
||||||
"Shuttle Transfer (direct migration)",
|
|
||||||
id="radio_direct",
|
|
||||||
value=(cur_mode == "direct_transfer"),
|
|
||||||
)
|
|
||||||
yield RadioButton(
|
|
||||||
"Backup & Migrate (backup first, then migrate)",
|
|
||||||
id="radio_backup",
|
|
||||||
value=(cur_mode == "backup_transfer"),
|
|
||||||
)
|
|
||||||
yield RadioButton(
|
|
||||||
"Backup Only (local backup, no migration)",
|
|
||||||
id="radio_bkonly",
|
|
||||||
value=(cur_mode == "backup_only"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Target Platform (hidden for backup_only) ─────────────
|
|
||||||
with Vertical(id="target_section"):
|
|
||||||
yield Label("Target Platform", classes="section_title")
|
|
||||||
yield Rule()
|
|
||||||
cur_plat = self.config.target_platform or "none"
|
|
||||||
with RadioSet(id="plat_radio"):
|
|
||||||
yield RadioButton(
|
|
||||||
"Fluxer",
|
|
||||||
id="radio_fluxer",
|
|
||||||
value=(cur_plat == "fluxer"),
|
|
||||||
)
|
|
||||||
yield RadioButton(
|
|
||||||
"Stoat",
|
|
||||||
id="radio_stoat",
|
|
||||||
value=(cur_plat == "stoat"),
|
|
||||||
)
|
|
||||||
yield Label("Bot Token:", classes="field_label")
|
yield Label("Bot Token:", classes="field_label")
|
||||||
yield Input(
|
yield Input(
|
||||||
value=self.config.target_bot_token or "",
|
value=self.config.discord_bot_token or "",
|
||||||
id="inp_target_token",
|
id="inp_discord_token",
|
||||||
password=True,
|
password=True,
|
||||||
)
|
)
|
||||||
yield Label("Community / Server ID:", classes="field_label")
|
yield Label("Server ID:", classes="field_label")
|
||||||
yield Input(
|
yield Input(
|
||||||
value=self.config.target_server_id or "",
|
value=self.config.discord_server_id or "",
|
||||||
id="inp_target_server",
|
id="inp_discord_server",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Reaper Mode ──────────────────────────────────────────
|
||||||
|
yield Label("Reaper Mode", classes="section_title")
|
||||||
|
cur_mode = self.config.tool_mode or "backup_only"
|
||||||
|
with RadioSet(id="mode_radio"):
|
||||||
|
yield RadioButton(
|
||||||
|
"Shuttle Transfer (direct migration)",
|
||||||
|
id="radio_direct",
|
||||||
|
value=(cur_mode == "direct_transfer"),
|
||||||
|
)
|
||||||
|
yield RadioButton(
|
||||||
|
"Backup & Migrate (backup first, then migrate)",
|
||||||
|
id="radio_backup",
|
||||||
|
value=(cur_mode == "backup_transfer"),
|
||||||
|
)
|
||||||
|
yield RadioButton(
|
||||||
|
"Backup Only (local backup, no migration)",
|
||||||
|
id="radio_bkonly",
|
||||||
|
value=(cur_mode == "backup_only"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Target Platform (hidden for backup_only) ─────────────
|
||||||
|
with Vertical(id="target_section"):
|
||||||
|
yield Label("Target Platform", classes="section_title")
|
||||||
|
cur_plat = self.config.target_platform or "none"
|
||||||
|
with RadioSet(id="plat_radio"):
|
||||||
|
yield RadioButton(
|
||||||
|
"Fluxer",
|
||||||
|
id="radio_fluxer",
|
||||||
|
value=(cur_plat == "fluxer"),
|
||||||
|
)
|
||||||
|
yield RadioButton(
|
||||||
|
"Stoat",
|
||||||
|
id="radio_stoat",
|
||||||
|
value=(cur_plat == "stoat"),
|
||||||
|
)
|
||||||
|
yield Label("Bot Token:", classes="field_label")
|
||||||
|
yield Input(
|
||||||
|
value=self.config.target_bot_token or "",
|
||||||
|
id="inp_target_token",
|
||||||
|
password=True,
|
||||||
|
)
|
||||||
|
yield Label("Community / Server ID:", classes="field_label")
|
||||||
|
yield Input(
|
||||||
|
value=self.config.target_server_id or "",
|
||||||
|
id="inp_target_server",
|
||||||
|
)
|
||||||
|
yield Label("Target API URL:", classes="field_label")
|
||||||
|
yield Input(
|
||||||
|
value=self.config.target_api_url or "default",
|
||||||
|
id="inp_target_api",
|
||||||
|
)
|
||||||
|
|
||||||
yield Rule()
|
yield Rule()
|
||||||
with Horizontal(id="cfg_actions"):
|
with Horizontal(id="cfg_actions"):
|
||||||
yield Button("Save & Start", variant="success", id="btn_start")
|
yield Button("Save Configuration", variant="primary", id="btn_save")
|
||||||
yield Button("Save", variant="primary", id="btn_save")
|
|
||||||
yield Button("Back", id="btn_back")
|
yield Button("Back", id="btn_back")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
|
@ -300,22 +304,14 @@ class ConfigScreen(Screen):
|
||||||
self.config.target_platform = self._get_selected_platform()
|
self.config.target_platform = self._get_selected_platform()
|
||||||
self.config.target_bot_token = self.query_one("#inp_target_token", Input).value.strip() or self.config.target_bot_token
|
self.config.target_bot_token = self.query_one("#inp_target_token", Input).value.strip() or self.config.target_bot_token
|
||||||
self.config.target_server_id = self.query_one("#inp_target_server", Input).value.strip() or self.config.target_server_id
|
self.config.target_server_id = self.query_one("#inp_target_server", Input).value.strip() or self.config.target_server_id
|
||||||
|
self.config.target_api_url = self.query_one("#inp_target_api", Input).value.strip() or self.config.target_api_url
|
||||||
else:
|
else:
|
||||||
self.config.target_platform = "none"
|
self.config.target_platform = "none"
|
||||||
|
|
||||||
save_config(self.config, self.cfg_path)
|
save_config(self.config, self.cfg_path)
|
||||||
|
|
||||||
def _launch_mode(self) -> None:
|
def _launch_mode(self) -> None:
|
||||||
mode = self.config.tool_mode
|
pass # No longer needed
|
||||||
if mode == "direct_transfer":
|
|
||||||
from src.ui.shuttle_screen import ShuttleScreen
|
|
||||||
self.app.push_screen(ShuttleScreen(self.cfg_name, self.cfg_path))
|
|
||||||
elif mode == "backup_transfer":
|
|
||||||
from src.ui.backup_screen import BackupScreen
|
|
||||||
self.app.push_screen(BackupScreen(self.cfg_name, self.cfg_path))
|
|
||||||
else: # backup_only
|
|
||||||
from src.ui.backup_screen import BackupScreen
|
|
||||||
self.app.push_screen(BackupScreen(self.cfg_name, self.cfg_path))
|
|
||||||
|
|
||||||
def action_go_back(self) -> None:
|
def action_go_back(self) -> None:
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
@ -326,9 +322,7 @@ class ConfigScreen(Screen):
|
||||||
elif event.button.id == "btn_save":
|
elif event.button.id == "btn_save":
|
||||||
self._collect_and_save()
|
self._collect_and_save()
|
||||||
self.notify("Configuration saved.", severity="information")
|
self.notify("Configuration saved.", severity="information")
|
||||||
elif event.button.id == "btn_start":
|
self.dismiss(True)
|
||||||
self._collect_and_save()
|
|
||||||
self._launch_mode()
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -336,6 +330,7 @@ class ConfigScreen(Screen):
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ReaperApp(App):
|
class ReaperApp(App):
|
||||||
|
theme = "dracula"
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.push_screen(ConfigSelectionScreen())
|
self.push_screen(ConfigSelectionScreen())
|
||||||
|
|
|
||||||
228
src/ui/modals.py
Normal file
228
src/ui/modals.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
"""
|
||||||
|
Shared modals used by backup and shuttle operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||||
|
from textual.widgets import Button, Label, Input, ProgressBar, RichLog, Rule, RadioButton
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ProgressModal – unified progress / log display
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ProgressModal(ModalScreen[None]):
|
||||||
|
"""Modal to display progress for any long-running operation."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="progress_dialog"):
|
||||||
|
yield Label("Operation Status", id="progress_status")
|
||||||
|
yield ProgressBar(total=None, show_eta=False, id="progress_bar")
|
||||||
|
yield RichLog(id="progress_log", highlight=True, markup=True)
|
||||||
|
yield Button("Close", id="btn_close_progress", disabled=True)
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed):
|
||||||
|
if event.button.id == "btn_close_progress":
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
def write(self, message: str):
|
||||||
|
self.query_one("#progress_log", RichLog).write(message)
|
||||||
|
|
||||||
|
# alias used by backup code
|
||||||
|
write_to_log = write
|
||||||
|
|
||||||
|
def set_status(self, status: str):
|
||||||
|
self.query_one("#progress_status", Label).update(status)
|
||||||
|
|
||||||
|
def set_progress(self, current: int, total: int):
|
||||||
|
bar = self.query_one("#progress_bar", ProgressBar)
|
||||||
|
bar.update(total=total, progress=current)
|
||||||
|
|
||||||
|
def allow_close(self):
|
||||||
|
btn = self.query_one("#btn_close_progress", Button)
|
||||||
|
btn.disabled = False
|
||||||
|
btn.variant = "success"
|
||||||
|
self.query_one("#progress_bar", ProgressBar).update(total=100, progress=100)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ConfirmModal – simple yes / no
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ConfirmModal(ModalScreen[bool]):
|
||||||
|
"""Simple Yes / No confirmation modal."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, danger: bool = False):
|
||||||
|
super().__init__()
|
||||||
|
self._message = message
|
||||||
|
self._danger = danger
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="confirm_dialog"):
|
||||||
|
yield Label(self._message, id="confirm_msg")
|
||||||
|
with Horizontal(id="confirm_buttons"):
|
||||||
|
yield Button("Confirm", variant="error" if self._danger else "success", id="btn_yes")
|
||||||
|
yield Button("Cancel", variant="primary", id="btn_no")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed):
|
||||||
|
self.dismiss(event.button.id == "btn_yes")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SubMenuModal – generic labelled-button list
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SubMenuModal(ModalScreen[str]):
|
||||||
|
"""A generic sub-menu modal that presents a list of labelled buttons."""
|
||||||
|
|
||||||
|
def __init__(self, title: str, options: list[tuple[str, str, str]]):
|
||||||
|
"""options: list of (button_id, label, variant)"""
|
||||||
|
super().__init__()
|
||||||
|
self._title = title
|
||||||
|
self._options = options
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="submenu_dialog"):
|
||||||
|
yield Label(self._title, id="submenu_title")
|
||||||
|
for btn_id, label, variant in self._options:
|
||||||
|
yield Button(label, id=btn_id, variant=variant)
|
||||||
|
yield Rule()
|
||||||
|
yield Button("Cancel", id="btn_cancel_sub")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed):
|
||||||
|
if event.button.id == "btn_cancel_sub":
|
||||||
|
self.dismiss(None)
|
||||||
|
else:
|
||||||
|
self.dismiss(event.button.id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ChannelPickerModal – single-channel selection (shuttle)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ChannelPickerModal(ModalScreen[int]):
|
||||||
|
"""Modal listing Discord channels for single-channel selection."""
|
||||||
|
|
||||||
|
def __init__(self, channels: list, categories: dict, label: str = "Select Channel"):
|
||||||
|
super().__init__()
|
||||||
|
self._channels = channels
|
||||||
|
self._categories = categories
|
||||||
|
self._label = label
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="chanpick_dialog"):
|
||||||
|
yield Label(self._label, id="chanpick_title")
|
||||||
|
with VerticalScroll(id="chanpick_scroll"):
|
||||||
|
cat_grouped: dict[int | None, list] = {}
|
||||||
|
for c in self._channels:
|
||||||
|
cat_id = getattr(c, "category_id", None) if not isinstance(c, dict) else c.get("parent_id")
|
||||||
|
cat_grouped.setdefault(cat_id, []).append(c)
|
||||||
|
|
||||||
|
for cat_id in sorted(cat_grouped, key=lambda k: self._categories.get(k, "") if k else ""):
|
||||||
|
if cat_id is not None and cat_id in self._categories:
|
||||||
|
yield Label(f"[cyan]{self._categories[cat_id]}[/cyan]", classes="category_header")
|
||||||
|
for c in cat_grouped[cat_id]:
|
||||||
|
if isinstance(c, dict):
|
||||||
|
name = c.get("name", "Unnamed")
|
||||||
|
cid = c.get("id")
|
||||||
|
else:
|
||||||
|
name = c.name
|
||||||
|
cid = c.id
|
||||||
|
yield RadioButton(name, value=False, id=f"chpk_{cid}")
|
||||||
|
|
||||||
|
with Horizontal(id="chanpick_buttons"):
|
||||||
|
yield Button("Select", variant="success", id="btn_pick_ok")
|
||||||
|
yield Button("Cancel", id="btn_pick_cancel")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed):
|
||||||
|
if event.button.id == "btn_pick_cancel":
|
||||||
|
self.dismiss(None)
|
||||||
|
elif event.button.id == "btn_pick_ok":
|
||||||
|
for rb in self.query(RadioButton):
|
||||||
|
if rb.value and rb.id and rb.id.startswith("chpk_"):
|
||||||
|
self.dismiss(int(rb.id.split("_", 1)[1]))
|
||||||
|
return
|
||||||
|
# Nothing selected
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ChannelSelectModal – multi-channel selection with sync/force (backup)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ChannelSelectModal(ModalScreen[dict]):
|
||||||
|
"""Modal for selecting channels using a simple checkbox list."""
|
||||||
|
|
||||||
|
def __init__(self, channels: list, categories: dict, backed_up_ids: set, any_found: bool):
|
||||||
|
super().__init__()
|
||||||
|
# Group channels by category
|
||||||
|
self.channels_by_category = {}
|
||||||
|
for c in channels:
|
||||||
|
cat_id = getattr(c, 'category_id', None)
|
||||||
|
if cat_id not in self.channels_by_category:
|
||||||
|
self.channels_by_category[cat_id] = []
|
||||||
|
self.channels_by_category[cat_id].append(c)
|
||||||
|
|
||||||
|
self.categories = categories # id -> name
|
||||||
|
self.backed_up_ids = backed_up_ids
|
||||||
|
self.any_found = any_found
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="channel_dialog"):
|
||||||
|
yield Label("Select Channels to Backup", id="chan_title")
|
||||||
|
|
||||||
|
with VerticalScroll(id="channel_list_scroll"):
|
||||||
|
cat_ids = sorted(
|
||||||
|
[k for k in self.channels_by_category.keys() if k is not None],
|
||||||
|
key=lambda k: self.categories.get(k, ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# No category channels
|
||||||
|
if None in self.channels_by_category:
|
||||||
|
for c in sorted(self.channels_by_category[None], key=lambda x: x.position if hasattr(x, 'position') else 0):
|
||||||
|
label = f"{c.name}"
|
||||||
|
color = "green" if c.id in self.backed_up_ids else "white"
|
||||||
|
yield RadioButton(f"[{color}]{label}[/]", value=False, id=f"chan_{c.id}")
|
||||||
|
|
||||||
|
for cat_id in cat_ids:
|
||||||
|
cat_name = self.categories.get(cat_id, "Unknown Category")
|
||||||
|
yield Label(f"[cyan]{cat_name}[/cyan]", classes="category_header")
|
||||||
|
for c in sorted(self.channels_by_category[cat_id], key=lambda x: x.position if hasattr(x, 'position') else 0):
|
||||||
|
label = f"{c.name}"
|
||||||
|
color = "green" if c.id in self.backed_up_ids else "white"
|
||||||
|
yield RadioButton(f"[{color}]{label}[/]", value=False, id=f"chan_{c.id}")
|
||||||
|
|
||||||
|
with Horizontal(id="select_all_buttons"):
|
||||||
|
yield Button("Select All", id="btn_all")
|
||||||
|
yield Button("Deselect All", id="btn_none")
|
||||||
|
|
||||||
|
with Horizontal(id="confirm_buttons"):
|
||||||
|
if self.any_found:
|
||||||
|
yield Label("Existing backups found:", classes="label_warning")
|
||||||
|
yield Button("Sync", variant="success", id="btn_sync")
|
||||||
|
yield Button("Force Overwrite", variant="error", id="btn_force")
|
||||||
|
else:
|
||||||
|
yield Button("Backup", variant="success", id="btn_backup")
|
||||||
|
yield Button("Cancel", id="btn_cancel_chan")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn_all":
|
||||||
|
for cb in self.query(RadioButton):
|
||||||
|
cb.value = True
|
||||||
|
elif event.button.id == "btn_none":
|
||||||
|
for cb in self.query(RadioButton):
|
||||||
|
cb.value = False
|
||||||
|
elif event.button.id in ["btn_sync", "btn_force", "btn_backup"]:
|
||||||
|
selected = []
|
||||||
|
for cb in self.query(RadioButton):
|
||||||
|
if cb.value and cb.id and cb.id.startswith("chan_"):
|
||||||
|
chan_id = int(cb.id.split("_")[1])
|
||||||
|
selected.append(chan_id)
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
|
||||||
|
force = event.button.id == "btn_force"
|
||||||
|
self.dismiss({"channels": selected, "force": force})
|
||||||
|
elif event.button.id == "btn_cancel_chan":
|
||||||
|
self.dismiss(None)
|
||||||
202
src/ui/mode_screen.py
Normal file
202
src/ui/mode_screen.py
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
"""
|
||||||
|
ModeScreen – the single unified screen used for all tool_mode values.
|
||||||
|
Shows the appropriate pane(s) based on the mode:
|
||||||
|
- backup_only: Backup pane only
|
||||||
|
- direct_transfer: Migrate pane only
|
||||||
|
- backup_transfer: Both panes with a toggle button to switch between them
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container, Horizontal, VerticalScroll
|
||||||
|
from textual.widgets import Header, Footer, Button, ContentSwitcher, Rule
|
||||||
|
from textual.screen import Screen
|
||||||
|
|
||||||
|
from src.core.configuration import load_config
|
||||||
|
from src.ui.backup_ops import BackupPane
|
||||||
|
from src.ui.shuttle_ops import ShuttlePane
|
||||||
|
|
||||||
|
|
||||||
|
class ModeScreen(Screen):
|
||||||
|
"""Unified mode screen — adapts content based on tool_mode."""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
#main_scroll {
|
||||||
|
align: center top;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#main_container {
|
||||||
|
width: 80%;
|
||||||
|
height: 100%;
|
||||||
|
layout: vertical;
|
||||||
|
min-height: 20;
|
||||||
|
border: solid green;
|
||||||
|
padding: 1 2;
|
||||||
|
margin: 2 0;
|
||||||
|
}
|
||||||
|
#switcher {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
#bottom_actions {
|
||||||
|
height: auto;
|
||||||
|
dock: bottom;
|
||||||
|
margin: 1 1 0 1;
|
||||||
|
}
|
||||||
|
#bottom_actions Button, #btn_switch {
|
||||||
|
width: 1fr;
|
||||||
|
margin: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styling (shared across both panes) */
|
||||||
|
#progress_dialog {
|
||||||
|
width: 80%;
|
||||||
|
height: 80%;
|
||||||
|
border: thick $background 80%;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
#progress_status { text-style: bold; margin-bottom: 1; }
|
||||||
|
#progress_bar { margin-bottom: 1; }
|
||||||
|
#progress_log { height: 1fr; margin-bottom: 1; border: solid $primary; }
|
||||||
|
|
||||||
|
#shuttle_config_dialog, #platform_select_dialog, #submenu_dialog, #confirm_dialog {
|
||||||
|
width: 60%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 80%;
|
||||||
|
border: thick $background 80%;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
#chanpick_dialog {
|
||||||
|
width: 70%;
|
||||||
|
height: 75%;
|
||||||
|
border: thick $background 80%;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
#chanpick_scroll {
|
||||||
|
height: 1fr;
|
||||||
|
border: solid $primary;
|
||||||
|
margin-bottom: 1;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
.category_header {
|
||||||
|
margin-top: 1;
|
||||||
|
background: $primary 10%;
|
||||||
|
text-style: bold;
|
||||||
|
padding-left: 1;
|
||||||
|
}
|
||||||
|
#config_title, #platform_title, #submenu_title, #confirm_msg, #chanpick_title, #chan_title {
|
||||||
|
text-style: bold; margin-bottom: 1;
|
||||||
|
}
|
||||||
|
#config_buttons, #confirm_buttons, #chanpick_buttons {
|
||||||
|
height: auto; margin-top: 1;
|
||||||
|
}
|
||||||
|
#config_buttons Button, #confirm_buttons Button, #chanpick_buttons Button {
|
||||||
|
width: 1fr; margin: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#channel_dialog {
|
||||||
|
width: 80%;
|
||||||
|
height: 80%;
|
||||||
|
border: thick $background 80%;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
#channel_list_scroll {
|
||||||
|
height: 1fr;
|
||||||
|
border: solid $primary;
|
||||||
|
margin-bottom: 1;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
#select_all_buttons { height: auto; margin-bottom: 1; }
|
||||||
|
#select_all_buttons Button { width: auto; margin-right: 1; }
|
||||||
|
|
||||||
|
RadioButton:focus { background: transparent; border: none; }
|
||||||
|
RadioButton > .radio-button--label { padding: 0 1; }
|
||||||
|
RadioButton:focus > .radio-button--label { background: transparent; text-style: none; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("q", "app.exit", "Quit"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, cfg_name: str, cfg_path: Path, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.cfg_name = cfg_name
|
||||||
|
self.config_path = cfg_path
|
||||||
|
self.config = load_config(cfg_path)
|
||||||
|
self._showing_backup = True # track which pane is visible in combined mode
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header(show_clock=True)
|
||||||
|
|
||||||
|
mode = self.config.tool_mode or "backup_only"
|
||||||
|
|
||||||
|
with VerticalScroll(id="main_scroll"):
|
||||||
|
with Container(id="main_container"):
|
||||||
|
if mode == "backup_only":
|
||||||
|
yield BackupPane(self.cfg_name, self.config_path, id="pane_backup")
|
||||||
|
elif mode == "direct_transfer":
|
||||||
|
yield ShuttlePane(self.cfg_name, self.config_path, id="pane_migrate")
|
||||||
|
else: # backup_transfer
|
||||||
|
with ContentSwitcher(initial="pane_backup", id="switcher"):
|
||||||
|
yield BackupPane(self.cfg_name, self.config_path, id="pane_backup")
|
||||||
|
yield ShuttlePane(self.cfg_name, self.config_path, id="pane_migrate")
|
||||||
|
|
||||||
|
yield Rule()
|
||||||
|
with Horizontal(id="bottom_actions"):
|
||||||
|
if mode == "backup_transfer":
|
||||||
|
yield Button("Switch to Migrate ⇄", id="btn_switch", variant="primary")
|
||||||
|
yield Button("Configuration", id="btn_config")
|
||||||
|
yield Button("Exit", id="btn_exit", variant="error")
|
||||||
|
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
bid = event.button.id
|
||||||
|
if bid == "btn_exit":
|
||||||
|
self.app.exit()
|
||||||
|
elif bid == "btn_config":
|
||||||
|
from src.ui.main_app import ConfigScreen
|
||||||
|
def reload_screen(saved: bool = False):
|
||||||
|
if saved:
|
||||||
|
self.app.pop_screen()
|
||||||
|
from src.ui.mode_screen import ModeScreen
|
||||||
|
self.app.push_screen(ModeScreen(self.cfg_name, self.config_path))
|
||||||
|
self.app.push_screen(ConfigScreen(self.cfg_name, self.config_path), reload_screen)
|
||||||
|
elif bid == "btn_switch":
|
||||||
|
self._toggle_pane()
|
||||||
|
|
||||||
|
def _toggle_pane(self) -> None:
|
||||||
|
"""Switch between Backup and Migrate panes in backup_transfer mode."""
|
||||||
|
switcher = self.query_one("#switcher", ContentSwitcher)
|
||||||
|
btn = self.query_one("#btn_switch", Button)
|
||||||
|
if self._showing_backup:
|
||||||
|
switcher.current = "pane_migrate"
|
||||||
|
btn.label = "Switch to Backup ⇄"
|
||||||
|
self._showing_backup = False
|
||||||
|
else:
|
||||||
|
switcher.current = "pane_backup"
|
||||||
|
btn.label = "Switch to Migrate ⇄"
|
||||||
|
self._showing_backup = True
|
||||||
|
|
||||||
|
def on_screen_resume(self) -> None:
|
||||||
|
"""Reload config when returning from ConfigScreen.
|
||||||
|
If the mode changed, pop back to ConfigSelectionScreen so the user
|
||||||
|
re-enters with the correct layout.
|
||||||
|
"""
|
||||||
|
old_mode = self.config.tool_mode
|
||||||
|
self.config = load_config(self.config_path)
|
||||||
|
new_mode = self.config.tool_mode
|
||||||
|
|
||||||
|
if new_mode != old_mode:
|
||||||
|
self.app.pop_screen()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Propagate config reload to panes
|
||||||
|
for pane in self.query(BackupPane):
|
||||||
|
pane.reload_config()
|
||||||
|
for pane in self.query(ShuttlePane):
|
||||||
|
pane.reload_config()
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
"""
|
"""
|
||||||
Shuttle Screen – native Textual TUI for direct server-to-server migration.
|
ShuttlePane – self-contained shuttle (migration) operations widget.
|
||||||
Ports every operation from the old Rich CLI (MigrationCLI) into Textual
|
Embedded inside ModeScreen's "Migrate" tab.
|
||||||
Screens, Modals, and Workers.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import discord
|
import discord
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -13,20 +11,17 @@ import time
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
from textual.containers import Container, Vertical, VerticalScroll
|
||||||
from textual.widgets import (
|
from textual.widgets import Button, Label, Rule
|
||||||
Header, Footer, Button, Static, Label, Input,
|
|
||||||
Checkbox, RadioButton, ProgressBar, RichLog, Rule,
|
|
||||||
ListItem, ListView, RadioSet,
|
|
||||||
)
|
|
||||||
from textual.screen import Screen, ModalScreen
|
|
||||||
from textual import work
|
from textual import work
|
||||||
from textual.worker import Worker, WorkerState
|
|
||||||
|
|
||||||
from src.core.configuration import load_config, save_config
|
from src.core.configuration import load_config
|
||||||
from src.core.base import MigrationContext
|
from src.core.base import MigrationContext
|
||||||
from src.core.audit import log_audit_event
|
from src.core.audit import log_audit_event
|
||||||
|
from src.ui.modals import (
|
||||||
|
ProgressModal, ConfirmModal, SubMenuModal, ChannelPickerModal,
|
||||||
|
)
|
||||||
|
|
||||||
import src.fluxer.roles_permissions as fluxer_roles
|
import src.fluxer.roles_permissions as fluxer_roles
|
||||||
import src.stoat.roles_permissions as stoat_roles
|
import src.stoat.roles_permissions as stoat_roles
|
||||||
|
|
@ -52,329 +47,81 @@ class RateLimitHandler(logging.Handler):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def emit(self, record):
|
def emit(self, record):
|
||||||
try:
|
global global_rate_limit_msg, global_rate_limit_expires
|
||||||
msg = record.getMessage()
|
msg = record.getMessage()
|
||||||
if "retry" in msg.lower() and ("rate limit" in msg.lower() or "429" in msg):
|
if "rate" in msg.lower() and "limit" in msg.lower():
|
||||||
match = re.search(r"in ([\d.]+)\s*(?:seconds?|s)", msg, re.IGNORECASE)
|
global_rate_limit_msg = msg
|
||||||
if match:
|
try:
|
||||||
seconds = match.group(1)
|
parts = msg.split()
|
||||||
platform = "API"
|
for i, p in enumerate(parts):
|
||||||
if "discord" in record.name.lower():
|
if p.lower() in ("retry_after", "retry"):
|
||||||
platform = "Discord"
|
secs = float(parts[i + 1].strip("s,."))
|
||||||
elif "fluxer" in record.name.lower():
|
global_rate_limit_expires = time.time() + secs
|
||||||
platform = "Fluxer"
|
break
|
||||||
elif "stoat" in record.name.lower():
|
if p.lower() == "after":
|
||||||
platform = "Stoat"
|
secs = float(parts[i + 1].strip("s,."))
|
||||||
global global_rate_limit_msg, global_rate_limit_expires
|
global_rate_limit_expires = time.time() + secs
|
||||||
global_rate_limit_msg = f"{platform} rate limit {seconds}s"
|
break
|
||||||
try:
|
else:
|
||||||
global_rate_limit_expires = time.time() + float(seconds)
|
for p in parts:
|
||||||
except ValueError:
|
try:
|
||||||
pass
|
secs = float(p.strip("s,."))
|
||||||
except Exception:
|
if 0 < secs < 3600:
|
||||||
pass
|
global_rate_limit_expires = time.time() + secs
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
class ShuttlePane(Container):
|
||||||
# Shared modals
|
"""Shuttle (migration) operations pane — clone, roles, emojis, metadata, messages, danger zone."""
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ProgressModal(ModalScreen[None]):
|
DEFAULT_CSS = """
|
||||||
"""Modal to display progress for any long-running operation."""
|
ShuttlePane { height: auto; width: 100%; }
|
||||||
|
ShuttlePane #sp_info {
|
||||||
def compose(self) -> ComposeResult:
|
height: auto; border: tall cyan; padding: 1; margin-bottom: 1;
|
||||||
with Vertical(id="progress_dialog"):
|
|
||||||
yield Label("Operation Status", id="progress_status")
|
|
||||||
yield ProgressBar(total=None, show_eta=False, id="progress_bar")
|
|
||||||
yield RichLog(id="progress_log", highlight=True, markup=True)
|
|
||||||
yield Button("Close", id="btn_close_progress", disabled=True)
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed):
|
|
||||||
if event.button.id == "btn_close_progress":
|
|
||||||
self.dismiss(None)
|
|
||||||
|
|
||||||
def write(self, message: str):
|
|
||||||
self.query_one("#progress_log", RichLog).write(message)
|
|
||||||
|
|
||||||
def set_status(self, status: str):
|
|
||||||
self.query_one("#progress_status", Label).update(status)
|
|
||||||
|
|
||||||
def set_progress(self, current: int, total: int):
|
|
||||||
bar = self.query_one("#progress_bar", ProgressBar)
|
|
||||||
bar.update(total=total, progress=current)
|
|
||||||
|
|
||||||
def allow_close(self):
|
|
||||||
btn = self.query_one("#btn_close_progress", Button)
|
|
||||||
btn.disabled = False
|
|
||||||
btn.variant = "success"
|
|
||||||
self.query_one("#progress_bar", ProgressBar).update(total=100, progress=100)
|
|
||||||
|
|
||||||
|
|
||||||
class ShuttleConfigModal(ModalScreen[dict]):
|
|
||||||
"""Modal for editing all shuttle-mode tokens & IDs."""
|
|
||||||
|
|
||||||
def __init__(self, config, target_platform: str):
|
|
||||||
super().__init__()
|
|
||||||
self.config = config
|
|
||||||
self.target_platform = target_platform
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Vertical(id="shuttle_config_dialog"):
|
|
||||||
yield Label("Shuttle Configuration", id="config_title")
|
|
||||||
yield Label("Discord Bot Token:")
|
|
||||||
yield Input(value=self.config.discord_bot_token or "", id="inp_d_token")
|
|
||||||
yield Label("Discord Server ID:")
|
|
||||||
yield Input(value=self.config.discord_server_id or "", id="inp_d_server")
|
|
||||||
|
|
||||||
plat_label = "Fluxer" if self.target_platform == "fluxer" else "Stoat"
|
|
||||||
yield Label(f"{plat_label} Bot Token:")
|
|
||||||
yield Input(value=self.config.target_bot_token or "", id="inp_t_token")
|
|
||||||
yield Label(f"{plat_label} Server/Community ID:")
|
|
||||||
yield Input(value=self.config.target_server_id or "", id="inp_t_server")
|
|
||||||
|
|
||||||
with Horizontal(id="config_buttons"):
|
|
||||||
yield Button("Save", variant="success", id="btn_save")
|
|
||||||
yield Button("Cancel", variant="primary", id="btn_cancel")
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed):
|
|
||||||
if event.button.id == "btn_save":
|
|
||||||
self.dismiss({
|
|
||||||
"d_token": self.query_one("#inp_d_token", Input).value,
|
|
||||||
"d_server": self.query_one("#inp_d_server", Input).value,
|
|
||||||
"t_token": self.query_one("#inp_t_token", Input).value,
|
|
||||||
"t_server": self.query_one("#inp_t_server", Input).value,
|
|
||||||
})
|
|
||||||
elif event.button.id == "btn_cancel":
|
|
||||||
self.dismiss(None)
|
|
||||||
|
|
||||||
|
|
||||||
class PlatformSelectModal(ModalScreen[str]):
|
|
||||||
"""Modal for selecting target platform (fluxer / stoat)."""
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Vertical(id="platform_select_dialog"):
|
|
||||||
yield Label("Select Target Platform", id="platform_title")
|
|
||||||
yield Button("Fluxer", variant="primary", id="btn_fluxer")
|
|
||||||
yield Button("Stoat", variant="warning", id="btn_stoat")
|
|
||||||
yield Rule()
|
|
||||||
yield Button("Cancel", id="btn_cancel_platform")
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed):
|
|
||||||
if event.button.id == "btn_fluxer":
|
|
||||||
self.dismiss("fluxer")
|
|
||||||
elif event.button.id == "btn_stoat":
|
|
||||||
self.dismiss("stoat")
|
|
||||||
elif event.button.id == "btn_cancel_platform":
|
|
||||||
self.dismiss(None)
|
|
||||||
|
|
||||||
|
|
||||||
class SubMenuModal(ModalScreen[str]):
|
|
||||||
"""A generic sub-menu modal that presents a list of labelled buttons."""
|
|
||||||
|
|
||||||
def __init__(self, title: str, options: list[tuple[str, str, str]]):
|
|
||||||
"""options: list of (button_id, label, variant)"""
|
|
||||||
super().__init__()
|
|
||||||
self._title = title
|
|
||||||
self._options = options
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Vertical(id="submenu_dialog"):
|
|
||||||
yield Label(self._title, id="submenu_title")
|
|
||||||
for btn_id, label, variant in self._options:
|
|
||||||
yield Button(label, id=btn_id, variant=variant)
|
|
||||||
yield Rule()
|
|
||||||
yield Button("Cancel", id="btn_cancel_sub")
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed):
|
|
||||||
if event.button.id == "btn_cancel_sub":
|
|
||||||
self.dismiss(None)
|
|
||||||
else:
|
|
||||||
self.dismiss(event.button.id)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfirmModal(ModalScreen[bool]):
|
|
||||||
"""Simple Yes / No confirmation modal."""
|
|
||||||
|
|
||||||
def __init__(self, message: str, danger: bool = False):
|
|
||||||
super().__init__()
|
|
||||||
self._message = message
|
|
||||||
self._danger = danger
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Vertical(id="confirm_dialog"):
|
|
||||||
yield Label(self._message, id="confirm_msg")
|
|
||||||
with Horizontal(id="confirm_buttons"):
|
|
||||||
yield Button("Confirm", variant="error" if self._danger else "success", id="btn_yes")
|
|
||||||
yield Button("Cancel", variant="primary", id="btn_no")
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed):
|
|
||||||
self.dismiss(event.button.id == "btn_yes")
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelPickerModal(ModalScreen[int]):
|
|
||||||
"""Modal listing Discord channels for single-channel selection."""
|
|
||||||
|
|
||||||
def __init__(self, channels: list, categories: dict, label: str = "Select Channel"):
|
|
||||||
super().__init__()
|
|
||||||
self._channels = channels
|
|
||||||
self._categories = categories
|
|
||||||
self._label = label
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Vertical(id="chanpick_dialog"):
|
|
||||||
yield Label(self._label, id="chanpick_title")
|
|
||||||
with VerticalScroll(id="chanpick_scroll"):
|
|
||||||
cat_grouped: dict[int | None, list] = {}
|
|
||||||
for c in self._channels:
|
|
||||||
cat_id = getattr(c, "category_id", None) if not isinstance(c, dict) else c.get("parent_id")
|
|
||||||
cat_grouped.setdefault(cat_id, []).append(c)
|
|
||||||
|
|
||||||
for cat_id in sorted(cat_grouped, key=lambda k: self._categories.get(k, "") if k else ""):
|
|
||||||
if cat_id is not None and cat_id in self._categories:
|
|
||||||
yield Label(f"[cyan]{self._categories[cat_id]}[/cyan]", classes="category_header")
|
|
||||||
for c in cat_grouped[cat_id]:
|
|
||||||
if isinstance(c, dict):
|
|
||||||
name = c.get("name", "Unnamed")
|
|
||||||
cid = c.get("id")
|
|
||||||
else:
|
|
||||||
name = c.name
|
|
||||||
cid = c.id
|
|
||||||
yield RadioButton(name, value=False, id=f"chpk_{cid}")
|
|
||||||
|
|
||||||
with Horizontal(id="chanpick_buttons"):
|
|
||||||
yield Button("Select", variant="success", id="btn_pick_ok")
|
|
||||||
yield Button("Cancel", id="btn_pick_cancel")
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed):
|
|
||||||
if event.button.id == "btn_pick_cancel":
|
|
||||||
self.dismiss(None)
|
|
||||||
elif event.button.id == "btn_pick_ok":
|
|
||||||
for rb in self.query(RadioButton):
|
|
||||||
if rb.value and rb.id and rb.id.startswith("chpk_"):
|
|
||||||
self.dismiss(int(rb.id.split("_", 1)[1]))
|
|
||||||
return
|
|
||||||
# Nothing selected
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ShuttleScreen — main screen for Shuttle Mode
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ShuttleScreen(Screen):
|
|
||||||
"""Native Textual screen for Shuttle (direct migration) mode."""
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
#shuttle_scroll {
|
|
||||||
align: center middle;
|
|
||||||
}
|
}
|
||||||
|
ShuttlePane #sp_actions { height: auto; }
|
||||||
#shuttle_container {
|
ShuttlePane #sp_actions Button { width: 100%; margin-bottom: 1; }
|
||||||
width: 80%;
|
|
||||||
height: auto;
|
|
||||||
min-height: 25;
|
|
||||||
border: solid #4641D9;
|
|
||||||
padding: 1 2;
|
|
||||||
margin: 2 0;
|
|
||||||
}
|
|
||||||
#shuttle_title {
|
|
||||||
text-style: bold;
|
|
||||||
color: #4641D9;
|
|
||||||
margin-bottom: 1;
|
|
||||||
content-align: center middle;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
#shuttle_info {
|
|
||||||
height: auto;
|
|
||||||
margin-bottom: 2;
|
|
||||||
border: tall cyan;
|
|
||||||
padding: 1;
|
|
||||||
}
|
|
||||||
#shuttle_actions {
|
|
||||||
height: auto;
|
|
||||||
layout: vertical;
|
|
||||||
align: center top;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
#shuttle_actions Button {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
/* Modals */
|
|
||||||
#shuttle_config_dialog, #platform_select_dialog, #submenu_dialog, #confirm_dialog {
|
|
||||||
width: 60%;
|
|
||||||
height: auto;
|
|
||||||
max-height: 80%;
|
|
||||||
border: thick $background 80%;
|
|
||||||
background: $surface;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
#progress_dialog {
|
|
||||||
width: 80%;
|
|
||||||
height: 80%;
|
|
||||||
border: thick $background 80%;
|
|
||||||
background: $surface;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
#chanpick_dialog {
|
|
||||||
width: 70%;
|
|
||||||
height: 75%;
|
|
||||||
border: thick $background 80%;
|
|
||||||
background: $surface;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
#chanpick_scroll {
|
|
||||||
height: 1fr;
|
|
||||||
border: solid $primary;
|
|
||||||
margin-bottom: 1;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
.category_header {
|
|
||||||
margin-top: 1;
|
|
||||||
background: $primary 10%;
|
|
||||||
text-style: bold;
|
|
||||||
padding-left: 1;
|
|
||||||
}
|
|
||||||
#config_title, #platform_title, #submenu_title, #confirm_msg, #chanpick_title {
|
|
||||||
text-style: bold; margin-bottom: 1;
|
|
||||||
}
|
|
||||||
#config_buttons, #confirm_buttons, #chanpick_buttons {
|
|
||||||
height: auto; margin-top: 1;
|
|
||||||
}
|
|
||||||
#config_buttons Button, #confirm_buttons Button, #chanpick_buttons Button {
|
|
||||||
width: 1fr; margin: 0 1;
|
|
||||||
}
|
|
||||||
#progress_status { text-style: bold; margin-bottom: 1; }
|
|
||||||
#progress_bar { margin-bottom: 1; }
|
|
||||||
#progress_log { height: 1fr; margin-bottom: 1; border: solid $primary; }
|
|
||||||
RadioButton:focus { background: transparent; border: none; }
|
|
||||||
RadioButton > .radio-button--label { padding: 0 1; }
|
|
||||||
RadioButton:focus > .radio-button--label { background: transparent; text-style: none; }
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("q", "app.exit", "Quit"),
|
|
||||||
("b", "go_back", "Back"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, cfg_name: str, cfg_path: Path, *args, **kwargs):
|
def __init__(self, cfg_name: str, cfg_path: Path, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.cfg_name = cfg_name
|
self.cfg_name = cfg_name
|
||||||
self.config_path = cfg_path
|
self.config_path = cfg_path
|
||||||
self.config = load_config(self.config_path)
|
self.config = load_config(cfg_path)
|
||||||
self.target_platform: str | None = None
|
self.target_platform = self.config.target_platform or "fluxer"
|
||||||
self.engine: MigrationContext | None = None
|
self.engine: MigrationContext | None = None
|
||||||
self.validation_results: dict = {}
|
self.validation_results: dict = {}
|
||||||
self.tokens_valid = False
|
self.tokens_valid = False
|
||||||
self.permissions_complete = False
|
self.permissions_complete = False
|
||||||
|
|
||||||
# Register rate-limit handler
|
def compose(self) -> ComposeResult:
|
||||||
rl = RateLimitHandler()
|
with VerticalScroll():
|
||||||
logging.getLogger("discord").addHandler(rl)
|
with Vertical(id="sp_info"):
|
||||||
logging.getLogger("fluxer").addHandler(rl)
|
yield Label("Discord: [yellow]Loading...[/yellow]", id="sp_lbl_discord")
|
||||||
logging.getLogger("stoat").addHandler(rl)
|
yield Label("Target: [yellow]Loading...[/yellow]", id="sp_lbl_target")
|
||||||
|
yield Label("Status: [yellow]Validating...[/yellow]", id="sp_lbl_status")
|
||||||
|
with Vertical(id="sp_actions"):
|
||||||
|
yield Button("Clone Server Template", id="sp_clone", disabled=True)
|
||||||
|
yield Button("Copy Roles & Permissions", id="sp_roles", disabled=True)
|
||||||
|
yield Button("Copy Emojis & Stickers", id="sp_emojis", disabled=True)
|
||||||
|
yield Button("Sync Server Profile", id="sp_metadata", disabled=True)
|
||||||
|
yield Button("Migrate Message History", id="sp_messages", disabled=True)
|
||||||
|
yield Rule()
|
||||||
|
yield Button("Danger Zone ⚠", id="sp_danger", variant="error", disabled=True)
|
||||||
|
|
||||||
# ── helpers ──────────────────────────────────────────────────────────
|
def on_mount(self) -> None:
|
||||||
|
self._rebuild_engine()
|
||||||
|
self.run_validate()
|
||||||
|
|
||||||
|
def reload_config(self) -> None:
|
||||||
|
self.config = load_config(self.config_path)
|
||||||
|
self.target_platform = self.config.target_platform or "fluxer"
|
||||||
|
self._rebuild_engine()
|
||||||
|
self.run_validate()
|
||||||
|
|
||||||
def _base_dir(self) -> str:
|
def _base_dir(self) -> str:
|
||||||
return f"Reaper-{self.cfg_name}"
|
return f"Reaper-{self.cfg_name}"
|
||||||
|
|
@ -382,89 +129,51 @@ class ShuttleScreen(Screen):
|
||||||
def _rebuild_engine(self):
|
def _rebuild_engine(self):
|
||||||
self.engine = MigrationContext(self.config, self.target_platform)
|
self.engine = MigrationContext(self.config, self.target_platform)
|
||||||
|
|
||||||
|
# ── labels ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _update_info_labels(self):
|
def _update_info_labels(self):
|
||||||
d_name = self.validation_results.get("discord_server_name")
|
v = self.validation_results
|
||||||
d_disp = f"[green]\"{d_name}\"[/green]" if d_name else "[red]NOT SET UP[/red]"
|
|
||||||
self.query_one("#lbl_discord", Label).update(f"Discord: {d_disp}")
|
|
||||||
|
|
||||||
if self.target_platform == "fluxer":
|
# Discord
|
||||||
t_name = self.validation_results.get("target_community_name")
|
d_name = v.get("discord_server_name")
|
||||||
t_disp = f"[green]\"{t_name}\"[/green]" if t_name else "[red]NOT SET UP[/red]"
|
d_bot = v.get("discord_bot_name")
|
||||||
self.query_one("#lbl_target", Label).update(f"Fluxer: {t_disp}")
|
if v.get("discord_timeout"):
|
||||||
|
d_disp = "[red]TIMEOUT[/red]"
|
||||||
|
elif d_name and v.get("discord_token") and v.get("discord_server"):
|
||||||
|
d_disp = f'[green]"{d_name}"[/green] Bot: [green]{d_bot}[/green]'
|
||||||
|
elif v.get("discord_token") is False:
|
||||||
|
d_disp = "[red]INVALID TOKEN[/red]"
|
||||||
else:
|
else:
|
||||||
t_name = self.validation_results.get("target_community_name")
|
d_disp = "[red]NOT SET UP[/red]"
|
||||||
t_disp = f"[green]\"{t_name}\"[/green]" if t_name else "[red]NOT SET UP[/red]"
|
self.query_one("#sp_lbl_discord", Label).update(f"Discord: {d_disp}")
|
||||||
self.query_one("#lbl_target", Label).update(f"Stoat: {t_disp}")
|
|
||||||
|
|
||||||
|
# Target
|
||||||
|
plat = "Fluxer" if self.target_platform == "fluxer" else "Stoat"
|
||||||
|
t_name = v.get("target_community_name")
|
||||||
|
if v.get("target_timeout"):
|
||||||
|
t_disp = "[red]TIMEOUT[/red]"
|
||||||
|
elif t_name and v.get("target_token") and v.get("target_community"):
|
||||||
|
t_disp = f'[green]"{t_name}"[/green]'
|
||||||
|
elif v.get("target_token") is False:
|
||||||
|
t_disp = "[red]INVALID TOKEN[/red]"
|
||||||
|
else:
|
||||||
|
t_disp = "[red]NOT SET UP[/red]"
|
||||||
|
self.query_one("#sp_lbl_target", Label).update(f"{plat}: {t_disp}")
|
||||||
|
|
||||||
|
# Status
|
||||||
if not self.tokens_valid:
|
if not self.tokens_valid:
|
||||||
val = "[red][INVALID][/red]"
|
val = "[red][INVALID][/red]"
|
||||||
elif not self.permissions_complete:
|
elif not self.permissions_complete:
|
||||||
val = "[yellow][PERMISSION MISSING][/yellow]"
|
val = "[yellow][MISSING PERMISSIONS][/yellow]"
|
||||||
else:
|
else:
|
||||||
val = "[green][VALID][/green]"
|
val = "[green][VALID][/green]"
|
||||||
self.query_one("#lbl_status", Label).update(f"Status: {val}")
|
self.query_one("#sp_lbl_status", Label).update(f"Status: {val}")
|
||||||
|
|
||||||
enabled = self.tokens_valid
|
# Buttons
|
||||||
for bid in ("#btn_clone", "#btn_roles", "#btn_emojis", "#btn_metadata", "#btn_messages", "#btn_danger"):
|
for bid in ("#sp_clone", "#sp_roles", "#sp_emojis", "#sp_metadata", "#sp_messages", "#sp_danger"):
|
||||||
self.query_one(bid, Button).disabled = not enabled
|
self.query_one(bid, Button).disabled = not self.tokens_valid
|
||||||
|
|
||||||
# ── compose ──────────────────────────────────────────────────────────
|
# ── validation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
yield Header(show_clock=True)
|
|
||||||
with VerticalScroll(id="shuttle_scroll"):
|
|
||||||
with Container(id="shuttle_container"):
|
|
||||||
yield Label("Shuttle Mode", id="shuttle_title")
|
|
||||||
with Vertical(id="shuttle_info"):
|
|
||||||
yield Label("Discord: [yellow]Loading...[/yellow]", id="lbl_discord")
|
|
||||||
yield Label("Target: [yellow]Loading...[/yellow]", id="lbl_target")
|
|
||||||
yield Label("Status: [yellow]Validating...[/yellow]", id="lbl_status")
|
|
||||||
with Vertical(id="shuttle_actions"):
|
|
||||||
yield Button("Clone Server Template", id="btn_clone", disabled=True)
|
|
||||||
yield Button("Copy Roles & Permissions", id="btn_roles", disabled=True)
|
|
||||||
yield Button("Copy Emojis & Stickers", id="btn_emojis", disabled=True)
|
|
||||||
yield Button("Sync Server Profile", id="btn_metadata", disabled=True)
|
|
||||||
yield Button("Migrate Message History", id="btn_messages", disabled=True)
|
|
||||||
yield Rule()
|
|
||||||
yield Button("Configuration", id="btn_config")
|
|
||||||
yield Button("Danger Zone ⚠", id="btn_danger", variant="error", disabled=True)
|
|
||||||
yield Rule()
|
|
||||||
yield Button("Back", id="btn_back")
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
# ── lifecycle ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
# Platform is already configured in config.yaml via ConfigScreen
|
|
||||||
self.target_platform = self.config.target_platform or "fluxer"
|
|
||||||
self._rebuild_engine()
|
|
||||||
self.run_validate()
|
|
||||||
|
|
||||||
def action_go_back(self):
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
# ── button routing ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed):
|
|
||||||
bid = event.button.id
|
|
||||||
if bid == "btn_back":
|
|
||||||
self.app.pop_screen()
|
|
||||||
elif bid == "btn_config":
|
|
||||||
self._open_config()
|
|
||||||
elif bid == "btn_clone":
|
|
||||||
self.run_clone_template()
|
|
||||||
elif bid == "btn_roles":
|
|
||||||
self._open_roles_menu()
|
|
||||||
elif bid == "btn_emojis":
|
|
||||||
self._open_emoji_menu()
|
|
||||||
elif bid == "btn_metadata":
|
|
||||||
self._open_metadata_menu()
|
|
||||||
elif bid == "btn_messages":
|
|
||||||
self.run_migrate_messages()
|
|
||||||
elif bid == "btn_danger":
|
|
||||||
self._open_danger_menu()
|
|
||||||
|
|
||||||
# ── (0) validation ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@work(exclusive=True)
|
@work(exclusive=True)
|
||||||
async def run_validate(self) -> None:
|
async def run_validate(self) -> None:
|
||||||
|
|
@ -501,7 +210,6 @@ class ShuttleScreen(Screen):
|
||||||
if all_tasks:
|
if all_tasks:
|
||||||
done, _ = await asyncio.wait(all_tasks, timeout=10.0)
|
done, _ = await asyncio.wait(all_tasks, timeout=10.0)
|
||||||
|
|
||||||
# Discord
|
|
||||||
dt = tasks.get("discord")
|
dt = tasks.get("discord")
|
||||||
if dt and dt in done:
|
if dt and dt in done:
|
||||||
res = dt.result()
|
res = dt.result()
|
||||||
|
|
@ -515,7 +223,6 @@ class ShuttleScreen(Screen):
|
||||||
self.validation_results["discord_timeout"] = True
|
self.validation_results["discord_timeout"] = True
|
||||||
dt.cancel()
|
dt.cancel()
|
||||||
|
|
||||||
# Target platform
|
|
||||||
tt = tasks.get("target")
|
tt = tasks.get("target")
|
||||||
if tt and tt in done:
|
if tt and tt in done:
|
||||||
res = tt.result()
|
res = tt.result()
|
||||||
|
|
@ -528,12 +235,10 @@ class ShuttleScreen(Screen):
|
||||||
self.validation_results["target_timeout"] = True
|
self.validation_results["target_timeout"] = True
|
||||||
tt.cancel()
|
tt.cancel()
|
||||||
|
|
||||||
# Compute validity
|
|
||||||
discord_ok = self.validation_results.get("discord_token") and self.validation_results.get("discord_server")
|
discord_ok = self.validation_results.get("discord_token") and self.validation_results.get("discord_server")
|
||||||
target_ok = self.validation_results.get("target_token") and self.validation_results.get("target_community")
|
target_ok = self.validation_results.get("target_token") and self.validation_results.get("target_community")
|
||||||
self.tokens_valid = bool(discord_ok and target_ok)
|
self.tokens_valid = bool(discord_ok and target_ok)
|
||||||
|
|
||||||
# Set state folder
|
|
||||||
if self.tokens_valid:
|
if self.tokens_valid:
|
||||||
srv_id = self.config.target_server_id
|
srv_id = self.config.target_server_id
|
||||||
srv_name = self.validation_results.get("target_community_name", "unknown")
|
srv_name = self.validation_results.get("target_community_name", "unknown")
|
||||||
|
|
@ -541,7 +246,6 @@ class ShuttleScreen(Screen):
|
||||||
safe = re.sub(r"[^a-zA-Z0-9_\-\.]", "_", srv_name)
|
safe = re.sub(r"[^a-zA-Z0-9_\-\.]", "_", srv_name)
|
||||||
self.engine.state.set_folder(str(srv_id), safe, base_dir=self._base_dir())
|
self.engine.state.set_folder(str(srv_id), safe, base_dir=self._base_dir())
|
||||||
|
|
||||||
# Check permissions
|
|
||||||
self.permissions_complete = True
|
self.permissions_complete = True
|
||||||
if self.tokens_valid:
|
if self.tokens_valid:
|
||||||
di = self.validation_results.get("discord_intents", {})
|
di = self.validation_results.get("discord_intents", {})
|
||||||
|
|
@ -560,7 +264,26 @@ class ShuttleScreen(Screen):
|
||||||
|
|
||||||
self._update_info_labels()
|
self._update_info_labels()
|
||||||
|
|
||||||
# ── (1) clone server template ────────────────────────────────────────
|
# ── button routing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
bid = event.button.id
|
||||||
|
if not bid or not bid.startswith("sp_"):
|
||||||
|
return
|
||||||
|
if bid == "sp_clone":
|
||||||
|
self.run_clone_template()
|
||||||
|
elif bid == "sp_roles":
|
||||||
|
self._open_roles_menu()
|
||||||
|
elif bid == "sp_emojis":
|
||||||
|
self._open_emoji_menu()
|
||||||
|
elif bid == "sp_metadata":
|
||||||
|
self._open_metadata_menu()
|
||||||
|
elif bid == "sp_messages":
|
||||||
|
self.run_migrate_messages()
|
||||||
|
elif bid == "sp_danger":
|
||||||
|
self._open_danger_menu()
|
||||||
|
|
||||||
|
# ── (1) clone server template ─────────────────────────────────────────
|
||||||
|
|
||||||
@work(exclusive=True)
|
@work(exclusive=True)
|
||||||
async def run_clone_template(self) -> None:
|
async def run_clone_template(self) -> None:
|
||||||
|
|
@ -616,7 +339,7 @@ class ShuttleScreen(Screen):
|
||||||
modal.set_status("Finished.")
|
modal.set_status("Finished.")
|
||||||
modal.allow_close()
|
modal.allow_close()
|
||||||
|
|
||||||
# ── (2) roles & permissions ──────────────────────────────────────────
|
# ── (2) roles & permissions ───────────────────────────────────────────
|
||||||
|
|
||||||
def _open_roles_menu(self):
|
def _open_roles_menu(self):
|
||||||
options = [
|
options = [
|
||||||
|
|
@ -707,7 +430,7 @@ class ShuttleScreen(Screen):
|
||||||
modal.set_status("Finished.")
|
modal.set_status("Finished.")
|
||||||
modal.allow_close()
|
modal.allow_close()
|
||||||
|
|
||||||
# ── (3) emojis & stickers ────────────────────────────────────────────
|
# ── (3) emojis & stickers ─────────────────────────────────────────────
|
||||||
|
|
||||||
def _open_emoji_menu(self):
|
def _open_emoji_menu(self):
|
||||||
options = [
|
options = [
|
||||||
|
|
@ -775,7 +498,7 @@ class ShuttleScreen(Screen):
|
||||||
modal.set_status("Finished.")
|
modal.set_status("Finished.")
|
||||||
modal.allow_close()
|
modal.allow_close()
|
||||||
|
|
||||||
# ── (4) server metadata sync ─────────────────────────────────────────
|
# ── (4) server metadata sync ──────────────────────────────────────────
|
||||||
|
|
||||||
def _open_metadata_menu(self):
|
def _open_metadata_menu(self):
|
||||||
options = [
|
options = [
|
||||||
|
|
@ -842,7 +565,7 @@ class ShuttleScreen(Screen):
|
||||||
modal.set_status("Finished.")
|
modal.set_status("Finished.")
|
||||||
modal.allow_close()
|
modal.allow_close()
|
||||||
|
|
||||||
# ── (5) message migration ────────────────────────────────────────────
|
# ── (5) message migration ─────────────────────────────────────────────
|
||||||
|
|
||||||
@work(exclusive=True)
|
@work(exclusive=True)
|
||||||
async def run_migrate_messages(self) -> None:
|
async def run_migrate_messages(self) -> None:
|
||||||
|
|
@ -870,8 +593,8 @@ class ShuttleScreen(Screen):
|
||||||
modal.allow_close()
|
modal.allow_close()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Pick source channel via modal
|
# Pick source channel
|
||||||
self.app.pop_screen() # pop progress temporarily
|
self.app.pop_screen()
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
src_future = loop.create_future()
|
src_future = loop.create_future()
|
||||||
|
|
@ -889,7 +612,7 @@ class ShuttleScreen(Screen):
|
||||||
|
|
||||||
source_channel = next(c for c in d_channels if c.id == src_id)
|
source_channel = next(c for c in d_channels if c.id == src_id)
|
||||||
|
|
||||||
# Pick target channel
|
# Fetch target channels
|
||||||
modal2_status = ProgressModal()
|
modal2_status = ProgressModal()
|
||||||
self.app.push_screen(modal2_status)
|
self.app.push_screen(modal2_status)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
@ -912,7 +635,7 @@ class ShuttleScreen(Screen):
|
||||||
if not recommended:
|
if not recommended:
|
||||||
recommended = next((c for c in f_channels if c.get("name") == source_channel.name), None)
|
recommended = next((c for c in f_channels if c.get("name") == source_channel.name), None)
|
||||||
|
|
||||||
self.app.pop_screen() # pop status
|
self.app.pop_screen()
|
||||||
|
|
||||||
target_cat_names = {str(c.get("id")): c.get("name") for c in full_f if c.get("type") == 4}
|
target_cat_names = {str(c.get("id")): c.get("name") for c in full_f if c.get("type") == 4}
|
||||||
|
|
||||||
|
|
@ -999,22 +722,7 @@ class ShuttleScreen(Screen):
|
||||||
modal.set_status("Finished.")
|
modal.set_status("Finished.")
|
||||||
modal.allow_close()
|
modal.allow_close()
|
||||||
|
|
||||||
# ── (6) configuration ────────────────────────────────────────────────
|
# ── (6) danger zone ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def _open_config(self):
|
|
||||||
def on_result(data: dict | None):
|
|
||||||
if data is None:
|
|
||||||
return
|
|
||||||
self.config.discord_bot_token = data["d_token"] or self.config.discord_bot_token
|
|
||||||
self.config.discord_server_id = data["d_server"] or self.config.discord_server_id
|
|
||||||
self.config.target_bot_token = data["t_token"] or self.config.target_bot_token
|
|
||||||
self.config.target_server_id = data["t_server"] or self.config.target_server_id
|
|
||||||
save_config(self.config, self.config_path)
|
|
||||||
self._rebuild_engine()
|
|
||||||
self.run_validate()
|
|
||||||
self.app.push_screen(ShuttleConfigModal(self.config, self.target_platform), on_result)
|
|
||||||
|
|
||||||
# ── (7) danger zone ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _open_danger_menu(self):
|
def _open_danger_menu(self):
|
||||||
options = [
|
options = [
|
||||||
Loading…
Add table
Reference in a new issue