disco-reaper/src/ui/shuttle_ops.py
2026-03-26 23:02:23 +05:30

2170 lines
106 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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()