diff --git a/.gitignore b/.gitignore index fe89e99..e003c1e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,9 @@ test_*.py test_release.zip test_release/ +REAPER-*/ +Reaper-*/ +DISCORD-*/ +FLUXER-*/ +REAPER-*/ EXPORT-*/ diff --git a/src/core/configuration.py b/src/core/configuration.py index fad6e87..156c949 100644 --- a/src/core/configuration.py +++ b/src/core/configuration.py @@ -4,7 +4,7 @@ from pathlib import Path from pydantic import BaseModel, Field class MigrationSettings(BaseModel): - batch_size: int = Field(default=50) + batch_size: int = Field(default=100) rate_limit_delay_seconds: int = Field(default=2) log_level: str = Field(default="INFO") diff --git a/src/ui/backup_ops.py b/src/ui/backup_ops.py new file mode 100644 index 0000000..9a0902d --- /dev/null +++ b/src/ui/backup_ops.py @@ -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) diff --git a/src/ui/backup_screen.py b/src/ui/backup_screen.py deleted file mode 100644 index a0182b2..0000000 --- a/src/ui/backup_screen.py +++ /dev/null @@ -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) - - diff --git a/src/ui/main_app.py b/src/ui/main_app.py index 63c60bb..cc9d82b 100644 --- a/src/ui/main_app.py +++ b/src/ui/main_app.py @@ -114,7 +114,8 @@ class ConfigSelectionScreen(Screen): def on_list_view_selected(self, event: ListView.Selected) -> None: cfg_name = event.item.name 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: if event.button.id == "btn_new_config": @@ -157,11 +158,12 @@ class ConfigScreen(Screen): DEFAULT_CSS = """ ConfigScreen { align: center middle; } - #cfg_scroll { width: 100%; height: 100%; align: center middle; } + #cfg_outer { width: 100%; height: 100%; align: center top; } #cfg_container { - width: 70; height: auto; - border: solid green; padding: 1 2; margin: 1 0; + width: 80%; height: 100%; layout: vertical; + border: solid green; padding: 1 2; margin: 2 0; } + #cfg_scroll { width: 100%; height: 1fr; margin-bottom: 1; } #cfg_title { text-style: bold; color: green; margin-bottom: 1; content-align: center middle; width: 100%; @@ -175,7 +177,7 @@ class ConfigScreen(Screen): height: auto; margin: 0 0 0 2; } #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; } """ @@ -189,78 +191,80 @@ class ConfigScreen(Screen): def compose(self) -> ComposeResult: yield Header(show_clock=True) - with VerticalScroll(id="cfg_scroll"): + with Container(id="cfg_outer"): with Container(id="cfg_container"): yield Label(f"Configuration — {self.cfg_name}", id="cfg_title") - # ── Discord ────────────────────────────────────────────── - yield Label("Discord", classes="section_title") - yield Rule() - 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"), - ) + with VerticalScroll(id="cfg_scroll"): + # ── Discord ────────────────────────────────────────────── + yield Label("Discord", classes="section_title") yield Label("Bot Token:", classes="field_label") yield Input( - value=self.config.target_bot_token or "", - id="inp_target_token", + value=self.config.discord_bot_token or "", + id="inp_discord_token", password=True, ) - yield Label("Community / Server ID:", classes="field_label") + yield Label("Server ID:", classes="field_label") yield Input( - value=self.config.target_server_id or "", - id="inp_target_server", + value=self.config.discord_server_id or "", + 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() with Horizontal(id="cfg_actions"): - yield Button("Save & Start", variant="success", id="btn_start") - yield Button("Save", variant="primary", id="btn_save") + yield Button("Save Configuration", variant="primary", id="btn_save") yield Button("Back", id="btn_back") yield Footer() @@ -300,22 +304,14 @@ class ConfigScreen(Screen): 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_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: self.config.target_platform = "none" save_config(self.config, self.cfg_path) def _launch_mode(self) -> None: - mode = self.config.tool_mode - 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)) + pass # No longer needed def action_go_back(self) -> None: self.app.pop_screen() @@ -326,9 +322,7 @@ class ConfigScreen(Screen): elif event.button.id == "btn_save": self._collect_and_save() self.notify("Configuration saved.", severity="information") - elif event.button.id == "btn_start": - self._collect_and_save() - self._launch_mode() + self.dismiss(True) # ────────────────────────────────────────────────────────────────────────────── @@ -336,6 +330,7 @@ class ConfigScreen(Screen): # ────────────────────────────────────────────────────────────────────────────── class ReaperApp(App): + theme = "dracula" def on_mount(self) -> None: self.push_screen(ConfigSelectionScreen()) diff --git a/src/ui/modals.py b/src/ui/modals.py new file mode 100644 index 0000000..2eb4cc4 --- /dev/null +++ b/src/ui/modals.py @@ -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) diff --git a/src/ui/mode_screen.py b/src/ui/mode_screen.py new file mode 100644 index 0000000..cd374a3 --- /dev/null +++ b/src/ui/mode_screen.py @@ -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() diff --git a/src/ui/shuttle_screen.py b/src/ui/shuttle_ops.py similarity index 65% rename from src/ui/shuttle_screen.py rename to src/ui/shuttle_ops.py index 52ef9a7..10aecba 100644 --- a/src/ui/shuttle_screen.py +++ b/src/ui/shuttle_ops.py @@ -1,10 +1,8 @@ """ -Shuttle Screen – native Textual TUI for direct server-to-server migration. -Ports every operation from the old Rich CLI (MigrationCLI) into Textual -Screens, Modals, and Workers. +ShuttlePane – self-contained shuttle (migration) operations widget. +Embedded inside ModeScreen's "Migrate" tab. """ -import sys import asyncio import discord import logging @@ -13,20 +11,17 @@ import time import aiohttp from pathlib import Path -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, - ListItem, ListView, RadioSet, -) -from textual.screen import Screen, ModalScreen +from textual.app import ComposeResult +from textual.containers import Container, Vertical, VerticalScroll +from textual.widgets import Button, Label, Rule 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.audit import log_audit_event +from src.ui.modals import ( + ProgressModal, ConfirmModal, SubMenuModal, ChannelPickerModal, +) import src.fluxer.roles_permissions as fluxer_roles import src.stoat.roles_permissions as stoat_roles @@ -52,329 +47,81 @@ class RateLimitHandler(logging.Handler): super().__init__() def emit(self, record): - try: - msg = record.getMessage() - if "retry" in msg.lower() and ("rate limit" in msg.lower() or "429" in msg): - match = re.search(r"in ([\d.]+)\s*(?:seconds?|s)", msg, re.IGNORECASE) - if match: - seconds = match.group(1) - platform = "API" - if "discord" in record.name.lower(): - platform = "Discord" - elif "fluxer" in record.name.lower(): - platform = "Fluxer" - elif "stoat" in record.name.lower(): - platform = "Stoat" - global global_rate_limit_msg, global_rate_limit_expires - global_rate_limit_msg = f"{platform} rate limit {seconds}s" - try: - global_rate_limit_expires = time.time() + float(seconds) - except ValueError: - pass - except Exception: - pass + global global_rate_limit_msg, global_rate_limit_expires + msg = record.getMessage() + if "rate" in msg.lower() and "limit" in msg.lower(): + global_rate_limit_msg = msg + try: + parts = msg.split() + for i, p in enumerate(parts): + if p.lower() in ("retry_after", "retry"): + secs = float(parts[i + 1].strip("s,.")) + global_rate_limit_expires = time.time() + secs + break + if p.lower() == "after": + secs = float(parts[i + 1].strip("s,.")) + global_rate_limit_expires = time.time() + secs + break + else: + for p in parts: + try: + secs = float(p.strip("s,.")) + if 0 < secs < 3600: + global_rate_limit_expires = time.time() + secs + break + except ValueError: + continue + except Exception: + pass -# --------------------------------------------------------------------------- -# Shared modals -# --------------------------------------------------------------------------- +class ShuttlePane(Container): + """Shuttle (migration) operations pane — clone, roles, emojis, metadata, messages, danger zone.""" -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) - - 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; + DEFAULT_CSS = """ + ShuttlePane { height: auto; width: 100%; } + ShuttlePane #sp_info { + height: auto; border: tall cyan; padding: 1; margin-bottom: 1; } - - #shuttle_container { - 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; } + ShuttlePane #sp_actions { height: auto; } + ShuttlePane #sp_actions Button { width: 100%; margin-bottom: 1; } """ - BINDINGS = [ - ("q", "app.exit", "Quit"), - ("b", "go_back", "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.target_platform: str | None = None + self.config = load_config(cfg_path) + self.target_platform = self.config.target_platform or "fluxer" self.engine: MigrationContext | None = None self.validation_results: dict = {} self.tokens_valid = False self.permissions_complete = False - # Register rate-limit handler - rl = RateLimitHandler() - logging.getLogger("discord").addHandler(rl) - logging.getLogger("fluxer").addHandler(rl) - logging.getLogger("stoat").addHandler(rl) + def compose(self) -> ComposeResult: + with VerticalScroll(): + with Vertical(id="sp_info"): + yield Label("Discord: [yellow]Loading...[/yellow]", id="sp_lbl_discord") + 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: return f"Reaper-{self.cfg_name}" @@ -382,89 +129,51 @@ class ShuttleScreen(Screen): def _rebuild_engine(self): self.engine = MigrationContext(self.config, self.target_platform) + # ── labels ──────────────────────────────────────────────────────────── + def _update_info_labels(self): - d_name = self.validation_results.get("discord_server_name") - 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}") + v = self.validation_results - if self.target_platform == "fluxer": - t_name = self.validation_results.get("target_community_name") - t_disp = f"[green]\"{t_name}\"[/green]" if t_name else "[red]NOT SET UP[/red]" - self.query_one("#lbl_target", Label).update(f"Fluxer: {t_disp}") + # Discord + d_name = v.get("discord_server_name") + d_bot = v.get("discord_bot_name") + 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: - t_name = self.validation_results.get("target_community_name") - t_disp = f"[green]\"{t_name}\"[/green]" if t_name else "[red]NOT SET UP[/red]" - self.query_one("#lbl_target", Label).update(f"Stoat: {t_disp}") + d_disp = "[red]NOT SET UP[/red]" + self.query_one("#sp_lbl_discord", Label).update(f"Discord: {d_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: val = "[red][INVALID][/red]" elif not self.permissions_complete: - val = "[yellow][PERMISSION MISSING][/yellow]" + val = "[yellow][MISSING PERMISSIONS][/yellow]" else: 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 - for bid in ("#btn_clone", "#btn_roles", "#btn_emojis", "#btn_metadata", "#btn_messages", "#btn_danger"): - self.query_one(bid, Button).disabled = not enabled + # Buttons + for bid in ("#sp_clone", "#sp_roles", "#sp_emojis", "#sp_metadata", "#sp_messages", "#sp_danger"): + self.query_one(bid, Button).disabled = not self.tokens_valid - # ── compose ────────────────────────────────────────────────────────── - - 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 ─────────────────────────────────────────────────── + # ── validation ──────────────────────────────────────────────────────── @work(exclusive=True) async def run_validate(self) -> None: @@ -501,7 +210,6 @@ class ShuttleScreen(Screen): if all_tasks: done, _ = await asyncio.wait(all_tasks, timeout=10.0) - # Discord dt = tasks.get("discord") if dt and dt in done: res = dt.result() @@ -515,7 +223,6 @@ class ShuttleScreen(Screen): self.validation_results["discord_timeout"] = True dt.cancel() - # Target platform tt = tasks.get("target") if tt and tt in done: res = tt.result() @@ -528,12 +235,10 @@ class ShuttleScreen(Screen): self.validation_results["target_timeout"] = True tt.cancel() - # Compute validity 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") self.tokens_valid = bool(discord_ok and target_ok) - # Set state folder if self.tokens_valid: srv_id = self.config.target_server_id 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) self.engine.state.set_folder(str(srv_id), safe, base_dir=self._base_dir()) - # Check permissions self.permissions_complete = True if self.tokens_valid: di = self.validation_results.get("discord_intents", {}) @@ -560,7 +264,26 @@ class ShuttleScreen(Screen): 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) async def run_clone_template(self) -> None: @@ -616,7 +339,7 @@ class ShuttleScreen(Screen): modal.set_status("Finished.") modal.allow_close() - # ── (2) roles & permissions ────────────────────────────────────────── + # ── (2) roles & permissions ─────────────────────────────────────────── def _open_roles_menu(self): options = [ @@ -707,7 +430,7 @@ class ShuttleScreen(Screen): modal.set_status("Finished.") modal.allow_close() - # ── (3) emojis & stickers ──────────────────────────────────────────── + # ── (3) emojis & stickers ───────────────────────────────────────────── def _open_emoji_menu(self): options = [ @@ -775,7 +498,7 @@ class ShuttleScreen(Screen): modal.set_status("Finished.") modal.allow_close() - # ── (4) server metadata sync ───────────────────────────────────────── + # ── (4) server metadata sync ────────────────────────────────────────── def _open_metadata_menu(self): options = [ @@ -842,7 +565,7 @@ class ShuttleScreen(Screen): modal.set_status("Finished.") modal.allow_close() - # ── (5) message migration ──────────────────────────────────────────── + # ── (5) message migration ───────────────────────────────────────────── @work(exclusive=True) async def run_migrate_messages(self) -> None: @@ -870,8 +593,8 @@ class ShuttleScreen(Screen): modal.allow_close() return - # Pick source channel via modal - self.app.pop_screen() # pop progress temporarily + # Pick source channel + self.app.pop_screen() loop = asyncio.get_running_loop() 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) - # Pick target channel + # Fetch target channels modal2_status = ProgressModal() self.app.push_screen(modal2_status) await asyncio.sleep(0.1) @@ -912,7 +635,7 @@ class ShuttleScreen(Screen): if not recommended: 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} @@ -999,22 +722,7 @@ class ShuttleScreen(Screen): modal.set_status("Finished.") modal.allow_close() - # ── (6) configuration ──────────────────────────────────────────────── - - 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 ────────────────────────────────────────────────── + # ── (6) danger zone ─────────────────────────────────────────────────── def _open_danger_menu(self): options = [