diff --git a/src/core/discord_reader.py b/src/core/discord_reader.py index f90edfd..22bb6a2 100644 --- a/src/core/discord_reader.py +++ b/src/core/discord_reader.py @@ -97,36 +97,50 @@ class DiscordReader: "server": False, "bot_name": None, "server_name": None, + "error_reason": None, "intents": {"message_content": False}, "permissions": {"view_channel": False, "read_message_history": False} } temp_client = self._create_client() try: - await temp_client.login(self.token) - results["token"] = True - if temp_client.user: - results["bot_name"] = temp_client.user.display_name + try: + await temp_client.login(self.token) + results["token"] = True + if temp_client.user: + results["bot_name"] = temp_client.user.display_name + except discord.LoginFailure: + results["error_reason"] = "Invalid Discord Token" + return results + except Exception as e: + results["error_reason"] = f"Login Error: {str(e)}" + return results - guild = await temp_client.fetch_guild(self.server_id) - if guild is not None: - results["server"] = True - results["server_name"] = guild.name - - # Check intents - results["intents"]["message_content"] = temp_client.intents.message_content - - # Check permissions - # We need to fetch the member to check permissions - try: - member = await guild.fetch_member(temp_client.user.id) - perms = member.guild_permissions - results["permissions"]["view_channel"] = perms.view_channel - results["permissions"]["read_message_history"] = perms.read_message_history - except Exception: - # Fallback if member fetch fails, though it shouldn't for the bot itself - pass - except Exception: - pass + try: + guild = await temp_client.fetch_guild(self.server_id) + if guild is not None: + results["server"] = True + results["server_name"] = guild.name + + # Check intents + results["intents"]["message_content"] = temp_client.intents.message_content + + # Check permissions + try: + member = await guild.fetch_member(temp_client.user.id) + perms = member.guild_permissions + results["permissions"]["view_channel"] = perms.view_channel + results["permissions"]["read_message_history"] = perms.read_message_history + except Exception as e: + logger.debug(f"Member fetch failed: {e}") + # Fallback if member fetch fails, though it shouldn't for the bot itself + except discord.Forbidden: + results["error_reason"] = "Missing Access to Server" + except discord.NotFound: + results["error_reason"] = "Server Not Found" + except Exception as e: + results["error_reason"] = f"Server Error: {str(e)}" + except Exception as e: + results["error_reason"] = str(e) finally: if not temp_client.is_closed(): await temp_client.close() diff --git a/src/fluxer/writer.py b/src/fluxer/writer.py index 0690034..52217b9 100644 --- a/src/fluxer/writer.py +++ b/src/fluxer/writer.py @@ -105,6 +105,7 @@ class FluxerWriter: is_community_valid = False bot_name = None community_name = None + error_reason = None permissions = { "manage_channels": False, "manage_messages": False, @@ -116,77 +117,84 @@ class FluxerWriter: try: # Check token by fetching me me_id = None - if self.bot and self.bot.user: - is_token_valid = True - bot_name = self.bot.user.username - me_id = self.bot.user.id - else: - me = await self.client.get_current_user() - if me: + try: + if self.bot and self.bot.user: is_token_valid = True - bot_name = me.get("username") - me_id = int(me["id"]) + bot_name = self.bot.user.username + me_id = self.bot.user.id + else: + me = await self.client.get_current_user() + if me: + is_token_valid = True + bot_name = me.get("username") + me_id = int(me["id"]) + except Exception as e: + error_reason = f"Token Error: {str(e)}" + return { + "token": False, + "community": False, + "bot_name": None, + "community_name": None, + "error_reason": error_reason, + "permissions": permissions + } # Check community - guild_data = await self.client.get_guild(self.community_id) - if guild_data: - is_community_valid = True - community_name = guild_data.get("name") + try: + guild_data = await self.client.get_guild(self.community_id) + if guild_data: + is_community_valid = True + community_name = guild_data.get("name") - if me_id: - try: - # Fetch member to get roles - member_data = await self.client.get_guild_member(self.community_id, me_id) - member_role_ids = [int(r) for r in member_data.get("roles", [])] - - # Fetch all roles to get their permissions - all_roles_data = await self.client.get_guild_roles(self.community_id) - - # Calculate total permissions - # In Discord/Fluxer, permissions are additive - total_perms = 0 - fluxer_guild_id = 0 + if me_id: try: - fluxer_guild_id = int(self.community_id) - except (ValueError, TypeError): - pass + # Fetch member to get roles + member_data = await self.client.get_guild_member(self.community_id, me_id) + member_role_ids = [int(r) for r in member_data.get("roles", [])] + + # Fetch all roles to get their permissions + all_roles_data = await self.client.get_guild_roles(self.community_id) + + # Calculate total permissions + # In Discord/Fluxer, permissions are additive + total_perms = 0 + fluxer_guild_id = 0 + try: + fluxer_guild_id = int(self.community_id) + except (ValueError, TypeError): + pass - for r_data in all_roles_data: - r_id = int(r_data["id"]) - # Add @everyone permissions (role ID same as guild ID) - if r_id == fluxer_guild_id or r_id in member_role_ids: - total_perms |= int(r_data.get("permissions", 0)) - - # Debugging - # print(f"DEBUG: me_id={me_id}, roles={member_role_ids}, total_perms={total_perms}") - - # Bitmask Mapping (Discord standard) - # Administrator: 1 << 3 - # Manage Channels: 1 << 4 - # Manage Messages: 1 << 13 - # Manage Roles: 1 << 28 - # Manage Webhooks: 1 << 29 - # Manage Emojis/Stickers: 1 << 30 - is_admin = bool(total_perms & (1 << 3)) - - permissions["manage_channels"] = is_admin or bool(total_perms & (1 << 4)) - permissions["manage_messages"] = is_admin or bool(total_perms & (1 << 13)) - permissions["manage_roles"] = is_admin or bool(total_perms & (1 << 28)) - permissions["manage_webhooks"] = is_admin or bool(total_perms & (1 << 29)) - permissions["manage_emojis_stickers"] = is_admin or bool(total_perms & (1 << 30)) - - if is_admin: - logger.info(f"Fluxer bot {bot_name} has Administrator permission.") - except Exception as e: - logger.error(f"Failed to calculate Fluxer permissions: {e}") - except Exception: - pass + for r_data in all_roles_data: + r_id = int(r_data["id"]) + # Add @everyone permissions (role ID same as guild ID) + if r_id == fluxer_guild_id or r_id in member_role_ids: + total_perms |= int(r_data.get("permissions", 0)) + + # Bitmask Mapping (Discord standard) + is_admin = bool(total_perms & (1 << 3)) + + permissions["manage_channels"] = is_admin or bool(total_perms & (1 << 4)) + permissions["manage_messages"] = is_admin or bool(total_perms & (1 << 13)) + permissions["manage_roles"] = is_admin or bool(total_perms & (1 << 28)) + permissions["manage_webhooks"] = is_admin or bool(total_perms & (1 << 29)) + permissions["manage_emojis_stickers"] = is_admin or bool(total_perms & (1 << 30)) + + except Exception as e: + logger.error(f"Failed to calculate Fluxer permissions: {e}") + error_reason = f"Permission error: {str(e)}" + else: + error_reason = "Community not found" + except Exception as e: + error_reason = f"Community Error: {str(e)}" + except Exception as e: + error_reason = str(e) return { "token": is_token_valid, "community": is_community_valid, "bot_name": bot_name, "community_name": community_name, + "error_reason": error_reason, "permissions": permissions } diff --git a/src/stoat/writer.py b/src/stoat/writer.py index bfa1918..f8d7602 100644 --- a/src/stoat/writer.py +++ b/src/stoat/writer.py @@ -116,6 +116,7 @@ class StoatWriter: "community": False, "bot_name": "N/A", "community_name": "N/A", + "error_reason": None, "permissions": { "manage_channels": False, "manage_server": False, @@ -143,7 +144,10 @@ class StoatWriter: results["token"] = True results["bot_name"] = current_user.display_name or current_user.name except stoat.Unauthorized: - logger.error("Invalid Stoat token.") + results["error_reason"] = "Invalid Stoat Token" + return results + except Exception as e: + results["error_reason"] = f"Token Error: {str(e)}" return results # Validate server access @@ -172,21 +176,20 @@ class StoatWriter: "mention_roles": perms.mention_roles } except stoat.NotFound: - logger.error(f"Bot member {current_user.id} not found in Stoat server {self.community_id}.") + results["error_reason"] = "Bot not member of server" except stoat.Forbidden: - logger.error(f"Bot lacks permissions to fetch its own member data in Stoat server {self.community_id}.") + results["error_reason"] = "Missing member data access" except Exception as e: - logger.error(f"Error fetching Stoat member permissions: {e}") - + results["error_reason"] = f"Permission error: {str(e)}" except stoat.NotFound: - logger.error(f"Stoat server {self.community_id} not found.") + results["error_reason"] = "Server not found" except stoat.Forbidden: - logger.error(f"Bot has no access to Stoat server {self.community_id}.") + results["error_reason"] = "Missing access to server" except Exception as e: - logger.error(f"Error validating Stoat server: {e}") + results["error_reason"] = f"Server error: {str(e)}" except Exception as e: - logger.error(f"Stoat validation failed: {str(e)}") + results["error_reason"] = f"Stoat validation failed: {str(e)}" self._validation_cache = results return results diff --git a/src/ui/backup_ops.py b/src/ui/backup_ops.py deleted file mode 100644 index a0c3c9e..0000000 --- a/src/ui/backup_ops.py +++ /dev/null @@ -1,553 +0,0 @@ -""" -BackupPane – self-contained backup-operations widget. -Embedded inside ModeScreen's "Backup" tab. -""" - -import asyncio -import json -import re -import logging -import traceback -from pathlib import Path -from datetime import datetime -from typing import Any, Optional, Union, List, Dict, Callable - -logger = logging.getLogger(__name__) - -from textual.app import ComposeResult -from textual.containers import Container, Vertical, VerticalScroll, Horizontal -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.core.exporter import DiscordExporter -from src.ui.modals import ProgressScreen, ChannelSelectScreen, MessageIDInputModal - - -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; layout: vertical; - } - #bp_info_split { height: auto; layout: horizontal; width: 100%; margin-bottom: 1; } - .info_pane { width: 1fr; height: auto; } - .info_pane Label { width: 100%; } - .pane_header { text-style: bold; color: $accent; margin-bottom: 1; } - .pane_status { text-style: bold; margin-top: 1; } - #bp_info_split Rule { height: 100%; margin: 0 2; color: $accent; } - #bp_lbl_backup { display: none; } - - 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", base_dir=f"ReaperFiles-{self.cfg_name}") - self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=f"ReaperFiles-{self.cfg_name}") - - def compose(self) -> ComposeResult: - with VerticalScroll(): - with Vertical(id="bp_info"): - with Horizontal(id="bp_info_split"): - with Vertical(classes="info_pane"): - yield Label("Discord", classes="pane_header") - yield Label("Server: -", id="bp_lbl_server") - yield Label("Source: -", id="bp_lbl_bot") - yield Label("Status: -", id="bp_lbl_d_status", classes="pane_status") - - yield Rule(orientation="vertical", id="bp_vrule") - - with Vertical(classes="info_pane", id="bp_target_pane"): - yield Label("Target", classes="pane_header") - yield Label("Community: -", id="bp_lbl_t_comm") - yield Label("Bot: -", id="bp_lbl_t_bot") - yield Label("Status: -", id="bp_lbl_t_status", classes="pane_status") - - yield Label("", id="bp_lbl_backup") - with Vertical(id="bp_actions"): - yield Button("Backup Server Profile", id="bp_backup_profile", disabled=True, tooltip="Backup Discord server roles, emojis, and channel structure") - yield Button("Backup Channel Messages", id="bp_backup_msgs", disabled=True, variant="primary", tooltip="Select and backup message history from text channels") - yield Button("Update Existing Backup", id="bp_backup_sync", disabled=True, variant="success", tooltip="Scan for new messages\n& Update existing backup") - yield Rule(id="bp_backup_stats_rule") - yield Button("Backup Stats", id="bp_backup_stats", variant="warning", flat=True,disabled=True, tooltip="View detailed statistics, storage, and entity metrics for the current backup profile") - - def on_mount(self) -> None: - self._validate() - - def on_show(self) -> None: - """Re-validate when the pane regains visibility (e.g. after a backup operation finishes).""" - 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", base_dir=f"ReaperFiles-{self.cfg_name}") - self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=f"ReaperFiles-{self.cfg_name}") - self._validate() - - # ── validation ──────────────────────────────────────────────────────── - - @work(exclusive=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._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() - has_backup = False - if info: - backup_text = f"Last backup: [cyan]{info}[/cyan]" - has_backup = True - - self._update_ui(s_text, b_text, backup_text, valid, has_backup) - except Exception as e: - self._update_ui(f"[red]Error: {e}[/red]", "", "", False, False) - - def _get_backup_info(self) -> str | None: - if not self.config or not self.config.discord_server_id: - return None - - target_dir = Path(f"ReaperFiles-{self.cfg_name}") / f"DISCORD_BACKUP-{self.config.discord_server_id}" - if not target_dir.exists(): - return None - - profile_file = target_dir / "server_profile" / "profile.json" - if not profile_file.exists(): - return None - 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, has_backup: bool = False): - try: - self.query_one("#bp_lbl_server", Label).update(f"Server: {server_text}") - self.query_one("#bp_lbl_bot", Label).update(f"Source: {bot_text}") - - # Status for Discord side - d_status = "[green][VALID][/green]" if enabled else "[red][INVALID][/red]" - self.query_one("#bp_lbl_d_status", Label).update(f"Status: {d_status}") - - # Legacy label for safety if called elsewhere - self.query_one("#bp_lbl_backup", Label).update(f"Status: {backup_text}") - - # Hide target side in backup mode completely - self.query_one("#bp_vrule").display = False - self.query_one("#bp_target_pane").display = False - - for bid in ("#bp_backup_profile", "#bp_backup_msgs", "#bp_backup_sync"): - self.query_one(bid, Button).disabled = not enabled - - self.query_one("#bp_backup_stats", Button).display = has_backup - self.query_one("#bp_backup_stats", Button).disabled = not has_backup - self.query_one("#bp_backup_stats_rule", Rule).display = has_backup - except Exception: - pass - - # ── 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() - elif bid == "bp_backup_stats": - from src.ui.backup_stats import BackupStatsScreen - target_dir = Path(f"ReaperFiles-{self.cfg_name}") / f"DISCORD_BACKUP-{self.config.discord_server_id}" - self.app.push_screen(BackupStatsScreen(self.cfg_name, target_dir)) - - # ── workers ─────────────────────────────────────────────────────────── - - @work(exclusive=True) - async def run_backup_profile(self) -> None: - modal = ProgressScreen(log_level=self.config.log_level) - self.app.push_screen(modal) - await asyncio.sleep(0.1) - modal.phase_progress() - - try: - modal.set_status("Starting readers...") - await self.engine.discord_reader.start() - await self.exporter.setup() - - # Gather and print summary - server = getattr(self.engine.discord_reader, 'guild', None) - if server: - modal.write(f"[bold cyan]Server Profile to Backup:[/bold cyan]") - modal.write(f" Name: [green]{server.name}[/green]") - modal.write(f" Icon: [green]{'Present' if server.icon else 'None'}[/green]") - modal.write(f" Roles: [green]{len(getattr(server, 'roles', []))}[/green]") - modal.write(f" Emojis: [green]{len(getattr(server, 'emojis', []))}[/green]") - modal.write(f" Channels: [green]{len(getattr(server, 'channels', []))}[/green]") - modal.write("") - - modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) - modal.phase_progress() - modal.set_status("Exporting Server Structure...") - modal.write("[yellow]Backing up server profile & skeleton...[/yellow]") - await self.exporter.export_metadata() - await self.exporter.download_server_assets() - - modal.write("Exporting structure...") - _, cat_count, chan_count = await self.exporter.export_channels_structure() - - modal.write("Exporting roles...") - roles = await self.exporter.export_roles() - - modal.write("Exporting assets...") - e_count, s_count = await self.exporter.export_assets() - - modal.write(f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]") - modal.write(f"- {len(roles)} roles, {e_count} emojis, {s_count} stickers.") - modal.phase_report("Profile Backup", show_back=False) - - except self.engine.discord_reader.Forbidden as e: - modal.write(f"[bold red]Backup failed: {e}[/bold red]") - modal.phase_report("Profile Backup", "error") - except Exception as e: - modal.write(f"[bold red]Error: {e}[/bold red]") - modal.phase_report("Profile Backup", "error") - finally: - await self.engine.close_connections() - self._validate() - - @work(exclusive=True) - async def run_backup_messages(self) -> None: - modal_prog = ProgressScreen(log_level=self.config.log_level) - self.app.push_screen(modal_prog) - await asyncio.sleep(0.1) - - try: - 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 [ - self.engine.discord_reader.CHANNEL_TYPE_TEXT, - self.engine.discord_reader.CHANNEL_TYPE_NEWS, - self.engine.discord_reader.CHANNEL_TYPE_FORUM - ] - ] - - if not eligible_channels: - modal_prog.write("[yellow]No text/news channels found to backup.[/yellow]") - modal_prog.allow_close() - return - - any_found = False - backed_up_ids = set() - for chan in eligible_channels: - if (self.exporter.export_path / "message_backup" / str(chan.id) / "messages.json").exists(): - any_found = True - backed_up_ids.add(chan.id) - - self.app.pop_screen() - - while True: - 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.push_screen( - ChannelSelectScreen(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] - - # Phase 2: Confirmation - modal_prog = ProgressScreen(log_level=self.config.log_level) # Re-instantiate to avoid Textual re-push UI freeze - self.app.push_screen(modal_prog) - await asyncio.sleep(0.1) - - new_channels = [c for c in selected_channels if c.id not in backed_up_ids] - existing_channels = [c for c in selected_channels if c.id in backed_up_ids] - - server = getattr(self.engine.discord_reader, 'guild', None) - if server: - modal_prog.write(f"[bold cyan]Server Profile:[/bold cyan]") - modal_prog.write(f" Name: [green]{server.name}[/green]") - modal_prog.write(f" Icon: [green]{'Present' if server.icon else 'None'}[/green]") - modal_prog.write("") - - modal_prog.set_status(f"Confirm to proceed with Backup of [bold]{len(selected_channels)}[/bold] channels") - modal_prog.show_info(f"[cyan]Backup Channels[/cyan]", f"{len(new_channels)} new, {len(existing_channels)} existing") - - # Show categorized channel lists in the bottom log - if new_channels: - modal_prog.write("[bold green]New Backups to be created:[/bold green]") - for idx, c in enumerate(new_channels): - modal_prog.write(f" {idx+1}. #{c.name}") - - if existing_channels: - action = "Overwritten" if force_overwrite else "Updated" - modal_prog.write(f"[bold yellow]\nExisting backups to be {action}:[/bold yellow]") - for idx, c in enumerate(existing_channels): - modal_prog.write(f" {idx+1}. #{c.name}") - - choice = await modal_prog.phase_wait_confirm(btn_start_label="Start Channel Backup", show_id=False) - if choice == "btn_back": - modal_prog.dismiss() - continue - elif choice == "btn_start_id": - loop = asyncio.get_running_loop() - future = loop.create_future() - def id_callback(res: int | None) -> None: - if not future.done(): - future.set_result(res) - - id_modal = MessageIDInputModal(self.engine.discord_reader, selected_channels[0].id) - self.app.push_screen(id_modal, id_callback) - verified_id = await future - - if verified_id is None: - # User cancelled the ID input - continue - - after_id = verified_id - elif choice == "btn_main_menu": - modal_prog.dismiss() - self.app.switch_screen("config_selection") - return - - # If we are here, proceeding either via Start First or Start from ID (after_id) - if choice == "btn_start_first": - after_id = None - break - - modal_prog.phase_progress() - modal_prog.show_stats() - - # Reset running flag and set cancel callback - self.exporter.is_running = True - modal_prog.cancel_callback = lambda: setattr(self.exporter, "is_running", False) - - total_chans = len(selected_channels) - modal_prog.set_status("Backing up messages...") - modal_prog.write(f"[yellow]Starting backup for {total_chans} channels...[/yellow]") - - accumulated_msgs = 0 - - for i, chan in enumerate(selected_channels): - if not self.exporter.is_running: - modal_prog.write("[bold red]Backup cancelled by user.[/bold red]") - break - await asyncio.sleep(0.01) # Yield to UI thread to keep it responsive - - backup_exists = (self.exporter.export_path / "message_backup" / str(chan.id) / "messages.json").exists() - is_sync = backup_exists and not force_overwrite - - label = "Syncing Backup" if is_sync else "Backing up" - modal_prog.set_item_status(f"[cyan]Processing ({i+1}/{total_chans}): #{chan.name}[/cyan]") - modal_prog.set_progress(i, total_chans) - modal_prog.write(f"[cyan]{label}: {chan.name}[/cyan]") - logger.info(f"{label} for channel: #{chan.name} ({chan.id})") - - _msg_log_counter = 0 - async def update_msg_count(name, count, author_name=None, message_preview=None): - nonlocal _msg_log_counter - modal_prog.update_stats(messages=str(count)) - _msg_log_counter += 1 - if author_name and message_preview and _msg_log_counter % 10 == 0: - modal_prog.write(f"[bold]{author_name}:[/bold] {message_preview}") - - accumulated_msgs = await self.exporter.export_channel_messages( - chan.id, progress_callback=update_msg_count, force=force_overwrite, - accumulated_count=accumulated_msgs, after_id=after_id - ) - accumulated_msgs = await self.exporter.export_threads( - chan.id, progress_callback=update_msg_count, force=force_overwrite, - accumulated_count=accumulated_msgs - ) - - modal_prog.write(f"[green]Completed: {chan.name}[/green]") - - if not self.exporter.is_running: - modal_prog.set_item_status("[bold red]Backup Cancelled.[/bold red]") - modal_prog.phase_report("Message Backup", "stopped", show_back=False) - return - - modal_prog.set_progress(total_chans, total_chans) - modal_prog.set_item_status("[bold green]Backup completed successfully![/bold green]") - - await self.exporter.export_metadata() - modal_prog.write("[bold green]Message backup complete![/bold green]") - logger.info("Message backup operation completed successfully.") - modal_prog.phase_report("Message Backup", show_back=False) - - except Exception as e: - logger.error(f"Message backup failed: {e}\n{traceback.format_exc()}") - modal_prog.write(f"[bold red]Message backup failed: {e}[/bold red]") - modal_prog.phase_report("Message Backup", "error", show_back=False) - finally: - await self.engine.close_connections() - self._validate() - - @work(exclusive=True) - async def run_backup_sync(self) -> None: - modal_prog = ProgressScreen(log_level=self.config.log_level) - self.app.push_screen(modal_prog) - await asyncio.sleep(0.1) - modal_prog.phase_progress() - - try: - modal_prog.set_status("Starting sync...") - await self.engine.discord_reader.start() - await self.exporter.setup() - - # Gather and print summary - server = getattr(self.engine.discord_reader, 'guild', None) - if server: - modal_prog.write(f"[bold cyan]Server Profile to Sync:[/bold cyan]") - modal_prog.write(f" Name: [green]{server.name}[/green]") - modal_prog.write(f" Icon: [green]{'Present' if server.icon else 'None'}[/green]") - modal_prog.write(f" Roles: [green]{len(getattr(server, 'roles', []))}[/green]") - modal_prog.write(f" Emojis: [green]{len(getattr(server, 'emojis', []))}[/green]") - modal_prog.write(f" Channels: [green]{len(getattr(server, 'channels', []))}[/green]") - modal_prog.write("\n[dim]This operation will update the profile and scan existing backed-up channels for new messages.[/dim]") - modal_prog.write("") - - modal_prog.show_info("[bold green]Sync Ready[/bold green]", f"Overview: {len(server.channels) if server else '?'} Channels") - modal_prog.set_status("Awaiting Confirmation to Sync Profile and Messages...") - - choice = await modal_prog.phase_wait_confirm( - btn_start_label="Start Sync", - show_id=False - ) - if choice in ("btn_back", "btn_main_menu"): - modal_prog.dismiss() - self.engine.is_running = False - await self.engine.close_connections() - if choice == "btn_main_menu": - self.app.switch_screen("config_selection") - return - - modal_prog.cancel_callback = lambda: setattr(self.engine, "is_running", False) - modal_prog.phase_progress() - modal_prog.set_status("Updating structure...") - 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 [ - self.engine.discord_reader.CHANNEL_TYPE_TEXT, - self.engine.discord_reader.CHANNEL_TYPE_NEWS, - self.engine.discord_reader.CHANNEL_TYPE_FORUM - ] - ] - - selected_channels = [ - c for c in eligible_channels - if (self.exporter.export_path / "message_backup" / str(c.id) / "messages.json").exists() - ] - - if not selected_channels: - modal_prog.write("[yellow]No existing backups found to sync.[/yellow]") - else: - total_chans = len(selected_channels) - modal_prog.show_stats() - modal_prog.set_status("Syncing messages...") - modal_prog.write(f"[yellow]Syncing {total_chans} channels...[/yellow]") - - # Reset running flag and set cancel callback - self.exporter.is_running = True - modal_prog.cancel_callback = lambda: setattr(self.exporter, "is_running", False) - - accumulated_msgs = 0 - - for i, chan in enumerate(selected_channels): - if not self.exporter.is_running: - modal_prog.write("[bold red]Sync cancelled by user.[/bold red]") - break - await asyncio.sleep(0.01) # Yield to UI thread - - modal_prog.set_item_status(f"[cyan]Syncing ({i+1}/{total_chans}): #{chan.name}[/cyan]") - modal_prog.set_progress(i, total_chans) - modal_prog.write(f"[cyan]Syncing: {chan.name}[/cyan]") - logger.info(f"Syncing backup for channel: #{chan.name} ({chan.id})") - - _msg_log_counter = 0 - async def update_msg_count(name, count, author_name=None, message_preview=None): - nonlocal _msg_log_counter - modal_prog.update_stats(messages=str(count)) - _msg_log_counter += 1 - if author_name and message_preview and _msg_log_counter % 10 == 0: - modal_prog.write(f"[bold]{author_name}:[/bold] {message_preview}") - - accumulated_msgs = await self.exporter.export_channel_messages( - chan.id, progress_callback=update_msg_count, force=False, - accumulated_count=accumulated_msgs - ) - accumulated_msgs = await self.exporter.export_threads( - chan.id, progress_callback=update_msg_count, force=False, - accumulated_count=accumulated_msgs - ) - modal_prog.write(f"[green]Synced: {chan.name}[/green]") - - if not self.exporter.is_running: - modal_prog.set_item_status("[bold red]Sync Cancelled.[/bold red]") - modal_prog.phase_report("Backup Sync", "stopped", show_back=False) - return - - modal_prog.set_progress(total_chans, total_chans) - modal_prog.set_item_status("[bold green]Sync operation complete![/bold green]") - - await self.exporter.export_metadata() - modal_prog.write("[bold green]Sync operation complete![/bold green]") - logger.info("Sync operation completed successfully.") - modal_prog.phase_report("Backup Sync", show_back=False) - - except Exception as e: - logger.error(f"Sync failed: {e}\n{traceback.format_exc()}") - modal_prog.write(f"[bold red]Sync failed: {e}[/bold red]") - modal_prog.phase_report("Backup Sync", "error", show_back=False) - finally: - await self.engine.close_connections() - self._validate() diff --git a/src/ui/main_app.py b/src/ui/main_app.py index c6c377f..9cd8513 100644 --- a/src/ui/main_app.py +++ b/src/ui/main_app.py @@ -370,29 +370,40 @@ class ConfigScreen(Screen): return except Exception as e: logger.error(f"Failed to fetch {platform} servers: {e}") - self.query_one("#btn_fetch_target_servers", Button).variant = "warning" - self.query_one("#inp_target_server", Select).prompt = "Invalid token" + try: + self.query_one("#btn_fetch_target_servers", Button).variant = "warning" + self.query_one("#inp_target_server", Select).prompt = "Invalid token" + except Exception as dom_err: + logger.debug(f"Could not update target server UI elements: {dom_err}") + if not initial: self.notify(f"Failed to fetch {platform} servers: {e}", severity="error") return if not servers: - self.query_one("#btn_fetch_target_servers", Button).variant = "warning" - self.query_one("#inp_target_server", Select).prompt = "No servers found" + try: + self.query_one("#btn_fetch_target_servers", Button).variant = "warning" + self.query_one("#inp_target_server", Select).prompt = "No servers found" + except Exception: + pass if not initial: self.notify(f"No {platform} servers found or invalid token.", severity="warning") return - self.query_one("#btn_fetch_target_servers", Button).variant = "success" - options = [(label, sid) for label, sid in servers] - select_widget = self.query_one("#inp_target_server", Select) - select_widget.prompt = "Select a server" - select_widget.set_options(options) + try: + self.query_one("#btn_fetch_target_servers", Button).variant = "success" + options = [(label, sid) for label, sid in servers] + select_widget = self.query_one("#inp_target_server", Select) + select_widget.prompt = "Select a server" + select_widget.set_options(options) + + # Restore saved value + saved_id = self.config.target_server_id + if saved_id and any(sid == saved_id for _, sid in servers): + select_widget.value = saved_id + except Exception as dom_err: + logger.debug(f"Could not update target server DOM: {dom_err}") - # Restore saved value - saved_id = self.config.target_server_id - if saved_id and any(sid == saved_id for _, sid in servers): - select_widget.value = saved_id def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "btn_fetch_guilds": diff --git a/src/ui/mode_screen.py b/src/ui/mode_screen.py index a1eb2f9..26c8852 100644 --- a/src/ui/mode_screen.py +++ b/src/ui/mode_screen.py @@ -14,8 +14,7 @@ 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 +from src.ui.shuttle_ops import OperationPane class ModeScreen(Screen): @@ -122,13 +121,13 @@ class ModeScreen(Screen): with Container(id="main_outer"): with Container(id="main_container"): if mode == "backup_only": - yield BackupPane(self.cfg_name, self.config_path, id="pane_backup") + yield OperationPane(self.cfg_name, self.config_path, view_mode="backup", id="pane_backup") elif mode == "direct_transfer": - yield ShuttlePane(self.cfg_name, self.config_path, id="pane_migrate") + yield OperationPane(self.cfg_name, self.config_path, view_mode="shuttle", 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 OperationPane(self.cfg_name, self.config_path, view_mode="backup", id="pane_backup") + yield OperationPane(self.cfg_name, self.config_path, view_mode="shuttle", id="pane_migrate") with Vertical(id="mode_footer"): yield Rule(id="footer_rule") @@ -155,9 +154,7 @@ class ModeScreen(Screen): self.app.push_screen(ModeScreen(self.cfg_name, self.config_path)) else: self.config = new_cfg - for pane in self.query(BackupPane): - pane.reload_config() - for pane in self.query(ShuttlePane): + for pane in self.query(OperationPane): pane.reload_config() self.app.push_screen(ConfigScreen(self.cfg_name, self.config_path), reload_screen) elif bid == "btn_switch": diff --git a/src/ui/shuttle_ops.py b/src/ui/shuttle_ops.py index ca932b2..363e0a8 100644 --- a/src/ui/shuttle_ops.py +++ b/src/ui/shuttle_ops.py @@ -1,6 +1,6 @@ """ -ShuttlePane – self-contained shuttle (migration) operations widget. -Embedded inside ModeScreen's "Migrate" tab. +OperationPane – unified operations widget for both Backup and Migration. +Handles Discord backups and multi-platform migrations. """ import asyncio @@ -20,8 +20,10 @@ from textual import work from src.core.configuration import load_config from src.core.base import MigrationContext from src.core.audit import log_audit_event +from src.core.exporter import DiscordExporter from src.ui.modals import ( ProgressScreen, SubMenuModal, ChannelPickerScreen, OptionSelectModal, MessageIDInputModal, + ChannelSelectScreen ) import src.fluxer.roles_permissions as fluxer_roles @@ -78,69 +80,87 @@ class RateLimitHandler(logging.Handler): pass -class ShuttlePane(Container): - """Shuttle (migration) operations pane — clone, roles, emojis, metadata, messages, danger zone.""" +class OperationPane(Container): + """Unified operations pane — Backup, Clone, Sync, Migrate.""" DEFAULT_CSS = """ - ShuttlePane { height: auto; width: 100%; } - ShuttlePane #sp_info { + OperationPane { height: auto; width: 100%; } + OperationPane #op_info { height: auto; border: tall yellow; padding: 1; margin-bottom: 1; layout: vertical; } - #sp_info_split { height: auto; layout: horizontal; width: 100%; margin-bottom: 1; } + #op_info_split { height: auto; layout: horizontal; width: 100%; margin-bottom: 1; } .info_pane { width: 1fr; height: auto; } .info_pane Label { width: 100%; } .pane_header { text-style: bold; color: $accent; margin-bottom: 1; } .pane_status { text-style: bold; margin-top: 1; } - #sp_info_split Rule { height: 100%; margin: 0 2; color: $accent; } - #sp_lbl_status { display: none; } + #op_info_split Rule { height: 100%; margin: 0 2; color: $accent; } + #op_lbl_backup { display: none; } - ShuttlePane #sp_actions { height: auto; } - ShuttlePane #sp_actions Button { width: 100%; margin-bottom: 1; } + OperationPane #op_actions { height: auto; } + OperationPane #op_actions Button { width: 100%; margin-bottom: 1; } #footer_rule { margin: 0; } """ - def __init__(self, cfg_name: str, cfg_path: Path, *args, **kwargs): + def __init__(self, cfg_name: str, cfg_path: Path, view_mode: str = "shuttle", *args, **kwargs): super().__init__(*args, **kwargs) self.cfg_name = cfg_name self.config_path = cfg_path + self.view_mode = view_mode # "backup" or "shuttle" (migrate) self.config = load_config(cfg_path) self.target_platform = self.config.target_platform or "fluxer" self.engine: MigrationContext | None = None + self.exporter: DiscordExporter | None = None self.validation_results: dict = {} self.tokens_valid = False self.permissions_complete = False + self.has_backup = False def compose(self) -> ComposeResult: with VerticalScroll(): - with Vertical(id="sp_info"): - with Horizontal(id="sp_info_split"): + with Vertical(id="op_info"): + with Horizontal(id="op_info_split"): with Vertical(classes="info_pane"): yield Label("Discord", classes="pane_header") - yield Label("Server: [yellow]Loading...[/yellow]", id="sp_lbl_d_server") - yield Label("Bot: [yellow]Loading...[/yellow]", id="sp_lbl_d_bot") - yield Label("Status: [yellow]Validating...[/yellow]", id="sp_lbl_d_status", classes="pane_status") + yield Label("Server: [yellow]Loading...[/yellow]", id="op_lbl_d_server") + if self.view_mode == "backup": + yield Label("Source: [yellow]Loading...[/yellow]", id="op_lbl_d_bot") + else: + yield Label("Bot: [yellow]Loading...[/yellow]", id="op_lbl_d_bot") + yield Label("Status: [yellow]Validating...[/yellow]", id="op_lbl_d_status", classes="pane_status") - yield Rule(orientation="vertical") + yield Rule(orientation="vertical", id="op_vrule") - with Vertical(classes="info_pane"): - yield Label("Target", id="sp_lbl_t_header", classes="pane_header") - yield Label("Community: [yellow]Loading...[/yellow]", id="sp_lbl_t_comm") - yield Label("Bot: [yellow]Loading...[/yellow]", id="sp_lbl_t_bot") - yield Label("Status: [yellow]Validating...[/yellow]", id="sp_lbl_t_status", classes="pane_status") + with Vertical(classes="info_pane", id="op_target_pane"): + yield Label("Target", id="op_lbl_t_header", classes="pane_header") + yield Label("Community: [yellow]Loading...[/yellow]", id="op_lbl_t_comm") + yield Label("Bot: [yellow]Loading...[/yellow]", id="op_lbl_t_bot") + yield Label("Status: [yellow]Validating...[/yellow]", id="op_lbl_t_status", classes="pane_status") - yield Label("", id="sp_lbl_status") - with Vertical(id="sp_actions"): - yield Button("Clone Server Template", id="sp_clone", disabled=True, tooltip="Clone server roles, categories, and channels to the target community") - yield Button("Sync Server Settings", id="sp_sync", disabled=True, tooltip="Sync emojis, stickers, server name, and icon to the target community") - yield Button("Migrate Message History", id="sp_messages", disabled=True, variant="primary", tooltip="Migrate message history from Discord to the target platform") - yield Rule(id="footer_rule") - yield Button("Danger Zone ⚠", id="sp_danger", variant="error", disabled=True, flat=True, tooltip="Dangerous operations:\ndelete channels, roles, emojis on target\n(use with caution)") + yield Label("", id="op_lbl_backup") + + with Vertical(id="op_actions"): + if self.view_mode == "backup": + yield Button("Backup Server Profile", id="op_backup_profile", disabled=True, tooltip="Backup Discord server roles, emojis, and channel structure") + yield Button("Backup Channel Messages", id="op_backup_msgs", disabled=True, variant="primary", tooltip="Select and backup message history from text channels") + yield Button("Update Existing Backup", id="op_backup_sync", disabled=True, variant="success", tooltip="Scan for new messages\n& Update existing backup") + yield Rule(id="op_backup_stats_rule") + yield Button("Backup Stats", id="op_backup_stats", variant="warning", flat=True, disabled=True, tooltip="View detailed statistics, storage, and entity metrics for the current backup profile") + else: + yield Button("Clone Server Template", id="op_clone", disabled=True, tooltip="Clone server roles, categories, and channels to the target community") + yield Button("Sync Server Settings", id="op_sync", disabled=True, tooltip="Sync emojis, stickers, server name, and icon to the target community") + yield Button("Migrate Message History", id="op_messages", disabled=True, variant="primary", tooltip="Migrate message history from Discord to the target platform") + yield Rule(id="footer_rule") + yield Button("Danger Zone ⚠", id="op_danger", variant="error", disabled=True, flat=True, tooltip="Dangerous operations:\ndelete channels, roles, emojis on target\n(use with caution)") def on_mount(self) -> None: self._rebuild_engine() - # run_validate is handled by the writer's internal caching now to prevent log flooding self.run_validate() + def on_show(self) -> None: + """Re-validate when the pane regains visibility.""" + if self.view_mode == "backup" or self.config.tool_mode == "backup_transfer": + self.run_validate() + def reload_config(self) -> None: self.config = load_config(self.config_path) self.target_platform = self.config.target_platform or "fluxer" @@ -153,6 +173,32 @@ class ShuttlePane(Container): def _rebuild_engine(self): source = "backup" if self.config.tool_mode == "backup_transfer" else "live" self.engine = MigrationContext(self.config, self.target_platform, source_mode=source, base_dir=self._base_dir()) + if self.view_mode == "backup": + self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=self._base_dir()) + + def _get_backup_info(self) -> str | None: + import json + if not self.config or not self.config.discord_server_id: + return None + + target_dir = Path(self._base_dir()) / f"DISCORD_BACKUP-{self.config.discord_server_id}" + if not target_dir.exists(): + return None + + profile_file = target_dir / "server_profile" / "profile.json" + if not profile_file.exists(): + return None + try: + from datetime import datetime + 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 # ── labels ──────────────────────────────────────────────────────────── @@ -169,69 +215,107 @@ class ShuttlePane(Container): s_disp = f'[green]"{d_name}"[/green]' b_disp = f'[green]{d_bot}[/green]' elif v.get("discord_token") is False: - if self.config.tool_mode == "backup_transfer": + if self.config.tool_mode == "backup_transfer" and self.view_mode == "shuttle": s_disp, b_disp = "[red]NOT FOUND[/red]", "[red]NOT FOUND[/red]" else: s_disp, b_disp = "[red]INVALID TOKEN[/red]", "[red]INVALID TOKEN[/red]" else: s_disp, b_disp = "[red]NOT SET UP[/red]", "[red]NOT SET UP[/red]" - self.query_one("#sp_lbl_d_server", Label).update(f"Server: {s_disp}") - if self.config.tool_mode == "backup_transfer": - self.query_one("#sp_lbl_d_bot", Label).update(f"Source: {b_disp}") + self.query_one("#op_lbl_d_server", Label).update(f"Server: {s_disp}") + if self.view_mode == "backup": + self.query_one("#op_lbl_d_bot", Label).update(f"Source: {b_disp}") + elif self.config.tool_mode == "backup_transfer": + self.query_one("#op_lbl_d_bot", Label).update(f"Source: {b_disp}") else: - self.query_one("#sp_lbl_d_bot", Label).update(f"Bot: {b_disp}") + self.query_one("#op_lbl_d_bot", Label).update(f"Bot: {b_disp}") # Discord Side Status - if v.get("discord_token") and v.get("discord_server"): - d_status = "[green][VALID][/green]" + d_err = v.get("discord_error") + di = v.get("discord_intents", {}) + dp = v.get("discord_permissions", {}) + + d_missing = [] + if d_err is None and v.get("discord_token") and v.get("discord_server"): + if not di.get("message_content"): d_missing.append("Message Intent") + if not dp.get("view_channel"): d_missing.append("View Channel") + if not dp.get("read_message_history"): d_missing.append("Msg History") + + if v.get("discord_token") and v.get("discord_server") and not d_missing: + d_status = "[green]VALID[/green]" elif v.get("discord_timeout"): - d_status = "[red][TIMEOUT][/red]" + d_status = "[red]TIMEOUT[/red]" + elif d_err: + d_status = f"[red]{d_err}[/red]" + elif d_missing: + d_status = f"[yellow]MISSING: {', '.join(d_missing)}[/yellow]" else: - d_status = "[red][INVALID][/red]" - self.query_one("#sp_lbl_d_status", Label).update(f"Status: {d_status}") + d_status = "[red]INVALID[/red]" + self.query_one("#op_lbl_d_status", Label).update(f"Status: {d_status}") - # Target - plat = "Fluxer" if self.target_platform == "fluxer" else "Stoat" - t_name = v.get("target_community_name") - t_bot = v.get("target_bot_name") - - self.query_one("#sp_lbl_t_header", Label).update(plat) - - if v.get("target_timeout"): - c_disp, tb_disp = "[red]TIMEOUT[/red]", "[red]TIMEOUT[/red]" - elif v.get("target_token") and v.get("target_community"): - c_disp = f'[green]"{t_name}"[/green]' - tb_disp = f'[green]{t_bot}[/green]' - elif v.get("target_token") is False: - c_disp, tb_disp = "[red]INVALID TOKEN[/red]", "[red]INVALID TOKEN[/red]" - else: - c_disp, tb_disp = "[red]NOT SET UP[/red]", "[red]NOT SET UP[/red]" + # Target / Backup Info + if self.view_mode == "backup": + backup_text = v.get("backup_info_text", "") + self.query_one("#op_lbl_backup", Label).update(backup_text) + self.query_one("#op_lbl_backup", Label).display = bool(backup_text) - self.query_one("#sp_lbl_t_comm", Label).update(f"Community: {c_disp}") - self.query_one("#sp_lbl_t_bot", Label).update(f"Bot: {tb_disp}") - - # Target Side Status - if v.get("target_token") and v.get("target_community"): - t_status = "[green][VALID][/green]" - elif v.get("target_timeout"): - t_status = "[red][TIMEOUT][/red]" + # Hide target side in backup mode completely + self.query_one("#op_vrule").display = False + self.query_one("#op_target_pane").display = False + + enabled = (v.get("discord_token") and v.get("discord_server") and not d_missing) + for bid in ("#op_backup_profile", "#op_backup_msgs", "#op_backup_sync"): + self.query_one(bid, Button).disabled = not enabled + + self.query_one("#op_backup_stats", Button).display = self.has_backup + self.query_one("#op_backup_stats", Button).disabled = not self.has_backup + self.query_one("#op_backup_stats_rule", Rule).display = self.has_backup else: - t_status = "[red][INVALID][/red]" - self.query_one("#sp_lbl_t_status", Label).update(f"Status: {t_status}") + # Target + plat = "Fluxer" if self.target_platform == "fluxer" else "Stoat" + t_name = v.get("target_community_name") + t_bot = v.get("target_bot_name") + + self.query_one("#op_lbl_t_header", Label).update(plat) + + if v.get("target_timeout"): + c_disp, tb_disp = "[red]TIMEOUT[/red]", "[red]TIMEOUT[/red]" + elif v.get("target_token") and v.get("target_community"): + c_disp = f'[green]"{t_name}"[/green]' + tb_disp = f'[green]{t_bot}[/green]' + elif v.get("target_token") is False: + c_disp, tb_disp = "[red]INVALID TOKEN[/red]", "[red]INVALID TOKEN[/red]" + else: + c_disp, tb_disp = "[red]NOT SET UP[/red]", "[red]NOT SET UP[/red]" + + self.query_one("#op_lbl_t_comm", Label).update(f"Community: {c_disp}") + self.query_one("#op_lbl_t_bot", Label).update(f"Bot: {tb_disp}") - # Status - if not self.tokens_valid: - val = "[red][INVALID][/red]" - elif not self.permissions_complete: - val = "[yellow][MISSING PERMISSIONS][/yellow]" - else: - val = "[green][VALID][/green]" - self.query_one("#sp_lbl_status", Label).update(f"Status: {val}") + # Target Side Status + t_err = v.get("target_error") + tp = v.get("target_permissions", {}) + + t_missing = [] + if t_err is None and v.get("target_token") and v.get("target_community"): + if tp: + t_missing = [k.replace('_', ' ').title() for k, val_p in tp.items() if not val_p] - # Buttons - for bid in ("#sp_clone", "#sp_sync", "#sp_messages", "#sp_danger"): - self.query_one(bid, Button).disabled = not self.tokens_valid + if v.get("target_token") and v.get("target_community") and not t_missing: + t_status = "[green]VALID[/green]" + elif v.get("target_timeout"): + t_status = "[red]TIMEOUT[/red]" + elif t_err: + t_status = f"[red]{t_err}[/red]" + elif t_missing: + # Show first two for brevity + t_status = f"[yellow]MISSING: {', '.join(t_missing[:2])}{'...' if len(t_missing)>2 else ''}[/yellow]" + else: + t_status = "[red]INVALID[/red]" + self.query_one("#op_lbl_t_status", Label).update(f"Status: {t_status}") + + # Buttons + for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_danger"): + self.query_one(bid, Button).disabled = not self.tokens_valid # ── validation ──────────────────────────────────────────────────────── @@ -241,13 +325,17 @@ class ShuttlePane(Container): "discord_token": False, "discord_bot_name": None, "discord_server": False, "discord_server_name": None, "discord_intents": {}, "discord_permissions": {}, + "discord_error": None, "target_token": False, "target_bot_name": None, "target_community": False, "target_community_name": None, "target_permissions": {}, + "target_error": None, "discord_timeout": False, "target_timeout": False, + "backup_info_text": "", } self.tokens_valid = False self.permissions_complete = False + self.has_backup = False fillers = [ "DISCORD_BOT_TOKEN", "FLUXER_BOT_TOKEN", "STOAT_BOT_TOKEN", @@ -255,16 +343,21 @@ class ShuttlePane(Container): "000000000000000000", "DISCORD_SERVER_ID", "FLUXER_COMMUNITY_ID", "STOAT_SERVER_ID", "TARGET_SERVER_ID", "", None, ] - if self.config.tool_mode == "backup_transfer": - d_dummy = self.config.discord_server_id in fillers - else: + + # Check dummies + d_dummy = False + if self.view_mode == "backup" or self.config.tool_mode != "backup_transfer": d_dummy = self.config.discord_bot_token in fillers or self.config.discord_server_id in fillers + else: + # In shuttle mode of backup_transfer, we look at the source backup dir + d_dummy = self.config.discord_server_id in fillers + t_dummy = (self.config.target_bot_token or "") in fillers or (self.config.target_server_id or "") in fillers tasks = {} if not d_dummy: tasks["discord"] = asyncio.create_task(self.engine.discord_reader.validate()) - if not t_dummy: + if not t_dummy and self.view_mode == "shuttle": tasks["target"] = asyncio.create_task(self.engine.writer.validate()) all_tasks = list(tasks.values()) @@ -282,6 +375,7 @@ class ShuttlePane(Container): self.validation_results["discord_server_name"] = res.get("server_name") self.validation_results["discord_intents"] = res.get("intents", {}) self.validation_results["discord_permissions"] = res.get("permissions", {}) + self.validation_results["discord_error"] = res.get("error_reason") elif dt and dt not in done: self.validation_results["discord_timeout"] = True dt.cancel() @@ -294,15 +388,24 @@ class ShuttlePane(Container): self.validation_results["target_community"] = res.get("community", False) self.validation_results["target_community_name"] = res.get("community_name") self.validation_results["target_permissions"] = res.get("permissions", {}) + self.validation_results["target_error"] = res.get("error_reason") elif tt and tt not in done: self.validation_results["target_timeout"] = True tt.cancel() 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) + + if self.view_mode == "backup": + self.tokens_valid = bool(discord_ok) + info = self._get_backup_info() + if info: + self.validation_results["backup_info_text"] = f"Last backup: [cyan]{info}[/cyan]" + self.has_backup = True + else: + target_ok = self.validation_results.get("target_token") and self.validation_results.get("target_community") + self.tokens_valid = bool(discord_ok and target_ok) - if self.tokens_valid: + if self.tokens_valid and self.view_mode == "shuttle": srv_id = self.config.target_server_id srv_name = self.validation_results.get("target_community_name", "unknown") if srv_id and srv_name: @@ -315,14 +418,16 @@ class ShuttlePane(Container): dp = self.validation_results.get("discord_permissions", {}) if not all([di.get("message_content"), dp.get("view_channel"), dp.get("read_message_history")]): self.permissions_complete = False - tp = self.validation_results.get("target_permissions", {}) - if tp and not all(tp.values()): - self.permissions_complete = False + + if self.view_mode == "shuttle": + tp = self.validation_results.get("target_permissions", {}) + if tp and not all(tp.values()): + self.permissions_complete = False except Exception: pass finally: for t in all_tasks: - if not t.done(): + if t and not t.done(): t.cancel() self._update_info_labels() @@ -331,16 +436,30 @@ class ShuttlePane(Container): def on_button_pressed(self, event: Button.Pressed) -> None: bid = event.button.id - if not bid or not bid.startswith("sp_"): + if not bid or not bid.startswith("op_"): return - if bid == "sp_clone": + + # Migration Routing + if bid == "op_clone": self._open_clone_menu() - elif bid == "sp_sync": + elif bid == "op_sync": self._open_sync_menu() - elif bid == "sp_messages": + elif bid == "op_messages": self.run_migrate_messages() - elif bid == "sp_danger": + elif bid == "op_danger": self._open_danger_menu() + + # Backup Routing + elif bid == "op_backup_profile": + self.run_backup_profile() + elif bid == "op_backup_msgs": + self.run_backup_messages() + elif bid == "op_backup_sync": + self.run_backup_sync() + elif bid == "op_backup_stats": + from src.ui.backup_stats import BackupStatsScreen + target_dir = Path(self._base_dir()) / f"DISCORD_BACKUP-{self.config.discord_server_id}" + self.app.push_screen(BackupStatsScreen(self.cfg_name, target_dir)) # ── (1) clone server template (combined) ───────────────────────────── @@ -1480,3 +1599,373 @@ class ShuttlePane(Container): counts = await danger_delete_all_emojis_and_stickers(self.engine, progress_callback=on_deleted) modal.write(f"[bold green]Success! {counts.get('emojis', 0)} emojis, {counts.get('stickers', 0)} stickers deleted.[/bold green]") await log_audit_event(self.engine, "Danger Zone: Assets Wiped", f"Deleted {counts.get('emojis', 0)} emojis and {counts.get('stickers', 0)} stickers.") + + # ── backup workers ─────────────────────────────────────────────────── + + @work(exclusive=True) + async def run_backup_profile(self) -> None: + modal = ProgressScreen(log_level=self.config.log_level) + self.app.push_screen(modal) + await asyncio.sleep(0.1) + modal.phase_progress() + + try: + modal.set_status("Starting readers...") + await self.engine.discord_reader.start() + await self.exporter.setup() + + # Gather and print summary + server = getattr(self.engine.discord_reader, 'guild', None) + if server: + modal.write(f"[bold cyan]Server Profile to Backup:[/bold cyan]") + modal.write(f" Name: [green]{server.name}[/green]") + modal.write(f" Icon: [green]{'Present' if server.icon else 'None'}[/green]") + modal.write(f" Roles: [green]{len(getattr(server, 'roles', []))}[/green]") + modal.write(f" Emojis: [green]{len(getattr(server, 'emojis', []))}[/green]") + modal.write(f" Channels: [green]{len(getattr(server, 'channels', []))}[/green]") + modal.write("") + + modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) + modal.phase_progress() + modal.set_status("Exporting Server Structure...") + modal.write("[yellow]Backing up server profile & skeleton...[/yellow]") + await self.exporter.export_metadata() + await self.exporter.download_server_assets() + + modal.write("Exporting structure...") + _, cat_count, chan_count = await self.exporter.export_channels_structure() + + modal.write("Exporting roles...") + roles = await self.exporter.export_roles() + + modal.write("Exporting assets...") + e_count, s_count = await self.exporter.export_assets() + + modal.write(f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]") + modal.write(f"- {len(roles)} roles, {e_count} emojis, {s_count} stickers.") + modal.phase_report("Profile Backup", show_back=False) + + except self.engine.discord_reader.Forbidden as e: + modal.write(f"[bold red]Backup failed: {e}[/bold red]") + modal.phase_report("Profile Backup", "error") + except Exception as e: + modal.write(f"[bold red]Error: {e}[/bold red]") + modal.phase_report("Profile Backup", "error") + finally: + await self.engine.close_connections() + self.run_validate() + + @work(exclusive=True) + async def run_backup_messages(self) -> None: + modal_prog = ProgressScreen(log_level=self.config.log_level) + self.app.push_screen(modal_prog) + await asyncio.sleep(0.1) + + try: + 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 [ + self.engine.discord_reader.CHANNEL_TYPE_TEXT, + self.engine.discord_reader.CHANNEL_TYPE_NEWS, + self.engine.discord_reader.CHANNEL_TYPE_FORUM + ] + ] + + if not eligible_channels: + modal_prog.write("[yellow]No text/news channels found to backup.[/yellow]") + modal_prog.allow_close() + return + + any_found = False + backed_up_ids = set() + for chan in eligible_channels: + if (self.exporter.export_path / "message_backup" / str(chan.id) / "messages.json").exists(): + any_found = True + backed_up_ids.add(chan.id) + + self.app.pop_screen() + + while True: + 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.push_screen( + ChannelSelectScreen(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] + + # Phase 2: Confirmation + modal_prog = ProgressScreen(log_level=self.config.log_level) # Re-instantiate to avoid Textual re-push UI freeze + self.app.push_screen(modal_prog) + await asyncio.sleep(0.1) + + new_channels = [c for c in selected_channels if c.id not in backed_up_ids] + existing_channels = [c for c in selected_channels if c.id in backed_up_ids] + + server = getattr(self.engine.discord_reader, 'guild', None) + if server: + modal_prog.write(f"[bold cyan]Server Profile:[/bold cyan]") + modal_prog.write(f" Name: [green]{server.name}[/green]") + modal_prog.write(f" Icon: [green]{'Present' if server.icon else 'None'}[/green]") + modal_prog.write("") + + modal_prog.set_status(f"Confirm to proceed with Backup of [bold]{len(selected_channels)}[/bold] channels") + modal_prog.show_info(f"[cyan]Backup Channels[/cyan]", f"{len(new_channels)} new, {len(existing_channels)} existing") + + # Show categorized channel lists in the bottom log + if new_channels: + modal_prog.write("[bold green]New Backups to be created:[/bold green]") + for idx, c in enumerate(new_channels): + modal_prog.write(f" {idx+1}. #{c.name}") + + if existing_channels: + action = "Overwritten" if force_overwrite else "Updated" + modal_prog.write(f"[bold yellow]\nExisting backups to be {action}:[/bold yellow]") + for idx, c in enumerate(existing_channels): + modal_prog.write(f" {idx+1}. #{c.name}") + + choice = await modal_prog.phase_wait_confirm(btn_start_label="Start Channel Backup", show_id=False) + if choice == "btn_back": + modal_prog.dismiss() + continue + elif choice == "btn_start_id": + loop = asyncio.get_running_loop() + future = loop.create_future() + def id_callback(res: int | None) -> None: + if not future.done(): + future.set_result(res) + + id_modal = MessageIDInputModal(self.engine.discord_reader, selected_channels[0].id) + self.app.push_screen(id_modal, id_callback) + verified_id = await future + + if verified_id is None: + # User cancelled the ID input + continue + + after_id = verified_id + elif choice == "btn_main_menu": + modal_prog.dismiss() + self.app.switch_screen("config_selection") + return + + # If we are here, proceeding either via Start First or Start from ID (after_id) + if choice == "btn_start_first": + after_id = None + break + + modal_prog.phase_progress() + modal_prog.show_stats() + + # Reset running flag and set cancel callback + self.exporter.is_running = True + modal_prog.cancel_callback = lambda: setattr(self.exporter, "is_running", False) + + total_chans = len(selected_channels) + modal_prog.set_status("Backing up messages...") + modal_prog.write(f"[yellow]Starting backup for {total_chans} channels...[/yellow]") + + accumulated_msgs = 0 + + for i, chan in enumerate(selected_channels): + if not self.exporter.is_running: + modal_prog.write("[bold red]Backup cancelled by user.[/bold red]") + break + await asyncio.sleep(0.01) # Yield to UI thread to keep it responsive + + backup_exists = (self.exporter.export_path / "message_backup" / str(chan.id) / "messages.json").exists() + is_sync = backup_exists and not force_overwrite + + label = "Syncing Backup" if is_sync else "Backing up" + modal_prog.set_item_status(f"[cyan]Processing ({i+1}/{total_chans}): #{chan.name}[/cyan]") + modal_prog.set_progress(i, total_chans) + modal_prog.write(f"[cyan]{label}: {chan.name}[/cyan]") + logger.info(f"{label} for channel: #{chan.name} ({chan.id})") + + _msg_log_counter = 0 + async def update_msg_count(name, count, author_name=None, message_preview=None): + nonlocal _msg_log_counter + modal_prog.update_stats(messages=str(count)) + _msg_log_counter += 1 + if author_name and message_preview and _msg_log_counter % 10 == 0: + modal_prog.write(f"[bold]{author_name}:[/bold] {message_preview}") + + accumulated_msgs = await self.exporter.export_channel_messages( + chan.id, progress_callback=update_msg_count, force=force_overwrite, + accumulated_count=accumulated_msgs, after_id=after_id + ) + accumulated_msgs = await self.exporter.export_threads( + chan.id, progress_callback=update_msg_count, force=force_overwrite, + accumulated_count=accumulated_msgs + ) + + modal_prog.write(f"[green]Completed: {chan.name}[/green]") + + if not self.exporter.is_running: + modal_prog.set_item_status("[bold red]Backup Cancelled.[/bold red]") + modal_prog.phase_report("Message Backup", "stopped", show_back=False) + return + + modal_prog.set_progress(total_chans, total_chans) + modal_prog.set_item_status("[bold green]Backup completed successfully![/bold green]") + + await self.exporter.export_metadata() + modal_prog.write("[bold green]Message backup complete![/bold green]") + logger.info("Message backup operation completed successfully.") + modal_prog.phase_report("Message Backup", show_back=False) + + except Exception as e: + logger.error(f"Message backup failed: {e}\n{traceback.format_exc()}") + modal_prog.write(f"[bold red]Message backup failed: {e}[/bold red]") + modal_prog.phase_report("Message Backup", "error", show_back=False) + finally: + await self.engine.close_connections() + self.run_validate() + + @work(exclusive=True) + async def run_backup_sync(self) -> None: + modal_prog = ProgressScreen(log_level=self.config.log_level) + self.app.push_screen(modal_prog) + await asyncio.sleep(0.1) + modal_prog.phase_progress() + + try: + modal_prog.set_status("Starting sync...") + await self.engine.discord_reader.start() + await self.exporter.setup() + + # Gather and print summary + server = getattr(self.engine.discord_reader, 'guild', None) + if server: + modal_prog.write(f"[bold cyan]Server Profile to Sync:[/bold cyan]") + modal_prog.write(f" Name: [green]{server.name}[/green]") + modal_prog.write(f" Icon: [green]{'Present' if server.icon else 'None'}[/green]") + modal_prog.write(f" Roles: [green]{len(getattr(server, 'roles', []))}[/green]") + modal_prog.write(f" Emojis: [green]{len(getattr(server, 'emojis', []))}[/green]") + modal_prog.write(f" Channels: [green]{len(getattr(server, 'channels', []))}[/green]") + modal_prog.write("\n[dim]This operation will update the profile and scan existing backed-up channels for new messages.[/dim]") + modal_prog.write("") + + modal_prog.show_info("[bold green]Sync Ready[/bold green]", f"Overview: {len(server.channels) if server else '?'} Channels") + modal_prog.set_status("Awaiting Confirmation to Sync Profile and Messages...") + + choice = await modal_prog.phase_wait_confirm( + btn_start_label="Start Sync", + show_id=False + ) + if choice in ("btn_back", "btn_main_menu"): + modal_prog.dismiss() + self.engine.is_running = False + await self.engine.close_connections() + if choice == "btn_main_menu": + self.app.switch_screen("config_selection") + return + + modal_prog.cancel_callback = lambda: setattr(self.engine, "is_running", False) + modal_prog.phase_progress() + modal_prog.set_status("Updating structure...") + 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 [ + self.engine.discord_reader.CHANNEL_TYPE_TEXT, + self.engine.discord_reader.CHANNEL_TYPE_NEWS, + self.engine.discord_reader.CHANNEL_TYPE_FORUM + ] + ] + + selected_channels = [ + c for c in eligible_channels + if (self.exporter.export_path / "message_backup" / str(c.id) / "messages.json").exists() + ] + + if not selected_channels: + modal_prog.write("[yellow]No existing backups found to sync.[/yellow]") + else: + total_chans = len(selected_channels) + modal_prog.show_stats() + modal_prog.set_status("Syncing messages...") + modal_prog.write(f"[yellow]Syncing {total_chans} channels...[/yellow]") + + # Reset running flag and set cancel callback + self.exporter.is_running = True + modal_prog.cancel_callback = lambda: setattr(self.exporter, "is_running", False) + + accumulated_msgs = 0 + + for i, chan in enumerate(selected_channels): + if not self.exporter.is_running: + modal_prog.write("[bold red]Sync cancelled by user.[/bold red]") + break + await asyncio.sleep(0.01) # Yield to UI thread + + modal_prog.set_item_status(f"[cyan]Syncing ({i+1}/{total_chans}): #{chan.name}[/cyan]") + modal_prog.set_progress(i, total_chans) + modal_prog.write(f"[cyan]Syncing: {chan.name}[/cyan]") + logger.info(f"Syncing backup for channel: #{chan.name} ({chan.id})") + + _msg_log_counter = 0 + async def update_msg_count(name, count, author_name=None, message_preview=None): + nonlocal _msg_log_counter + modal_prog.update_stats(messages=str(count)) + _msg_log_counter += 1 + if author_name and message_preview and _msg_log_counter % 10 == 0: + modal_prog.write(f"[bold]{author_name}:[/bold] {message_preview}") + + accumulated_msgs = await self.exporter.export_channel_messages( + chan.id, progress_callback=update_msg_count, force=False, + accumulated_count=accumulated_msgs + ) + accumulated_msgs = await self.exporter.export_threads( + chan.id, progress_callback=update_msg_count, force=False, + accumulated_count=accumulated_msgs + ) + modal_prog.write(f"[green]Synced: {chan.name}[/green]") + + if not self.exporter.is_running: + modal_prog.set_item_status("[bold red]Sync Cancelled.[/bold red]") + modal_prog.phase_report("Backup Sync", "stopped", show_back=False) + return + + modal_prog.set_progress(total_chans, total_chans) + modal_prog.set_item_status("[bold green]Sync operation complete![/bold green]") + + await self.exporter.export_metadata() + modal_prog.write("[bold green]Sync operation complete![/bold green]") + logger.info("Sync operation completed successfully.") + modal_prog.phase_report("Backup Sync", show_back=False) + + except Exception as e: + logger.error(f"Sync failed: {e}\n{traceback.format_exc()}") + modal_prog.write(f"[bold red]Sync failed: {e}[/bold red]") + modal_prog.phase_report("Backup Sync", "error", show_back=False) + finally: + await self.engine.close_connections() + self.run_validate()