""" OperationPane – unified operations widget for both Backup and Migration. Handles Discord backups and multi-platform migrations. """ import asyncio import logging import re import time import aiohttp import traceback from pathlib import Path from typing import Any, Optional, Union, List, Dict, Callable from textual.app import ComposeResult from textual.containers import Container, Vertical, Horizontal, VerticalScroll from textual.widgets import Button, Label, Rule, LoadingIndicator 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 import src.stoat.roles_permissions as stoat_roles import src.fluxer.emoji_stickers as fluxer_emoji_stickers import src.stoat.emoji_stickers as stoat_emoji_stickers import src.fluxer.server_metadata as fluxer_metadata import src.stoat.server_metadata as stoat_metadata import src.fluxer.migrate_message as fluxer_migrate import src.stoat.migrate_message as stoat_migrate # --------------------------------------------------------------------------- # Rate-limit handler (global, shared with logging subsystem) # --------------------------------------------------------------------------- global_rate_limit_msg = "" global_rate_limit_expires = 0.0 logger = logging.getLogger(__name__) class RateLimitHandler(logging.Handler): """Intercepts library logs to capture rate-limit messages.""" def __init__(self): super().__init__() def emit(self, record): 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 class OperationPane(Container): """Unified operations pane — Backup, Clone, Sync, Migrate.""" DEFAULT_CSS = """ OperationPane { height: auto; width: 100%; } OperationPane #op_info { height: auto; border: tall yellow; padding: 1 1 0 1; margin-bottom: 1; layout: vertical; } #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; } .status_row { height: auto; min-height: 1; width: 100%; margin-top: 1; } .status_row Label { width: 100%; height: auto; } .status_row LoadingIndicator { width: 8; height: 1; margin: 0; min-width: 8; } .pane_status { text-style: bold; } #op_info_split Rule { height: 100%; margin: 0 2; color: $accent; } #op_lbl_backup { display: none; } 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, 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 = { "discord_validating": True, "target_validating": True, } self.tokens_valid = False self.permissions_complete = False self.has_backup = False def compose(self) -> ComposeResult: with VerticalScroll(): 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="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") with Horizontal(classes="status_row"): yield LoadingIndicator(id="op_d_loader") yield Label("", id="op_lbl_d_status", classes="pane_status") yield Rule(orientation="vertical", id="op_vrule") 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") with Horizontal(classes="status_row"): yield LoadingIndicator(id="op_t_loader") yield Label("", id="op_lbl_t_status", classes="pane_status") yield Label("", id="op_lbl_backup") with Vertical(id="op_actions"): if self.view_mode == "backup": yield Button("Backup Channel Messages", id="op_backup_msgs", disabled=True, 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="primary", 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() self._update_info_labels() # Wait for DOM to be stable before first validation self.call_after_refresh(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": if self.view_mode == "shuttle": # Re-run path discovery in case a new backup was just made 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: # If the config file is in the root, our base directory is current (.) if str(self.config_path) == "reaper_config.yaml": return "." return f"ReaperFiles-{self.cfg_name}" def _rebuild_engine(self): # In backup_transfer mode, the Backup tab reads from LIVE discord, # while the Shuttle tab reads from the LOCAL BACKUP. if self.view_mode == "backup": source = "live" else: 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: 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 db_file = target_dir / "backup.db" if not db_file.exists(): return None try: from datetime import datetime from src.core.backup_database import BackupDatabase db = BackupDatabase(db_file) profile = db.get_guild_profile() if profile: ts_str = profile.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 ──────────────────────────────────────────────────────────── # ── labels ──────────────────────────────────────────────────────────── def _update_info_labels(self): if not self.is_mounted: return v = self.validation_results # Discord d_name = v.get("discord_server_name") d_bot = v.get("discord_bot_name") is_val_d = v.get("discord_validating") or v.get("discord_token") is None if is_val_d: s_disp, b_disp = "[yellow]Validating...[/yellow]", "[yellow]Validating...[/yellow]" elif v.get("discord_timeout"): s_disp, b_disp = "[red]TIMEOUT[/red]", "[red]TIMEOUT[/red]" elif v.get("discord_token") and v.get("discord_server"): s_disp = f'[cyan]"{d_name}"[/cyan]' b_disp = f'[green]{d_bot}[/green]' elif v.get("discord_token") and not v.get("discord_server"): s_disp, b_disp = "[red]SERVER NOT SELECTED[/red]", f"[green]{d_bot}[/green]" elif v.get("discord_token") is False: if self.config.tool_mode == "backup_transfer" and self.view_mode == "shuttle": if not self.config.discord_server_id: s_disp, b_disp = "[red]SERVER NOT SELECTED[/red]", "[red]SERVER NOT SELECTED[/red]" else: s_disp, b_disp = "[red]NOT FOUND[/red]", "[red]NOT FOUND[/red]" else: if not self.config.discord_bot_token: s_disp, b_disp = "[red]NOT SET UP[/red]", "[red]NOT SET UP[/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]" for lbl in self.query("#op_lbl_d_server"): lbl.update(f"Server: {s_disp}") if self.view_mode == "backup": for lbl in self.query("#op_lbl_d_bot"): lbl.update(f"Source: {b_disp}") elif self.config.tool_mode == "backup_transfer": for lbl in self.query("#op_lbl_d_bot"): lbl.update(f"Source: {b_disp}") else: for lbl in self.query("#op_lbl_d_bot"): lbl.update(f"Bot: {b_disp}") # Discord Side Status 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 Content Intent") if not di.get("members"): d_missing.append("Server Members Intent") if not dp.get("view_channel"): d_missing.append("View Channels") if not dp.get("read_messages"): d_missing.append("Read Messages") if not dp.get("read_message_history"): d_missing.append("Read Message History") if is_val_d: d_status = "" for ldr in self.query("#op_d_loader"): ldr.display = True for lbl in self.query("#op_lbl_d_status"): lbl.display = False else: for ldr in self.query("#op_d_loader"): ldr.display = False for lbl in self.query("#op_lbl_d_status"): lbl.display = True if v.get("discord_token") and v.get("discord_server") and not d_missing: d_status = "STATUS: [green]VALID[/green]" elif v.get("discord_token") and not v.get("discord_server"): d_status = "[red]SERVER NOT SET[/red]" elif v.get("discord_timeout"): 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]" elif v.get("discord_token") is False: if self.config.tool_mode == "backup_transfer" and self.view_mode == "shuttle": d_status = "[yellow]Local Backup[/yellow] [red]Not Found[/red]" else: d_status = "[red]INVALID[/red]" else: d_status = "" for lbl in self.query("#op_lbl_d_status"): lbl.update(f"{d_status}") # Target / Backup Info if self.view_mode == "backup": backup_text = v.get("backup_info_text", "") for lbl in self.query("#op_lbl_backup"): lbl.update(backup_text) lbl.display = bool(backup_text) # Hide target side in backup mode completely for rle in self.query("#op_vrule"): rle.display = False for pne in self.query("#op_target_pane"): pne.display = False enabled = (v.get("discord_token") and v.get("discord_server") and not d_missing) for bid in ("#op_backup_msgs", "#op_backup_sync"): for btn in self.query(bid): btn.disabled = not enabled for btn in self.query("#op_backup_stats"): btn.display = self.has_backup btn.disabled = not self.has_backup for rle in self.query("#op_backup_stats_rule"): rle.display = self.has_backup else: # Show target side in shuttle mode for rle in self.query("#op_vrule"): rle.display = True for pne in self.query("#op_target_pane"): pne.display = True # Target plat = "Fluxer" if self.target_platform == "fluxer" else "Stoat" t_name = v.get("target_community_name") t_bot = v.get("target_bot_name") for lbl in self.query("#op_lbl_t_header"): lbl.update(plat) is_val_t = v.get("target_validating") or v.get("target_token") is None if is_val_t: c_disp, tb_disp = "[yellow]Validating...[/yellow]", "[yellow]Validating...[/yellow]" elif 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'[cyan]"{t_name}"[/cyan]' 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]" for lbl in self.query("#op_lbl_t_comm"): lbl.update(f"Community: {c_disp}") for lbl in self.query("#op_lbl_t_bot"): lbl.update(f"Bot: {tb_disp}") # 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] if is_val_t: t_status = "" for ldr in self.query("#op_t_loader"): ldr.display = True for lbl in self.query("#op_lbl_t_status"): lbl.display = False else: for ldr in self.query("#op_t_loader"): ldr.display = False for lbl in self.query("#op_lbl_t_status"): lbl.display = True if v.get("target_token") and v.get("target_community") and not t_missing: t_status = "STATUS: [green]VALID[/green]" elif v.get("target_timeout"): t_status = "ERROR: [red]TIMEOUT[/red]" elif t_err: t_status = f"ERROR: [red]{t_err}[/red]" elif t_missing: t_status = f"[yellow]MISSING: {', '.join(t_missing)} Permission[/yellow]" elif v.get("target_token") is False: t_status = "ERROR: [red]INVALID[/red]" else: t_status = "" for lbl in self.query("#op_lbl_t_status"): lbl.update(f"{t_status}") # Buttons for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_danger"): for btn in self.query(bid): btn.disabled = not self.tokens_valid # ── validation ──────────────────────────────────────────────────────── @work(exclusive=True) async def run_validate(self) -> None: if not self.is_mounted: return try: plat = "Fluxer" if self.target_platform == "fluxer" else "Stoat" # Use query().first() or check presence to avoid NoMatches crashes for lbl in self.query("#op_lbl_t_header"): lbl.update(plat) for lbl in self.query("#op_lbl_d_server"): lbl.update("Server: [yellow]Validating...[/yellow]") for lbl in self.query("#op_lbl_d_bot"): lbl.update("Source: [yellow]Validating...[/yellow]" if self.view_mode == "backup" else "Bot: [yellow]Validating...[/yellow]") for lbl in self.query("#op_lbl_t_comm"): lbl.update("Community: [yellow]Validating...[/yellow]") for lbl in self.query("#op_lbl_t_bot"): lbl.update("Bot: [yellow]Validating...[/yellow]") # Disable all operation buttons while validation is in progress if self.view_mode == "shuttle": for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_danger"): for btn in self.query(bid): btn.disabled = True elif self.view_mode == "backup": for bid in ("#op_backup_msgs", "#op_backup_sync"): for btn in self.query(bid): btn.disabled = True except Exception as e: logger.error(f"Error in run_validate setup: {e}") info = self._get_backup_info() self.validation_results = { "discord_validating": True, "target_validating": True, "discord_token": None, "discord_bot_name": None, "discord_server": None, "discord_server_name": None, "discord_intents": {}, "discord_permissions": {}, "discord_error": None, "target_token": None, "target_bot_name": None, "target_community": None, "target_community_name": None, "target_permissions": {}, "target_error": None, "discord_timeout": False, "target_timeout": False, "backup_info_text": f"Last backup: [cyan]{info}[/cyan]" if info else "", } self.tokens_valid = False self.permissions_complete = False self.has_backup = bool(info) # Check what we have has_d_token = bool(self.config.discord_bot_token) has_d_server = bool(self.config.discord_server_id) if self.target_platform == "stoat": has_t_token = bool(self.config.stoat_bot_token) has_t_server = bool(self.config.stoat_server_id) else: has_t_token = bool(self.config.fluxer_bot_token) has_t_server = bool(self.config.fluxer_server_id) # Flag which operations are being validated validating_discord = False validating_target = False # 1. Determine Discord validating status if self.config.tool_mode == "backup_transfer" and self.view_mode == "shuttle": if has_d_server: validating_discord = True else: self.validation_results["discord_token"] = False self.validation_results["discord_server"] = False else: if has_d_token: validating_discord = True else: self.validation_results["discord_token"] = False # 2. Determine Target validating status if self.view_mode == "shuttle" and has_t_token: validating_target = True elif self.view_mode == "shuttle": self.validation_results["target_token"] = False self.validation_results["discord_validating"] = validating_discord self.validation_results["target_validating"] = validating_target # Trigger the UI spinners instantly self._update_info_labels() async def check_discord(): try: import asyncio res = await asyncio.wait_for(self.engine.discord_reader.validate(), timeout=10.0) 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.validation_results["discord_intents"] = res.get("intents", {}) self.validation_results["discord_permissions"] = res.get("permissions", {}) self.validation_results["discord_error"] = res.get("error_reason") except asyncio.TimeoutError: self.validation_results["discord_timeout"] = True except asyncio.CancelledError: pass except Exception as e: self.validation_results["discord_error"] = str(e) finally: self.validation_results["discord_validating"] = False self._check_and_update() async def check_target(): try: import asyncio res = await asyncio.wait_for(self.engine.writer.validate(), timeout=10.0) self.validation_results["target_token"] = res.get("token", False) self.validation_results["target_bot_name"] = res.get("bot_name") 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") except asyncio.TimeoutError: self.validation_results["target_timeout"] = True except asyncio.CancelledError: pass except Exception as e: self.validation_results["target_error"] = str(e) finally: self.validation_results["target_validating"] = False self._check_and_update() coros = [] if validating_discord: coros.append(check_discord()) if validating_target: coros.append(check_target()) try: if coros: import asyncio await asyncio.gather(*coros) else: self._check_and_update() except asyncio.CancelledError: pass def _check_and_update(self) -> None: """Called safely on the main thread after any validation task finishes.""" v = self.validation_results discord_ok = v.get("discord_token") and v.get("discord_server") if self.view_mode == "backup": self.tokens_valid = bool(discord_ok) # Check for backup regardless of token validity 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 = v.get("target_token") and v.get("target_community") self.tokens_valid = bool(discord_ok and target_ok) self._update_info_labels() # ── button routing ──────────────────────────────────────────────────── def on_button_pressed(self, event: Button.Pressed) -> None: bid = event.button.id if not bid or not bid.startswith("op_"): return # Migration Routing if bid == "op_clone": self._open_clone_menu() elif bid == "op_sync": self._open_sync_menu() elif bid == "op_messages": self.run_migrate_messages() elif bid == "op_danger": self._open_danger_menu() # Backup Routing 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) ───────────────────────────── def _open_clone_menu(self): options = [ ("sub_clone_roles", "Roles & Role Permissions"), ("sub_clone_channels", "Server Structure [Channels & Categories]"), ("sub_sync_perms", "Channel & Category Permissions"), ] def on_result(choices): if choices: # Order defined: Roles -> Channels -> Permissions ordered = [c for c in ["sub_clone_roles", "sub_clone_channels", "sub_sync_perms"] if c in choices] self.run_batch_clone(ordered) self.app.push_screen(OptionSelectModal("Clone Server Template", options), on_result) # ── (2) sync server settings (combined) ──────────────────────────── def _open_sync_menu(self): options = [ ("sub_emoji", "Custom Emojis"), ("sub_sticker", "Custom Stickers"), ("sub_name", "Server Name"), ("sub_icon", "Server Icon"), ("sub_banner", "Server Banner"), ] def on_result(choices): if choices: self.run_batch_sync(choices) self.app.push_screen(OptionSelectModal("Sync Server Settings", options), on_result) # ── batch workers ────────────────────────────────────────────────── @work(exclusive=True) async def run_batch_clone(self, selections: list[str]) -> None: modal = ProgressScreen(log_level=self.config.log_level) self.app.push_screen(modal) await asyncio.sleep(0.1) connections_started = False try: # Phase 1: Connect early to fetch both source and target structure for preview modal.set_status("Connecting to Source and Target Servers for Preview...") try: await self.engine.start_connections() connections_started = True # Sync all entities before preview/confirmation modal.set_status("Synchronizing entity mappings...") await self._perform_auto_matching() except Exception as e: logger.warning(f"Could not pre-connect for Clone preview: {e}") # Show info container early modal.show_info("[bold cyan]Clone Template Ready[/bold cyan]", f"{len(selections)} categories/roles selected.") modal.set_status(f"Awaiting Confirmation for {len(selections)} Operations...") # Fetch and display live preview auto-matching already ran above preview = await self._fetch_clone_preview(selections) if connections_started else {} if connections_started: src_server = getattr(self.engine.discord_reader, 'guild', None) tgt_server_info = await self.engine.writer.validate() tgt_server_name = tgt_server_info.get("community_name", "target community") if src_server: modal.write(f"[bold cyan]Source Server Profile:[/bold cyan]") modal.write(f" Name: [green]{src_server.name}[/green]") modal.write(f" Icon: [green]{'Present' if src_server.icon else 'None'}[/green]") modal.write(f" Roles: [green]{len(getattr(src_server, 'roles', []))}[/green]") modal.write(f" Emojis: [green]{len(getattr(src_server, 'emojis', []))}[/green]") modal.write(f" Channels: [green]{len(getattr(src_server, 'channels', []))}[/green]") modal.write("") modal.write(f"[bold cyan]Target Community:[/bold cyan] [green]{tgt_server_name}[/green]\n") if "roles" in preview: roles = preview["roles"] modal.write(f"[bold cyan]Roles to be Cloned ({len(roles)}):[/bold cyan]") for name, exists in roles[:15]: if exists: modal.write(f" - [green]{name}[/green]") else: modal.write(f" - {name}") if len(roles) > 15: modal.write(f" [dim]... and {len(roles)-15} more[/dim]") modal.write("") if "structure" in preview: structure = preview["structure"] total_ch = sum(len(chans) for chans in structure.values()) num_cats = sum(1 for k in structure if k is not None) modal.write(f"[bold cyan]Server Structure ({num_cats} Categories, {total_ch} Channels):[/bold cyan]") # Show uncategorized channels first at the top if None in structure: _, _, uncat_channels = structure[None] for ch_name, ch_exists in uncat_channels: if ch_exists: modal.write(f" - [green]# {ch_name}[/green]") else: modal.write(f" - # {ch_name}") for cat_id, (cat_name, cat_exists, channels) in structure.items(): if cat_id is None: continue # already shown above cat_color = "green" if cat_exists else "bold yellow" modal.write(f" [{cat_color}]📁 {cat_name}[/{cat_color}]") for ch_name, ch_exists in channels: if ch_exists: modal.write(f" - [green]# {ch_name}[/green]") else: modal.write(f" - # {ch_name}") modal.write("") if connections_started: # Add highlighting note target_valid = await self.engine.writer.validate() community_name = target_valid.get("community_name", "the target") modal.write(f"[dim]Note: entities shown in 'green' are already present in {community_name} community[/dim]") modal.write("") choice = await modal.phase_wait_confirm( btn_start_label="Start Cloning", btn_id_label="Force Clone", show_id=True, btn_start_tooltip="Clone without creating duplicates", btn_id_tooltip="Force clone everything\n(may create duplicates)" ) if choice == "btn_back": modal.dismiss() self._open_clone_menu() return elif choice == "btn_main_menu": modal.dismiss() self.app.switch_screen("config_selection") return force_mode = (choice == "btn_start_id") modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) modal.phase_progress() modal.set_status("Cloning Server Template") # Connections already started above if not connections_started: await self.engine.start_connections() self.engine.is_running = True results = {} for sel in selections: if not self.engine.is_running: break if sel == "sub_clone_roles": results["roles"] = await self._logic_clone_roles(modal, force=force_mode) elif sel == "sub_clone_channels": results["channels"] = await self._logic_clone_channels(modal, force=force_mode) elif sel == "sub_sync_perms": results["perms"] = await self._logic_sync_permissions(modal) modal.phase_report("Clone Template Complete", show_back=False) report = self._format_clone_report(results) modal.write(report) except Exception as e: logger.error(f"Batch Cloning Error: {e}\n{traceback.format_exc()}") modal.write(f"[bold red]Error: {e}[/bold red]") modal.phase_report("Batch Operation", "error", show_back=False) finally: self.engine.is_running = False # Ensure we only close if we actually started them and no other task is inheriting await self.engine.close_connections() @work(exclusive=True) async def run_batch_sync(self, selections: list[str]) -> None: modal = ProgressScreen(log_level=self.config.log_level) self.app.push_screen(modal) await asyncio.sleep(0.1) try: # Connect early to get metadata modal.set_status("Connecting to Source and Target Servers for Preview...") connections_started = False try: await self.engine.start_connections() connections_started = True except Exception as e: logger.warning(f"Could not pre-connect for Sync preview: {e}") if connections_started: src_server = getattr(self.engine.discord_reader, 'guild', None) tgt_server_info = await self.engine.writer.validate() tgt_server_name = tgt_server_info.get("community_name", "target community") if src_server: modal.write(f"[bold cyan]Source Server Profile:[/bold cyan]") modal.write(f" Name: [green]{src_server.name}[/green]") modal.write(f" Icon: [green]{'Present' if src_server.icon else 'None'}[/green]") modal.write(f" Roles: [green]{len(getattr(src_server, 'roles', []))}[/green]") modal.write(f" Emojis: [green]{len(getattr(src_server, 'emojis', []))}[/green]") modal.write(f" Channels: [green]{len(getattr(src_server, 'channels', []))}[/green]") modal.write("") modal.write(f"[bold cyan]Target Community:[/bold cyan] [green]{tgt_server_name}[/green]\n") # Show info container modal.show_info("[bold yellow]Sync Ready[/bold yellow]", "Comparing server configurations...") modal.set_status("Awaiting Confirmation to Sync Server Settings...") choice = await modal.phase_wait_confirm( btn_start_label="Start Syncing", btn_id_label="Force Sync", show_id=True, btn_start_tooltip="Sync new assets only", btn_id_tooltip="Force sync assets\n(may create duplicates)" ) if choice == "btn_back": modal.dismiss() self._open_sync_menu() return elif choice == "btn_main_menu": modal.dismiss() self.app.switch_screen("config_selection") return force_mode = (choice == "btn_start_id") modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) modal.phase_progress() modal.set_status("Syncing Server Settings") if not connections_started: await self.engine.start_connections() self.engine.is_running = True results = {} # Separate asset sync from metadata sync asset_types = [] if "sub_emoji" in selections: asset_types.append("Emoji") if "sub_sticker" in selections: asset_types.append("Sticker") if asset_types: results["assets"] = await self._logic_copy_assets(modal, asset_types, force=force_mode) meta_comps = [] if "sub_name" in selections: meta_comps.append("name") if "sub_icon" in selections: meta_comps.append("icon") if "sub_banner" in selections: meta_comps.append("banner") if meta_comps: results["metadata"] = await self._logic_sync_metadata(modal, meta_comps) modal.phase_report("Sync Settings Complete", show_back=False) report = self._format_sync_report(results) modal.write(report) except Exception as e: logger.error(f"Batch Sync Error: {e}\n{traceback.format_exc()}") modal.write(f"[bold red]Error: {e}[/bold red]") modal.phase_report("Batch Operation", "error", show_back=False) finally: self.engine.is_running = False await self.engine.close_connections() # ── logic blocks (internal) ──────────────────────────────────────── async def _logic_clone_channels(self, modal: ProgressScreen, force: bool = False): if self.target_platform == "fluxer": from src.fluxer.clone_server import sync_channel_state, migrate_channels else: from src.stoat.clone_server import sync_channel_state, migrate_channels modal.set_item_status("Processing Server Structure...") await sync_channel_state(self.engine) categories = await self.engine.discord_reader.get_categories() channels = await self.engine.discord_reader.get_channels() async def update_progress(item_name, status, current, total): color = "cyan" if status == "Copying" else "yellow" modal.set_item_status(f"[{color}]{status}: {item_name}[/{color}]") modal.set_progress(current, total) cloned_info = await migrate_channels(self.engine, progress_callback=update_progress, force=force) if cloned_info and cloned_info.get("structure"): lines = ["Successfully cloned channels and categories:"] cats = sorted(cloned_info["structure"].keys(), key=lambda x: (x == "No Category", x)) for cat_name in cats: ch_names = cloned_info["structure"][cat_name] if cat_name in cloned_info.get("categories_created", []) or ch_names: lines.append(f"- **{cat_name}**") for n in sorted(ch_names): lines.append(f" - {n}") await log_audit_event(self.engine, "Channels Cloned", "\n".join(lines)) return cloned_info async def _logic_clone_roles(self, modal: ProgressScreen, force: bool = False): roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles modal.set_item_status("Processing Roles...") await roles_mod.sync_roles_state(self.engine) async def update(name, current, total): modal.set_item_status(f"[cyan]Copying Role: {name}[/cyan]") modal.set_progress(current, total) cloned = await roles_mod.migrate_roles(self.engine, progress_callback=update, force=force) if cloned: await log_audit_event(self.engine, "Roles Cloned", "Successfully cloned roles:\n" + "\n".join(f"- {r}" for r in cloned)) return cloned async def _logic_sync_permissions(self, modal: ProgressScreen): roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles modal.set_item_status("Syncing Permissions...") async def update(name, current, total): modal.set_item_status(f"[cyan]Syncing Perms: {name}[/cyan]") modal.set_progress(current, total) synced = await roles_mod.sync_permissions(self.engine, progress_callback=update) if synced and synced.get("structure"): lines = ["Synchronized permission overrides:"] cats = sorted(synced["structure"].keys(), key=lambda x: (x == "No Category", x)) for cat_name in cats: ch_names = synced["structure"][cat_name] if cat_name in synced.get("categories_synced", []) or ch_names: lines.append(f"- **{cat_name}**") for n in sorted(ch_names): lines.append(f" - {n}") await log_audit_event(self.engine, "Permissions Synced", "\n".join(lines)) return synced async def _logic_copy_assets(self, modal: ProgressScreen, types_to_include: list[str], force: bool = False): asset_mod = stoat_emoji_stickers if self.target_platform == "stoat" else fluxer_emoji_stickers modal.set_item_status("Processing Assets...") await asset_mod.sync_assets_state(self.engine) async def update(name, item_type, current, total): modal.set_item_status(f"[cyan]Copying {item_type}: {name}[/cyan]") modal.set_progress(current, total) cloned = await asset_mod.migrate_emojis(self.engine, progress_callback=update, types_to_include=types_to_include, force=force) if cloned and (cloned.get("Emoji") or cloned.get("Sticker")): lines = [] if cloned.get("Emoji"): lines.append("Emojis cloned:"); lines.extend([f"- {n}" for n in cloned["Emoji"]]) if cloned.get("Sticker"): lines.append("Stickers cloned:"); lines.extend([f"- {n}" for n in cloned["Sticker"]]) await log_audit_event(self.engine, "Assets Cloned", "\n".join(lines)) return cloned async def _logic_sync_metadata(self, modal: ProgressScreen, components: list[str]): meta_mod = fluxer_metadata if self.target_platform == "fluxer" else stoat_metadata modal.set_item_status("Syncing Server Profile...") async def progress_cb(item, status): color = "green" if status == "DONE" else "red" if status == "ERROR" else "yellow" modal.write(f"{item} [[bold {color}]{status}[/bold {color}]]") cloned = await meta_mod.sync_server_metadata(self.engine, progress_cb, components=components) if cloned: lines = ["Synchronized profile traits:"] if "name" in cloned: lines.append(f"- **Name**: {cloned['name']}") if "icon" in cloned: lines.append("- **Icon**") if "banner" in cloned: lines.append("- **Banner**") # Prepare files for audit log files = [] if "icon" in cloned: ext = "gif" if cloned["icon"].startswith(b"GIF") else "png" files.append({"filename": f"icon.{ext}", "data": cloned["icon"]}) if "banner" in cloned: ext = "gif" if cloned["banner"].startswith(b"GIF") else "png" files.append({"filename": f"banner.{ext}", "data": cloned["banner"]}) await log_audit_event(self.engine, "Profile Synced", "\n".join(lines), files=files) return cloned # ── report formatting ─────────────────────────────────────────────── def _format_sync_report(self, results: dict) -> str: lines = ["[bold green]Synchronization Report[/bold green]\n"] meta = results.get("metadata", {}) if meta: lines.append("[bold cyan]Server Profile:[/bold cyan]") if "name" in meta: lines.append(f"- Name: [white]{meta['name']}[/white]") if "icon" in meta: lines.append("- Icon") if "banner" in meta: lines.append("- Banner") lines.append("") assets = results.get("assets", {}) emojis = assets.get("Emoji", {}) if emojis: lines.append("[bold cyan]Emojis:[/bold cyan]") for name, eid in emojis.items(): lines.append(f"- {name} ([dim]{eid}[/dim])") lines.append("") stickers = assets.get("Sticker", {}) if stickers: lines.append("[bold cyan]Stickers:[/bold cyan]") for name, sid in stickers.items(): lines.append(f"- {name} ([dim]{sid}[/dim])") lines.append("") if not meta and not emojis and not stickers: lines.append("[yellow]No items were synchronized.[/yellow]") return "\n".join(lines) def _format_clone_report(self, results: dict) -> str: lines = ["[bold green]Cloning Template Report[/bold green]\n"] roles = results.get("roles", []) if roles: lines.append(f"[bold cyan]Roles ({len(roles)}):[/bold cyan]") for r in sorted(roles): lines.append(f"- {r}") lines.append("") channels = results.get("channels", {}) structure = channels.get("structure", {}) if structure: lines.append("[bold cyan]Server Structure:[/bold cyan]") cats = sorted(structure.keys(), key=lambda x: (x == "No Category", x)) for cat in cats: chans = structure[cat] if cat in channels.get("categories_created", []) or chans: lines.append(f"[bold]{cat}[/bold]") for ch in sorted(chans): lines.append(f" - {ch}") lines.append("") if not roles and not structure: lines.append("[yellow]No items were cloned.[/yellow]") return "\n".join(lines) # ── (5) message migration ───────────────────────────────────────────── @work(exclusive=True) async def run_migrate_messages(self) -> None: if not self.tokens_valid: return migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate platform_name = self.target_platform.capitalize() modal = ProgressScreen(log_level=self.config.log_level) self.app.push_screen(modal) await asyncio.sleep(0.1) try: # Show info container modal.show_info("[bold cyan]Message Migration Ready[/bold cyan]", "Checking channel permissions...") modal.set_status("Connecting to Servers...") await self.engine.start_connections() # Sync all entities before confirmation modal.set_status("Synchronizing entity mappings...") await self._perform_auto_matching() full_d = await self.engine.discord_reader.get_channels() # If reading from backup, only show channels that have actual message backup data if getattr(self.engine, "source_mode", "live") == "backup" and hasattr(self.engine.discord_reader, "get_backed_up_channel_ids"): valid_ids = await self.engine.discord_reader.get_backed_up_channel_ids() full_d = [c for c in full_d if c.id in valid_ids] d_channels = [c for c in full_d if c.type in [self.engine.discord_reader.CHANNEL_TYPE_TEXT, self.engine.discord_reader.CHANNEL_TYPE_NEWS]] d_cats = await self.engine.discord_reader.get_categories() d_cat_map = {c.id: c.name for c in d_cats} if not d_channels: modal.write("[yellow]No text channels found.[/yellow]") modal.allow_close() return # Fetch target channels modal.set_status(f"Fetching {platform_name} channels...") full_f = await self.engine.writer.get_channels() f_channels = [c for c in full_f if str(c.get("name")).lower() not in ["reaper-logs", "reaper_logs", "reaperfiles-logs"] and c.get("type") not in [2, 4]] if not f_channels: modal.write(f"[yellow]No channels found in {platform_name} community.[/yellow]") modal.allow_close() await self.engine.close_connections() return self.app.pop_screen() target_cat_names = {str(c.get("id")): c.get("name") for c in full_f if c.get("type") == 4} while True: loop = asyncio.get_running_loop() pick_future = loop.create_future() def on_pick(result): if not pick_future.done(): pick_future.set_result(result) self.app.push_screen(ChannelPickerScreen(d_channels, d_cat_map, f_channels, target_cat_names, platform_name, all_tgt_channels=full_f), on_pick) res = await pick_future if res is None: await self.engine.close_connections() return # Handle result from channel picker # Normal: (src_id, tgt_id) - 2-tuple # Create new: (src_id, "create_new", channel_name) - 3-tuple # Enter ID: (src_id, tgt_id, channel_dict) - 3-tuple pending_create_name = None # Deferred channel creation if len(res) == 3 and res[1] == "create_new": src_id, _, chan_name = res source_channel = next(c for c in d_channels if c.id == src_id) # Don't create yet — defer until user confirms migration pending_create_name = chan_name target_channel = {"id": "__pending__", "name": chan_name, "type": 0} elif len(res) == 3: src_id, _, chan_dict = res source_channel = next(c for c in d_channels if c.id == src_id) target_channel = chan_dict else: src_id, tgt_id = res source_channel = next(c for c in d_channels if c.id == src_id) target_channel = next(c for c in f_channels if c.get("id") == tgt_id) # Determine after_id status (skip for pending channels) if pending_create_name: last_migrated = None has_previous = False else: last_migrated = self.engine.state.get_last_message_id(str(target_channel.get('id'))) has_previous = bool(last_migrated) # Analyze modal = ProgressScreen(log_level=self.config.log_level) self.app.push_screen(modal) await asyncio.sleep(0.1) src_server = getattr(self.engine.discord_reader, 'guild', None) tgt_server_info = await self.engine.writer.validate() tgt_server_name = tgt_server_info.get("community_name", "target community") # ENSURE INITIALIZED for mapping lookup in analyze/migrate tid = self.engine.config.fluxer_server_id if self.target_platform == "fluxer" else self.engine.config.stoat_server_id self.engine.ensure_state_initialized(str(tid or ""), tgt_server_name) if src_server: modal.write(f"[bold cyan]Source Server Profile:[/bold cyan]") modal.write(f" Name: [green]{src_server.name}[/green]") modal.write(f" Icon: [green]{'Present' if src_server.icon else 'None'}[/green]") modal.write("") modal.write(f"[bold cyan]Target Community:[/bold cyan] [green]{tgt_server_name}[/green]\n") modal.set_status("Analyzing channel...") modal.show_stats() # Show buttons early so user can skip analysis modal.show_early_buttons( show_continue=has_previous, show_id=True, btn_start_label="Start from\nFirst Message", btn_continue_label="Continue\nMigration", btn_id_label="Start from\nmessage ID", btn_start_tooltip="Start migrating from the earliest available message", btn_continue_tooltip="Resume from the last successfully migrated message", btn_id_tooltip="Start migrating from a specific Discord message ID" ) self.engine.is_running = True stats_analysis = {"messages": 0, "threads": 0, "attachments": 0} async def update_scan(current_stats): modal.set_status(f"[cyan]Scanned {current_stats['messages']} items...") logger.info(f"Analyzing message history for Discord #{source_channel.name}...") # Run analysis and wait for confirmation concurrently analysis_task = asyncio.create_task(migrate_mod.analyze_migration( self.engine, source_channel_id=source_channel.id, after_message_id=int(last_migrated) if last_migrated else None, progress_callback=update_scan, )) # Fetch and display message previews as background tasks async def fetch_previews(): try: first_msg_task = asyncio.create_task(self.engine.discord_reader.get_first_message(source_channel.id)) prev_msg_task = None if has_previous and last_migrated: prev_msg_task = asyncio.create_task(self.engine.discord_reader.get_message(source_channel.id, int(last_migrated))) first_msg = await first_msg_task if first_msg: content = first_msg.content or (f"[dim]({len(first_msg.attachments)} attachments)[/dim]" if first_msg.attachments else "[dim](no content)[/dim]") modal.write("[bold cyan]Start from first message:[/bold cyan]") modal.write(f"[bold]{first_msg.author.display_name}:[/bold] {content[:200]}\n") if prev_msg_task: try: prev_msg = await prev_msg_task if prev_msg: content = prev_msg.content or (f"[dim]({len(prev_msg.attachments)} attachments)[/dim]" if prev_msg.attachments else "[dim](no content)[/dim]") modal.write("[bold yellow]Continue from previous migration:[/bold yellow]") modal.write(f"[bold]{prev_msg.author.display_name}:[/bold] {content[:200]}\n") except Exception as e: logger.warning(f"Could not fetch previous message {last_migrated}: {e}") except Exception as e: logger.warning(f"Error fetching message previews: {e}") preview_task = asyncio.create_task(fetch_previews()) # Cleanup function for confirmation phase def cleanup_preview(): if not preview_task.done(): preview_task.cancel() # Create a task for waiting for confirmation confirm_task = asyncio.create_task(modal.phase_wait_confirm( show_continue=has_previous, show_id=True, btn_start_label="Start from\nFirst Message", btn_continue_label="Continue\nMigration", btn_id_label="Start from\nmessage ID", btn_start_tooltip="Start migrating from the earliest available message", btn_continue_tooltip="Resume from the last successfully migrated message", btn_id_tooltip="Start migrating from a specific Discord message ID" )) # Wait for either analysis to finish OR user to make a choice done, pending = await asyncio.wait( [analysis_task, confirm_task], return_when=asyncio.FIRST_COMPLETED ) if confirm_task in done: # User clicked a button early choice = confirm_task.result() if not analysis_task.done(): analysis_task.cancel() logger.info("Analysis cancelled by early user choice.") else: # Analysis finished first stats_analysis = analysis_task.result() logger.info(f"Analysis complete: {stats_analysis['messages']} new messages found.") # Update stats in UI modal.update_stats( messages=stats_analysis['messages'], threads=stats_analysis['threads'], files=stats_analysis['attachments'] ) m_status = "[bold yellow]Previous Migration Detected[/bold yellow]" if has_previous else "[bold cyan]No previous migration data.[/bold cyan]" i_status = f"[bold]{stats_analysis['messages']}[/bold] New Messages, [bold]{stats_analysis['threads']}[/bold] Threads." modal.show_info(m_status, i_status) modal.set_status(f"Awaiting Confirmation to migrate Discord [cyan]#{source_channel.name}[/cyan] → {platform_name} [green]#{target_channel.get('name')}[/green]") # Now wait for the choice if not already made choice = await confirm_task self.engine.is_running = False cleanup_preview() logger.info(f"User confirmation choice: {choice}") if choice == "btn_back": modal.dismiss() continue # Return to channel picker elif choice == "btn_main_menu": modal.dismiss() self.app.switch_screen("config_selection") self.engine.is_running = False await self.engine.close_connections() return after_id = None if choice == "btn_continue" and last_migrated: logger.info("Proceeding with 'Continue Migration' (incremental sink).") after_id = int(last_migrated) 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, source_channel.id) self.app.push_screen(id_modal, id_callback) verified_id = await future if verified_id is None: # User cancelled the ID input, stay on the progress modal logger.info("User cancelled 'Start from ID' input.") continue logger.info(f"Proceeding with 'Start from ID': {verified_id}") after_id = verified_id else: logger.info("Proceeding with 'Start from First' (clean sink).") after_id = None # Clear previous tracking data for this channel self.engine.state.clear_channel_data(target_channel.get("id")) is_inclusive = (choice == "btn_start_id") # If after_id changed from the initial analysis, we must re-analyze # to get the correct total count for the UI fraction (e.g. Messages: 8/8 instead of 8/1) initial_after = int(last_migrated) if last_migrated else None # User selected a different start point, transition UI immediately if after_id != initial_after: modal.phase_progress() # Hide buttons immediately if choice == "btn_start_first": modal.set_status("Starting from first message...") elif choice == "btn_start_id": modal.set_status(f"Starting from ID [cyan]{after_id}[/cyan]...") else: modal.set_status("Re-analyzing channel from new starting point...") try: self.engine.is_running = True stats_analysis = await migrate_mod.analyze_migration( self.engine, source_channel_id=source_channel.id, after_message_id=after_id, inclusive=is_inclusive, progress_callback=update_scan, ) modal.update_stats( messages=stats_analysis['messages'], threads=stats_analysis['threads'], files=stats_analysis['attachments'] ) except Exception as e: logger.warning(f"Failed to re-analyze for correct totals: {e}") # If we are here, we are proceeding with migration break # Create the channel now if it was deferred if pending_create_name: modal.set_status(f"Creating channel [green]#{pending_create_name}[/green]...") try: new_id = await self.engine.writer.create_channel(name=pending_create_name) logger.info(f"Created new channel '{pending_create_name}' with ID: {new_id}") target_channel = {"id": new_id, "name": pending_create_name, "type": 0} f_channels.append(target_channel) except Exception as e: logger.error(f"Failed to create channel '{pending_create_name}': {e}") modal.write(f"[bold red]Failed to create channel: {e}[/bold red]") modal.phase_report("Channel Creation", status="error") return # Phase 3: Progress modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) modal.phase_progress() modal.set_status("Migrating messages...") total_messages = stats_analysis["messages"] total_threads = stats_analysis["threads"] total_attachments = stats_analysis["attachments"] modal.set_status(f"Migrating: [cyan]#{source_channel.name}[/cyan] → [green]#{target_channel.get('name')}[/green]") modal.write(f"[bold cyan]Migration Started:[/bold cyan] Discord [cyan]#{source_channel.name}[/cyan] → {platform_name} [green]#{target_channel.get('name')}[/green]") modal.write(f"[dim]Stats: {total_messages} messages, {total_threads} threads, {total_attachments} files[/dim]\n") logger.info(f"Execution started for #{source_channel.name} -> {platform_name} @ {target_channel.get('name')}") self.engine.is_running = True async def update_msg(current_stats): c_msgs = current_stats["messages"] c_threads = current_stats["threads"] c_files = current_stats["attachments"] msg_stat = f"{c_msgs}/{total_messages}" if total_messages > 0 else str(c_msgs) thr_stat = f"{c_threads}/{total_threads}" if total_threads > 0 else str(c_threads) fil_stat = f"{c_files}/{total_attachments}" if total_attachments > 0 else str(c_files) modal.set_item_status(f"[cyan]Migrated {msg_stat} messages...") modal.set_progress(c_msgs, total_messages or 100) # Fallback total for bar animation modal.update_stats( messages=msg_stat, threads=thr_stat, files=fil_stat ) # optionally show a scrolling trace if the backend provided it modal.write_live(f"Migrated message #{c_msgs}") content = current_stats.get("last_message_content", "") author = current_stats.get("last_message_author", "Unknown") if content: # Clean up content for display (truncate long messages) disp_content = (content[:100] + '...') if len(content) > 100 else content modal.write(f"[bold]{author}:[/bold] {disp_content}") result = await migrate_mod.migrate_messages( self.engine, source_channel_id=source_channel.id, target_channel_id=target_channel.get("id"), after_message_id=after_id, inclusive=is_inclusive, progress_callback=update_msg, ) if self.engine.is_running: modal.write(f"[bold green]Success! {result['messages']} messages migrated.[/bold green]") event_title = "Message Migration" modal.phase_report(event_title, show_back=False) else: modal.write(f"[bold yellow]Interrupted! {result['messages']} messages migrated.[/bold yellow]") event_title = "Message Migration" modal.phase_report(event_title, "stopped", show_back=False) lines = [f"Migrated Discord #{source_channel.name} → {platform_name} #{target_channel.get('name')}:"] lines.append(f"{result['messages']} messages, {result['attachments']} attachments, {result['threads']} threads") await log_audit_event(self.engine, event_title, "\n".join(lines)) except Exception as e: err = str(e) if "MissingPermission" in err and "Masquerade" in err: modal.write("[bold red]Bot is missing the 'Masquerade' permission.[/bold red]") else: modal.write(f"[bold red]Error: {err}[/bold red]") modal.phase_report("Message Migration", "error", show_back=False) finally: self.engine.is_running = False await self.engine.close_connections() # ── (6) danger zone ─────────────────────────────────────────────────── def _open_danger_menu(self): options = [ ("dz_del_channels", "Delete ALL Channels & Categories"), ("dz_reset_perms", "Reset ALL Channel Permissions"), ("dz_del_roles", "Delete ALL Roles"), ("dz_del_assets", "Delete ALL Emojis & Stickers"), ] def on_result(selections: list[str] | None): if selections: self.run_batch_danger(selections) self.app.push_screen(OptionSelectModal("⚠ DANGER ZONE ⚠", options), on_result) @work(exclusive=True) async def run_batch_danger(self, selections: list[str]) -> None: modal = ProgressScreen(log_level=self.config.log_level) self.app.push_screen(modal) await asyncio.sleep(0.1) target_started = False try: # Phase 1: Connect early to fetch real item names for preview modal.set_status("Connecting to Target Server for Preview...") try: await self.engine.start_target_only() target_started = True # Sync all entities before confirmation (even in danger zone) modal.set_status("Synchronizing entity mappings...") await self._perform_auto_matching() except Exception as e: logger.warning(f"Could not pre-connect for DZ preview: {e}") if target_started: tgt_server_info = await self.engine.writer.validate() tgt_server_name = tgt_server_info.get("community_name", "target community") modal.write(f"[bold red]Target Community:[/bold red] [green]{tgt_server_name}[/green]") modal.write(f"[bold red]WARNING: THE ACTIONS BELOW WILL DELETE DATA PERMANENTLY IN: {tgt_server_name}![/bold red]") modal.write("") else: modal.write("[bold red]WARNING: THIS WILL DELETE DATA PERMANENTLY! MUST PROCEED TO CONTINUE.[/bold red]") modal.write("") # Show info container modal.show_info("[bold red]Danger Zone Ready[/bold red]", "Fetching target entities...") modal.set_status(f"Awaiting Confirmation for {len(selections)} Destructive Operations...") # Fetch and display live item names from target server preview = await self._fetch_dz_preview(selections) if target_started else {} dz_labels = { "dz_del_channels": "Channels & Categories to be Deleted", "dz_reset_perms": "Channels with Permissions to Reset", "dz_del_roles": "Roles to be Deleted", "dz_del_assets": "Emojis & Stickers to be Deleted" } for sel in selections: section_title = dz_labels.get(sel, sel) items = preview.get(sel, []) if items: modal.write(f"[bold cyan]{section_title} ({len(items)}):[/bold cyan]") for name in items[:20]: # cap at 20 to avoid flooding the log modal.write(f" [red]- {name}[/red]") if len(items) > 20: modal.write(f" [dim]... and {len(items) - 20} more[/dim]") else: modal.write(f"[bold cyan]{section_title}:[/bold cyan]") modal.write(f" [dim](could not fetch list)[/dim]") modal.write("") choice = await modal.phase_wait_confirm( btn_start_label="WIPE ALL DATA", show_id=False, btn_start_variant="error", btn_start_tooltip="WARNING\nIrreversible Operation!\nProceed with Caution" ) if choice == "btn_back": modal.dismiss() self._open_danger_menu() return elif choice == "btn_main_menu": modal.dismiss() self.app.switch_screen("config_selection") return modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) modal.phase_progress() modal.set_status("Danger Zone: Destructive Operations") self.engine.is_running = True # Writer already started above, no need to reconnect if not target_started: await self.engine.start_target_only() for sel in selections: if not self.engine.is_running: break if sel == "dz_del_channels": await self._logic_dz_delete_channels(modal) elif sel == "dz_reset_perms": await self._logic_dz_reset_perms(modal) elif sel == "dz_del_roles": await self._logic_dz_delete_roles(modal) elif sel == "dz_del_assets": await self._logic_dz_delete_assets(modal) modal.phase_report("Danger Zone Operations Complete", show_back=False) modal.write("[bold green]All selected destructive operations finished.[/bold green]") except Exception as e: modal.write(f"[bold red]Error: {e}[/bold red]") modal.phase_report("Danger Zone Batch", "error", show_back=False) finally: self.engine.is_running = False await self.engine.close_target_only() async def _fetch_dz_preview(self, selections: list[str]) -> dict[str, list[str]]: """Fetches real item names from the target server for each Danger Zone selection. Returns a dict mapping selection ID -> list of item names.""" preview: dict[str, list[str]] = {} writer = self.engine.writer is_fluxer = self.target_platform == "fluxer" try: if "dz_del_channels" in selections or "dz_reset_perms" in selections: channels_raw = await writer.get_channels() protected = ["reaperfiles-logs", "reaper_logs", "reaper-logs"] channel_names = [ c.get("name", "Unknown") for c in channels_raw if c.get("type") != 4 and str(c.get("name", "")).lower() not in protected ] category_names = [ c.get("name", "Unknown") for c in channels_raw if c.get("type") == 4 and str(c.get("name", "")).lower() not in protected ] all_names = category_names + channel_names if "dz_del_channels" in selections: preview["dz_del_channels"] = all_names if "dz_reset_perms" in selections: preview["dz_reset_perms"] = channel_names # only channels, not categories except Exception as e: logger.warning(f"DZ preview: failed to fetch channels: {e}") try: if "dz_del_roles" in selections: if is_fluxer: community_id = self.engine.config.fluxer_server_id roles_raw = await writer.client.get_guild_roles(community_id) role_names = [ r.get("name", "Unknown") for r in roles_raw if not r.get("managed") and r.get("name") != "@everyone" ] else: # Stoat: server.roles is a dict {id: Role} server = await writer._get_server() role_names = [ role.name for role in server.roles.values() if str(role.id) != writer.community_id ] preview["dz_del_roles"] = role_names except Exception as e: logger.warning(f"DZ preview: failed to fetch roles: {e}") try: if "dz_del_assets" in selections: asset_names = [] if is_fluxer: community_id = self.engine.config.fluxer_server_id emojis = await writer.client.get_guild_emojis(community_id) asset_names += [f"{e.get('name', '?')} (emoji)" for e in emojis] try: stickers = await writer.client.get_guild_stickers(community_id) asset_names += [f"{s.get('name', '?')} (sticker)" for s in stickers] except Exception: pass # Stickers may not exist else: server = await writer._get_server() emojis = await server.fetch_emojis() asset_names += [f"{e.name} (emoji)" for e in emojis] preview["dz_del_assets"] = asset_names except Exception as e: logger.warning(f"DZ preview: failed to fetch assets: {e}") return preview async def _fetch_clone_preview(self, selections: list[str]) -> dict[str, Any]: """Fetches preview data from Discord (source server) for cloning confirmation, comparing with existing entities on the target server for presence highlighting.""" async def _perform_auto_matching(self): """Matches Discord entities (roles, channels, emojis, stickers) with target platform items by name.""" if not self.engine: return reader = self.engine.discord_reader writer = self.engine.writer is_fluxer = self.target_platform == "fluxer" # 1. Fetch target data for comparison target_roles_map = {} target_chans_map = {} target_cats_map = {} target_emojis_map = {} target_stickers_map = {} try: if is_fluxer: tid = self.engine.config.fluxer_server_id target_roles_raw = await writer.client.get_guild_roles(tid) target_roles_map = {r.get("name", "").lower(): str(r.get("id")) for r in target_roles_raw} target_emojis_raw = await writer.client.get_guild_emojis(tid) target_emojis_map = {e.get("name", "").lower(): str(e.get("id")) for e in target_emojis_raw} # RE-INITIALIZE STATE if we found community info # This ensures mapping persistence even if validate_all was skipped try: community_info = await writer.client.get_guild(tid) if community_info: self.engine.ensure_state_initialized(str(tid or ""), community_info.get("name", "Target")) except Exception: pass try: target_stickers_raw = await writer.client.get_guild_stickers(tid) target_stickers_map = {s.get("name", "").lower(): str(s.get("id")) for s in target_stickers_raw} except Exception: pass else: server = await writer._get_server() target_roles_map = {r.name.lower(): str(r.id) for r in server.roles.values()} target_emojis_raw = await server.fetch_emojis() target_emojis_map = {e.name.lower(): str(e.id) for e in target_emojis_raw} # RE-INITIALIZE STATE tid = self.engine.config.stoat_server_id self.engine.ensure_state_initialized(str(tid or ""), server.name) target_chans_raw = await writer.get_channels() target_chans_map = {c.get("name", "").lower(): str(c.get("id")) for c in target_chans_raw if c.get("type") != 4} target_cats_map = {c.get("name", "").lower(): str(c.get("id")) for c in target_chans_raw if c.get("type") == 4} except Exception as e: logger.warning(f"Auto-matching: failed to fetch target data: {e}") return # Cannot match without target data # 2. Match entities try: # Roles src_roles = await reader.get_roles() for r in src_roles: name_l = r.name.lower() if name_l in target_roles_map and not self.engine.state.get_target_role_id(r.id): logger.info(f"Auto-matched Role: {r.name} -> {target_roles_map[name_l]}") self.engine.state.set_target_role_mapping(r.id, target_roles_map[name_l]) # Categories src_cats = await reader.get_categories() for cat in src_cats: name_l = cat.name.lower() if name_l in target_cats_map and not self.engine.state.get_target_category_id(cat.id): logger.info(f"Auto-matched Category: {cat.name} -> {target_cats_map[name_l]}") self.engine.state.set_target_category_mapping(cat.id, target_cats_map[name_l]) # Channels src_channels = await reader.get_channels() for ch in src_channels: name_l = ch.name.lower() if name_l in target_chans_map and not self.engine.state.get_target_channel_id(ch.id): logger.info(f"Auto-matched Channel: {ch.name} -> {target_chans_map[name_l]}") self.engine.state.set_target_channel_mapping(ch.id, target_chans_map[name_l]) # Emojis src_emojis = await reader.get_emojis() for e in src_emojis: name_l = e.name.lower() if name_l in target_emojis_map and not self.engine.state.get_target_emoji_id(e.id): logger.info(f"Auto-matched Emoji: {e.name} -> {target_emojis_map[name_l]}") self.engine.state.set_target_emoji_mapping(e.id, target_emojis_map[name_l]) # Stickers if is_fluxer: src_stickers = await reader.get_stickers() for s in src_stickers: name_l = s.name.lower() if name_l in target_stickers_map and not self.engine.state.get_target_sticker_id(s.id): logger.info(f"Auto-matched Sticker: {s.name} -> {target_stickers_map[name_l]}") self.engine.state.set_target_sticker_mapping(s.id, target_stickers_map[name_l]) except Exception as e: logger.warning(f"Auto-matching error: {e}") return { "target_roles": target_roles_map, "target_channels": target_chans_map, "target_categories": target_cats_map, "target_emojis": target_emojis_map, "target_stickers": target_stickers_map } async def _fetch_clone_preview(self, selections: list[str]) -> dict[str, Any]: """Fetches preview data from Discord (source server) for cloning confirmation, comparing with existing mappings in SQLite for presence highlighting.""" preview = {} reader = self.engine.discord_reader # We rely on the global auto-match that ran during connection mapping_ch = self.engine.state.channel_map mapping_cat = self.engine.state.category_map mapping_role = self.engine.state.role_map try: if "sub_clone_roles" in selections: roles = await reader.get_roles() # Highlight if existing in mapping preview["roles"] = [(r.name, str(r.id) in mapping_role) for r in roles] except Exception as e: logger.warning(f"Clone Preview: failed to fetch roles: {e}") try: if "sub_clone_channels" in selections: src_categories = await reader.get_categories() src_channels = await reader.get_channels() # Build hierarchy for preview structure = {} for cat in src_categories: cat_exists = str(cat.id) in mapping_cat structure[cat.id] = (cat.name, cat_exists, []) for ch in src_channels: ch_exists = str(ch.id) in mapping_ch if ch.category_id in structure: structure[ch.category_id][2].append((ch.name, ch_exists)) else: if None not in structure: structure[None] = ("No Category", False, []) structure[None][2].append((ch.name, ch_exists)) preview["structure"] = structure except Exception as e: logger.warning(f"Clone Preview: failed to fetch structure: {e}") return preview async def _logic_dz_delete_channels(self, modal: ProgressScreen) -> None: if self.target_platform == "fluxer": from src.fluxer.danger_zone import danger_delete_all_channels else: from src.stoat.danger_zone import danger_delete_all_channels modal.set_item_status("[red]Deleting channels...") async def on_deleted(name, current, total): modal.set_item_status(f"[red]Deleting: {name}") modal.set_progress(current, total) count = await danger_delete_all_channels(self.engine, progress_callback=on_deleted) modal.write(f"[bold green]Success! {count} channels/categories deleted.[/bold green]") await log_audit_event(self.engine, "Danger Zone: Channels Wiped", f"Deleted {count} channels and categories.") async def _logic_dz_reset_perms(self, modal: ProgressScreen) -> None: if self.target_platform == "fluxer": from src.fluxer.danger_zone import danger_reset_channel_permissions else: from src.stoat.danger_zone import danger_reset_channel_permissions modal.set_item_status("[red]Resetting permissions...") async def on_reset(name, current, total): modal.set_item_status(f"[red]Resetting: {name}") modal.set_progress(current, total) count = await danger_reset_channel_permissions(self.engine, progress_callback=on_reset) modal.write(f"[bold green]Success! Permissions reset on {count} items.[/bold green]") await log_audit_event(self.engine, "Danger Zone: Permissions Wiped", f"Reset permissions on {count} items.") async def _logic_dz_delete_roles(self, modal: ProgressScreen) -> None: if self.target_platform == "fluxer": from src.fluxer.danger_zone import danger_delete_all_roles else: from src.stoat.danger_zone import danger_delete_all_roles modal.set_item_status("[red]Deleting roles...") async def on_deleted(name, current, total): modal.set_item_status(f"[red]Deleting role: {name}") modal.set_progress(current, total) count = await danger_delete_all_roles(self.engine, progress_callback=on_deleted) modal.write(f"[bold green]Success! {count} roles deleted.[/bold green]") await log_audit_event(self.engine, "Danger Zone: Roles Wiped", f"Deleted {count} roles.") async def _logic_dz_delete_assets(self, modal: ProgressScreen) -> None: if self.target_platform == "fluxer": from src.fluxer.danger_zone import danger_delete_all_emojis_and_stickers else: from src.stoat.danger_zone import danger_delete_all_emojis_and_stickers modal.set_item_status("[red]Deleting assets...") async def on_deleted(name, asset_type, current, total): modal.set_item_status(f"[red]Deleting {asset_type}: {name}") modal.set_progress(current, total) 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_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() # Check if profile is empty profile_exists = False if self.exporter.db: try: profile_exists = self.exporter.db.get_guild_profile() is not None except Exception: profile_exists = False if not profile_exists: modal_prog.set_status("First-Time Setup: Exporting Server Profile...") modal_prog.write("[yellow]No existing profile found. Performing primary profile backup...[/yellow]") modal_prog.write("[yellow]Exporting server metadata...[/yellow]") await self.exporter.export_metadata() modal_prog.write("[yellow]Syncing server assets (icon/banner)...[/yellow]") await self.exporter.download_server_assets() modal_prog.write("[yellow]Exporting server structure...[/yellow]") await self.exporter.export_channels_structure() modal_prog.write("[yellow]Exporting roles & permissions...[/yellow]") await self.exporter.export_roles() modal_prog.write("[yellow]Exporting custom emojis & stickers...[/yellow]") await self.exporter.export_assets() modal_prog.write("[bold green]Primary profile setup complete![/bold green]") modal_prog.write("") else: modal_prog.write("[dim]Existing profile detected. Scanning structure...[/dim]") 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() if self.exporter.db: channel_stats = self.exporter.db.get_stats_by_channel() for chan in eligible_channels: if chan.id in channel_stats: 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 accumulated_threads = 0 accumulated_files = 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 = chan.id in backed_up_ids 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, thread_count=0, file_count=0): nonlocal _msg_log_counter modal_prog.update_stats(messages=str(count), threads=str(thread_count), files=str(file_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, accumulated_threads, accumulated_files = await self.exporter.export_channel_messages( chan.id, progress_callback=update_msg_count, force=force_overwrite, accumulated_count=accumulated_msgs, accumulated_threads=accumulated_threads, accumulated_files=accumulated_files, after_id=after_id ) 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 Server Profile & Structure...") modal_prog.write("[yellow]Updating server metadata...[/yellow]") await self.exporter.export_metadata() modal_prog.write("[yellow]Syncing server assets (icon/banner)...[/yellow]") await self.exporter.download_server_assets() modal_prog.write("[yellow]Syncing server structure & channels...[/yellow]") await self.exporter.export_channels_structure() modal_prog.write("[yellow]Syncing roles & permissions...[/yellow]") roles = await self.exporter.export_roles() modal_prog.write("[yellow]Syncing custom emojis & stickers...[/yellow]") e_count, s_count = await self.exporter.export_assets() modal_prog.write(f"[bold green]Profile Sync Complete:[/bold green] {len(roles)} roles, {e_count} emojis, {s_count} stickers.") modal_prog.write("") 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 ] ] # Get channels that have messages in the database backed_up_channel_ids = set() if self.exporter.db: backed_up_channel_ids = set(self.exporter.db.get_stats_by_channel().keys()) selected_channels = [ c for c in eligible_channels if c.id in backed_up_channel_ids ] 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 accumulated_threads = 0 accumulated_files = 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, thread_count=0, file_count=0): nonlocal _msg_log_counter modal_prog.update_stats(messages=str(count), threads=str(thread_count), files=str(file_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, accumulated_threads, accumulated_files = await self.exporter.export_channel_messages( chan.id, progress_callback=update_msg_count, force=False, accumulated_count=accumulated_msgs, accumulated_threads=accumulated_threads, accumulated_files=accumulated_files ) 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()