option select modals

This commit is contained in:
rambros 2026-03-03 16:16:43 +05:30
parent 6c51fcd9ae
commit c7adc61a4a
8 changed files with 738 additions and 493 deletions

View file

@ -22,10 +22,9 @@ class MigrationState:
# audit log tracking
self.audit_log_channel: str | None = None
# message tracking
self.message_map: Dict[str, str] = {}
self.last_message_ids: Dict[str, str] = {}
self.last_message_timestamps: Dict[str, str] = {}
# message tracking per target channel
# Format: { target_channel_id: {"message_map": {}, "last_message_id": "", "last_message_timestamp": ""} }
self.channel_messages: Dict[str, Dict[str, Any]] = {}
self.load()
@ -48,9 +47,25 @@ class MigrationState:
if self.messages_file and self.messages_file.exists():
with open(self.messages_file, "r", encoding="utf-8") as f:
msg_data = json.load(f)
self.message_map = msg_data.get("messages", {})
self.last_message_ids = msg_data.get("last_message_ids", {})
self.last_message_timestamps = msg_data.get("last_message_timestamps", {})
# Check for new schema (nested under 'channels')
if "channels" in msg_data:
self.channel_messages = msg_data.get("channels", {})
else:
# Legacy schema detection & conversion to a default 'unknown_channel' just in case,
# though new migrations shouldn't hit this based on previous removals.
legacy_map = msg_data.get("messages", {})
legacy_ids = msg_data.get("last_message_ids", {})
legacy_times = msg_data.get("last_message_timestamps", {})
if legacy_map or legacy_ids or legacy_times:
self.channel_messages = {
"legacy_migrated_channel": {
"message_map": legacy_map,
"last_message_id": list(legacy_ids.values())[-1] if legacy_ids else "",
"last_message_timestamp": list(legacy_times.values())[-1] if legacy_times else ""
}
}
@ -74,9 +89,7 @@ class MigrationState:
if not self.messages_file:
return
data = {
"last_message_ids": self.last_message_ids,
"last_message_timestamps": self.last_message_timestamps,
"messages": self.message_map
"channels": self.channel_messages
}
with open(self.messages_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
@ -170,31 +183,55 @@ class MigrationState:
def set_target_sticker_mapping(self, discord_id: str, target_id: str):
self.set_sticker_mapping(discord_id, target_id)
def get_target_message_id(self, discord_id: str) -> str | None:
return self.get_fluxer_message_id(discord_id)
def get_target_message_id(self, target_channel_id: str, discord_id: str) -> str | None:
return self.get_fluxer_message_id(target_channel_id, discord_id)
def set_target_message_mapping(self, discord_id: str, target_id: str):
self.set_message_mapping(discord_id, target_id)
def set_target_message_mapping(self, target_channel_id: str, discord_id: str, target_id: str):
self.set_message_mapping(target_channel_id, discord_id, target_id)
# --- Message Management ---
def set_message_mapping(self, discord_id: str, fluxer_id: str):
self.message_map[str(discord_id)] = str(fluxer_id)
def _ensure_channel_tracking(self, target_channel_id: str):
if str(target_channel_id) not in self.channel_messages:
self.channel_messages[str(target_channel_id)] = {
"message_map": {},
"last_message_id": "",
"last_message_timestamp": "",
"total_messages": 0,
"total_files": 0
}
def increment_stats(self, target_channel_id: str, messages: int = 1, files: int = 0):
self._ensure_channel_tracking(target_channel_id)
c = self.channel_messages[str(target_channel_id)]
c["total_messages"] = c.get("total_messages", 0) + messages
c["total_files"] = c.get("total_files", 0) + files
self.save_messages()
def set_message_mapping(self, target_channel_id: str, discord_id: str, fluxer_id: str):
self._ensure_channel_tracking(target_channel_id)
self.channel_messages[str(target_channel_id)]["message_map"][str(discord_id)] = str(fluxer_id)
self.save_messages()
def get_fluxer_message_id(self, discord_id: str) -> str | None:
return self.message_map.get(str(discord_id))
def get_fluxer_message_id(self, target_channel_id: str, discord_id: str) -> str | None:
if str(target_channel_id) in self.channel_messages:
return self.channel_messages[str(target_channel_id)]["message_map"].get(str(discord_id))
return None
def update_last_message_timestamp(self, channel_id: str, timestamp: str):
self.last_message_timestamps[str(channel_id)] = timestamp
def update_last_message_timestamp(self, target_channel_id: str, timestamp: str):
self._ensure_channel_tracking(target_channel_id)
self.channel_messages[str(target_channel_id)]["last_message_timestamp"] = str(timestamp)
self.save_messages()
def update_last_message_id(self, channel_id: str, message_id: str):
self.last_message_ids[str(channel_id)] = message_id
def update_last_message_id(self, target_channel_id: str, message_id: str):
self._ensure_channel_tracking(target_channel_id)
self.channel_messages[str(target_channel_id)]["last_message_id"] = str(message_id)
self.save_messages()
def get_last_message_id(self, channel_id: str) -> str | None:
return self.last_message_ids.get(str(channel_id))
def get_last_message_id(self, target_channel_id: str) -> str | None:
if str(target_channel_id) in self.channel_messages:
return self.channel_messages[str(target_channel_id)].get("last_message_id")
return None
# --- Danger Zone Clearing ---
@ -217,9 +254,7 @@ class MigrationState:
def clear_message_history(self):
"""Clears all message mappings and timestamps."""
self.message_map.clear()
self.last_message_ids.clear()
self.last_message_timestamps.clear()
self.channel_messages.clear()
self.save_messages()
def set_folder(self, server_id: str, clean_name: str, base_dir: Path | str = ""):
@ -240,5 +275,4 @@ class MigrationState:
self.state_file = new_folder / "state-migration.json"
self.messages_file = new_folder / "message-tracker.json"
self.save_state()
self.save_messages()
self.load()

View file

@ -27,7 +27,7 @@ class DiscordExporter:
# Create safe folder name
import re
safe_name = re.sub(r'[^a-zA-Z0-9_\-\.]', '_', self.server_name)
self.export_path = self.base_dir / f"DISCORD-{self.server_id}"
self.export_path = self.base_dir / f"DISCORD_BACKUP-{self.server_id}"
self.export_path.mkdir(parents=True, exist_ok=True)
# Consolidate media into one folder

View file

@ -65,7 +65,7 @@ def clean_mentions(content: str, guild, user_mentions=None, role_mentions=None,
return content
async def analyze_migration(context: MigrationContext, source_channel_id: int, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None) -> Dict[str, int]:
async def analyze_migration(context: MigrationContext, source_channel_id: int, after_message_id: int | None = None, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, int]:
"""
Scans channel history to count messages, threads, and attachments.
"""
@ -92,12 +92,12 @@ async def analyze_migration(context: MigrationContext, source_channel_id: int, a
stats["attachments"] += len(msg.attachments)
if progress_callback and stats["messages"] % 10 == 0:
await progress_callback(stats["messages"])
await progress_callback(stats)
return stats
async def migrate_messages(context: MigrationContext, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None) -> Dict[str, Any]:
async def migrate_messages(context: MigrationContext, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, Any]:
"""Migrate messages for a specific channel and returns detailed statistics."""
stats = {
"messages": 0,
@ -189,7 +189,7 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
# Check if this message is a reply
reply_to_fluxer_id = None
if msg.reference and msg.reference.message_id:
reply_to_fluxer_id = context.state.get_fluxer_message_id(str(msg.reference.message_id))
reply_to_fluxer_id = context.state.get_fluxer_message_id(target_channel_id, str(msg.reference.message_id))
fluxer_msg_id = await context.fluxer_writer.send_message(
channel_id=target_channel_id,
@ -203,11 +203,12 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
)
if fluxer_msg_id:
context.state.set_message_mapping(str(msg.id), fluxer_msg_id)
context.state.set_message_mapping(target_channel_id, str(msg.id), fluxer_msg_id)
context.state.update_last_message_timestamp(str(source_channel_id), str(msg.created_at))
context.state.update_last_message_id(str(source_channel_id), str(msg.id))
context.state.update_last_message_timestamp(target_channel_id, str(msg.created_at))
context.state.update_last_message_id(target_channel_id, str(msg.id))
stats["messages"] += 1
context.state.increment_stats(target_channel_id, messages=1, files=len(files) if files else 0)
# Check for associated thread (Normal case: parent message is migrated)
if hasattr(msg, 'thread') and msg.thread:
@ -240,7 +241,7 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
stats["last_message_url"] = msg.jump_url
if progress_callback:
await progress_callback(stats["messages"])
await progress_callback(stats)
except Exception as e:
logger.error(f"Failed to process message {msg.id}: {e}")
import traceback

View file

@ -65,7 +65,7 @@ def clean_mentions(content: str, guild, user_mentions=None, role_mentions=None,
return content
async def analyze_migration(context: MigrationContext, source_channel_id: int, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None) -> Dict[str, int]:
async def analyze_migration(context: MigrationContext, source_channel_id: int, after_message_id: int | None = None, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, int]:
"""
Scans channel history to count messages, threads, and attachments.
"""
@ -91,12 +91,12 @@ async def analyze_migration(context: MigrationContext, source_channel_id: int, a
stats["attachments"] += len(msg.attachments)
if progress_callback and stats["messages"] % 10 == 0:
await progress_callback(stats["messages"])
await progress_callback(stats)
return stats
async def migrate_messages(context: MigrationContext, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None) -> Dict[str, Any]:
async def migrate_messages(context: MigrationContext, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, Any]:
"""Migrate messages for a specific channel using Stoat masquerade for author impersonation."""
stats = {
"messages": 0,
@ -186,7 +186,7 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
# Check if this message is a reply
reply_to_stoat_id = None
if msg.reference and msg.reference.message_id:
reply_to_stoat_id = context.state.get_target_message_id(str(msg.reference.message_id))
reply_to_stoat_id = context.state.get_target_message_id(target_channel_id, str(msg.reference.message_id))
stoat_msg_id = await context.stoat_writer.send_message(
channel_id=target_channel_id,
@ -200,11 +200,12 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
)
if stoat_msg_id:
context.state.set_message_mapping(str(msg.id), stoat_msg_id)
context.state.set_message_mapping(target_channel_id, str(msg.id), stoat_msg_id)
context.state.update_last_message_timestamp(str(source_channel_id), str(msg.created_at))
context.state.update_last_message_id(str(source_channel_id), str(msg.id))
context.state.update_last_message_timestamp(target_channel_id, str(msg.created_at))
context.state.update_last_message_id(target_channel_id, str(msg.id))
stats["messages"] += 1
context.state.increment_stats(target_channel_id, messages=1, files=len(files) if files else 0)
# Check for associated thread (Normal case: parent message is migrated)
if hasattr(msg, 'thread') and msg.thread:
@ -236,7 +237,7 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
stats["last_message_url"] = msg.jump_url
if progress_callback:
await progress_callback(stats["messages"])
await progress_callback(stats)
except Exception as e:
# If it's a permission error, stop the entire migration
if "MissingPermission" in str(e):

View file

@ -18,7 +18,7 @@ from textual import work
from src.core.configuration import load_config
from src.core.base import MigrationContext
from src.disco_reaper.exporter import DiscordExporter
from src.ui.modals import ProgressScreen, ChannelSelectScreen, ReportModal
from src.ui.modals import ProgressScreen, ChannelSelectScreen
class BackupPane(Container):
@ -126,6 +126,7 @@ class BackupPane(Container):
modal = ProgressScreen()
self.app.call_from_thread(self.app.push_screen, modal)
await asyncio.sleep(0.1)
self.app.call_from_thread(modal.phase_progress)
try:
self.app.call_from_thread(modal.set_status, "Starting readers...")
@ -143,17 +144,17 @@ class BackupPane(Container):
e_count, s_count = await self.exporter.export_assets()
self.app.call_from_thread(modal.write, f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]")
self.app.call_from_thread(modal.write, f"- {cat_count} categories & {chan_count} channels")
self.app.call_from_thread(modal.write, f"- {e_count} emojis, {s_count} stickers.")
self.app.call_from_thread(modal.phase_report, "Profile Backup")
except discord.Forbidden as e:
self.app.call_from_thread(modal.write, f"[bold red]Backup failed: {e}[/bold red]")
self.app.call_from_thread(modal.phase_report, "Profile Backup", "error")
except Exception as e:
self.app.call_from_thread(modal.write, f"[bold red]Error: {e}[/bold red]")
self.app.call_from_thread(modal.phase_report, "Profile Backup", "error")
finally:
await self.engine.close_connections()
self.app.call_from_thread(modal.allow_close)
self.app.call_from_thread(self.app.push_screen, ReportModal("Backup Complete", "Server profile and structure successfully exported."))
@work(exclusive=True, thread=True)
async def run_backup_messages(self) -> None:
@ -190,30 +191,49 @@ class BackupPane(Container):
self.app.call_from_thread(self.app.pop_screen)
loop = asyncio.get_running_loop()
future = loop.create_future()
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)
def check_channels(reply: dict | None) -> None:
if not future.done():
future.set_result(reply)
self.app.call_from_thread(
self.app.push_screen,
ChannelSelectScreen(eligible_channels, cat_map, backed_up_ids, any_found),
check_channels,
)
self.app.call_from_thread(
self.app.push_screen,
ChannelSelectScreen(eligible_channels, cat_map, backed_up_ids, any_found),
check_channels,
)
reply = await future
if not reply:
return
reply = await future
if not reply:
return
selected_ids = reply["channels"]
force_overwrite = reply["force"]
selected_ids = reply["channels"]
force_overwrite = reply["force"]
selected_channels = [c for c in eligible_channels if c.id in selected_ids]
selected_channels = [c for c in eligible_channels if c.id in selected_ids]
# Phase 2: Confirmation
self.app.call_from_thread(self.app.push_screen, modal_prog)
await asyncio.sleep(0.1)
msg = "Sync existing backups" if not force_overwrite else "Overwriting existing backups"
self.app.call_from_thread(modal_prog.set_status, f"Awaiting Confirmation to backup [bold]{len(selected_channels)}[/bold] channels...")
self.app.call_from_thread(modal_prog.show_info, f"[cyan]{msg}[/cyan]", f"Targets: {', '.join([c.name for c in selected_channels[:3]])}{'...' if len(selected_channels) > 3 else ''}")
self.app.call_from_thread(self.app.push_screen, modal_prog)
await asyncio.sleep(0.1)
choice = await modal_prog.phase_wait_confirm()
if choice == "btn_back":
self.app.call_from_thread(modal_prog.dismiss)
continue
elif choice == "btn_main_menu":
self.app.call_from_thread(modal_prog.dismiss)
self.app.call_from_thread(self.app.switch_screen, "config_selection")
return
# Proceed to progress
break
self.app.call_from_thread(modal_prog.phase_progress)
total_chans = len(selected_channels)
self.app.call_from_thread(modal_prog.set_status, "Backing up messages...")
@ -236,19 +256,20 @@ class BackupPane(Container):
await self.exporter.export_metadata()
self.app.call_from_thread(modal_prog.write, "[bold green]Message backup complete![/bold green]")
self.app.call_from_thread(modal_prog.phase_report, "Message Backup")
except Exception as e:
self.app.call_from_thread(modal_prog.write, f"[bold red]Message backup failed: {e}[/bold red]")
self.app.call_from_thread(modal_prog.phase_report, "Message Backup", "error")
finally:
await self.engine.close_connections()
self.app.call_from_thread(modal_prog.allow_close)
self.app.call_from_thread(self.app.push_screen, ReportModal("Backup Complete", "Channel messages and threads successfully backed up."))
@work(exclusive=True, thread=True)
async def run_backup_sync(self) -> None:
modal_prog = ProgressScreen()
self.app.call_from_thread(self.app.push_screen, modal_prog)
await asyncio.sleep(0.1)
self.app.call_from_thread(modal_prog.phase_progress)
try:
self.app.call_from_thread(modal_prog.set_status, "Starting sync...")
@ -288,10 +309,10 @@ class BackupPane(Container):
await self.exporter.export_metadata()
self.app.call_from_thread(modal_prog.write, "[bold green]Sync operation complete![/bold green]")
self.app.call_from_thread(modal_prog.phase_report, "Backup Sync")
except Exception as e:
self.app.call_from_thread(modal_prog.write, f"[bold red]Sync failed: {e}[/bold red]")
self.app.call_from_thread(modal_prog.phase_report, "Backup Sync", "error")
finally:
await self.engine.close_connections()
self.app.call_from_thread(modal_prog.allow_close)
self.app.call_from_thread(self.app.push_screen, ReportModal("Sync Complete", "Backup cleanly synced with latest Discord data."))

View file

@ -9,6 +9,22 @@ from textual.screen import ModalScreen, Screen
import time
import logging
import asyncio
class UILogHandler(logging.Handler):
"""Custom logging handler to send logs to the Textual UI RichLog."""
def __init__(self, callback):
super().__init__()
self.callback = callback
self.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(name)s: %(message)s', '%H:%M:%S'))
def emit(self, record):
try:
msg = self.format(record)
self.callback(msg)
except Exception:
pass
# ---------------------------------------------------------------------------
# ProgressScreen unified full-screen progress / log / stats display
@ -44,12 +60,17 @@ class ProgressScreen(Screen[None]):
.stat_label { width: 1fr; content-align: center middle; text-style: bold; }
#prog_log { height: 1fr; margin-bottom: 1; border: solid $primary; }
#live_log { height: 10; margin-bottom: 1; border: solid yellow; }
#prog_loader { margin-bottom: 1; }
#prog_bar_container { height: auto; width: 100%; }
#prog_bar_container { height: auto; width: 100%; align: center middle; }
#prog_bar { margin-bottom: 1; width: 80%; }
#prog_actions { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; }
#btn_close_progress { width: 1fr; margin: 0 1; }
#info_container { height: auto; layout: vertical; border: solid $secondary; padding: 1; margin-bottom: 1; display: none; }
.info_label { text-style: bold; content-align: center middle; width: 100%; color: $secondary-lighten-2; }
#prog_actions { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; layout: vertical; }
.action_row { height: auto; layout: horizontal; }
.action_row Button { width: 1fr; margin: 0 1; }
"""
def compose(self) -> ComposeResult:
@ -66,20 +87,58 @@ class ProgressScreen(Screen[None]):
yield Label("Files: 0", id="stat_files", classes="stat_label")
yield LoadingIndicator(id="prog_loader")
with Center(id="prog_bar_container"):
pb = ProgressBar(total=None, show_eta=False, id="prog_bar")
pb.display = False
yield pb
with Vertical(id="info_container"):
yield Label("No previous migration data.", id="info_migration_status", classes="info_label")
yield Label("New items detected.", id="info_new_items", classes="info_label")
# Progress bar moved inside info container
with Center(id="prog_bar_container"):
pb = ProgressBar(total=None, show_eta=False, id="prog_bar")
pb.display = False
yield pb
yield RichLog(id="prog_log", highlight=True, markup=True)
yield RichLog(id="live_log", highlight=True, markup=True)
with Horizontal(id="prog_actions"):
yield Button("Close", id="btn_close_progress", disabled=True)
with Vertical(id="prog_actions"):
with Horizontal(classes="action_row", id="prog_actions_row1"):
yield Button("Start from First", id="btn_start_first", disabled=True, variant="primary")
yield Button("Continue Migration", id="btn_continue", disabled=True, variant="success")
yield Button("Start from ID", id="btn_start_id", disabled=True, variant="warning")
with Horizontal(classes="action_row", id="prog_actions_row2"):
yield Button("Back", id="btn_back", disabled=False)
yield Button("Main Menu", id="btn_main_menu", disabled=False)
with Horizontal(classes="action_row", id="prog_actions_cancel"):
yield Button("Cancel", id="btn_cancel", variant="error")
yield Footer()
def on_mount(self):
self.confirm_future = None
self.cancel_callback = None
self.start_time = time.time()
self.timer_event = self.set_interval(1.0, self.update_timer)
# Hide all action rows by default (fetch phase = no buttons)
try: self.query_one("#prog_actions_row1", Horizontal).display = False
except Exception: pass
try: self.query_one("#prog_actions_row2", Horizontal).display = False
except Exception: pass
try: self.query_one("#prog_actions_cancel", Horizontal).display = False
except Exception: pass
# Intercept Python logs and pipe to the #live_log
self.log_handler = UILogHandler(self.write_live)
# Attach to root logger
logging.getLogger().addHandler(self.log_handler)
# Also let's capture discord.py logs specifically if they aren't propagating
logging.getLogger("discord").addHandler(self.log_handler)
def on_unmount(self):
# Detach log handler when UI is cleanly removed
if hasattr(self, "log_handler"):
logging.getLogger().removeHandler(self.log_handler)
logging.getLogger("discord").removeHandler(self.log_handler)
def update_timer(self):
elapsed = int(time.time() - self.start_time)
@ -90,10 +149,25 @@ class ProgressScreen(Screen[None]):
pass
def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "btn_close_progress":
btn_id = event.button.id
if self.confirm_future and not self.confirm_future.done():
self.confirm_future.set_result(btn_id)
return
# If Cancel is pressed during operation, invoke callback and dismiss
if btn_id == "btn_cancel":
if self.cancel_callback:
self.cancel_callback()
if self.timer_event:
self.timer_event.stop()
self.dismiss(None)
self.dismiss("btn_cancel")
return
# If operation is done (report phase), just dismiss with the action
if btn_id in ["btn_back", "btn_main_menu"]:
if self.timer_event:
self.timer_event.stop()
self.dismiss(btn_id)
def write(self, message: str):
try:
@ -101,6 +175,12 @@ class ProgressScreen(Screen[None]):
except Exception:
pass
def write_live(self, message: str):
try:
self.query_one("#live_log", RichLog).write(message)
except Exception:
pass
write_to_log = write
def set_status(self, status: str):
@ -132,79 +212,119 @@ class ProgressScreen(Screen[None]):
except Exception:
pass
def allow_close(self):
if self.timer_event:
self.timer_event.stop()
async def phase_wait_confirm(self, show_continue: bool = False):
"""Phase 2: Wait for user confirmation after analysis."""
try: self.query_one("#prog_loader", LoadingIndicator).display = False
except Exception: pass
try: self.query_one("#prog_bar", ProgressBar).display = False
except Exception: pass
# Show confirmation buttons
try: self.query_one("#prog_actions_row1", Horizontal).display = True
except Exception: pass
try: self.query_one("#prog_actions_row2", Horizontal).display = True
except Exception: pass
try: self.query_one("#prog_actions_cancel", Horizontal).display = False
except Exception: pass
try: self.query_one("#btn_start_first", Button).disabled = False
except Exception: pass
try: self.query_one("#btn_start_id", Button).disabled = False
except Exception: pass
try:
btn = self.query_one("#btn_close_progress", Button)
btn.disabled = False
btn.variant = "success"
self.query_one("#prog_loader", LoadingIndicator).display = False
bar = self.query_one("#prog_bar", ProgressBar)
bar.display = True
bar.update(total=100, progress=100)
if show_continue:
self.query_one("#btn_continue", Button).disabled = False
self.query_one("#btn_continue", Button).display = True
else:
self.query_one("#btn_continue", Button).display = False
except Exception:
pass
loop = asyncio.get_running_loop()
self.confirm_future = loop.create_future()
# Wait until the user clicks one of the buttons
choice = await self.confirm_future
return choice
def phase_progress(self):
"""Phase 3: The actual operation begins. Only Cancel visible."""
try: self.query_one("#prog_actions_row1", Horizontal).display = False
except Exception: pass
try: self.query_one("#prog_actions_row2", Horizontal).display = False
except Exception: pass
# Show Cancel button
try: self.query_one("#prog_actions_cancel", Horizontal).display = True
except Exception: pass
try: self.query_one("#prog_loader", LoadingIndicator).display = True
except Exception: pass
try: self.query_one("#info_migration_status", Label).display = False
except Exception: pass
try: self.query_one("#info_new_items", Label).display = False
except Exception: pass
def phase_report(self, operation_name: str, status: str = "complete"):
"""Phase 4: Operation is done. Show Back + Main Menu.
status can be: 'complete', 'stopped', 'error'
"""
try:
if self.timer_event:
self.timer_event.stop()
except Exception:
pass
# Format status title with color
status_map = {
"complete": ("[bold green]", "Complete"),
"stopped": ("[bold yellow]", "Stopped"),
"error": ("[bold red]", "Error"),
}
color, label = status_map.get(status, ("[bold]", status.capitalize()))
self.set_status(f"{color}{operation_name} {label}![/{color.split(']')[0].lstrip('[')}]")
# Hide loader
try: self.query_one("#prog_loader", LoadingIndicator).display = False
except Exception: pass
# Hide progress bar (no need to show 100% bar)
try: self.query_one("#prog_bar", ProgressBar).display = False
except Exception: pass
# Hide Cancel, show Back + Main Menu
try: self.query_one("#prog_actions_cancel", Horizontal).display = False
except Exception: pass
try: self.query_one("#prog_actions_row1", Horizontal).display = False
except Exception: pass
try: self.query_one("#prog_actions_row2", Horizontal).display = True
except Exception: pass
try:
back_btn = self.query_one("#btn_back", Button)
back_btn.disabled = False
except Exception:
pass
# ---------------------------------------------------------------------------
# ReportModal simple post-operation report
# ---------------------------------------------------------------------------
class ReportModal(ModalScreen[None]):
"""Modal to display a post-operation report."""
DEFAULT_CSS = """
ReportModal { align: center middle; }
#report_dialog {
width: 60;
height: auto;
border: thick $background 80%;
background: $surface;
padding: 1 2;
}
#report_title { text-style: bold; margin-bottom: 1; content-align: center middle; width: 100%; color: green; }
#report_content { margin-bottom: 2; height: auto; }
#report_btn { width: 1fr; margin: 0 1; }
"""
def __init__(self, title: str, report_text: str):
super().__init__()
self.report_title = title
self.report_text = report_text
def compose(self) -> ComposeResult:
with Vertical(id="report_dialog"):
yield Label(self.report_title, id="report_title")
yield Label(self.report_text, id="report_content")
yield Button("OK", variant="primary", id="report_btn")
def on_button_pressed(self, event: Button.Pressed):
self.dismiss(None)
# ---------------------------------------------------------------------------
# ConfirmModal simple yes / no
# ---------------------------------------------------------------------------
class ConfirmModal(ModalScreen[bool]):
"""Simple Yes / No confirmation modal."""
DEFAULT_CSS = "ConfirmModal { align: center middle; }"
def __init__(self, message: str, danger: bool = False):
super().__init__()
self._message = message
self._danger = danger
def compose(self) -> ComposeResult:
with Vertical(id="confirm_dialog"):
yield Label(self._message, id="confirm_msg")
with Horizontal(id="confirm_buttons"):
yield Button("Confirm", variant="error" if self._danger else "success", id="btn_yes")
yield Button("Cancel", variant="primary", id="btn_no")
def on_button_pressed(self, event: Button.Pressed):
self.dismiss(event.button.id == "btn_yes")
def show_info(self, migration_status: str, items_status: str):
try:
info = self.query_one("#info_container", Vertical)
info.display = True
self.query_one("#info_migration_status", Label).update(migration_status)
self.query_one("#info_new_items", Label).update(items_status)
except Exception:
pass
def allow_close(self):
"""Legacy fallback: show Back + Main Menu buttons for early exit."""
self.phase_report("Operation", "error")
# ---------------------------------------------------------------------------
# SubMenuModal generic labelled-button list
@ -235,6 +355,71 @@ class SubMenuModal(ModalScreen[str]):
self.dismiss(event.button.id)
# ---------------------------------------------------------------------------
# OptionSelectModal radio-button selection list
# ---------------------------------------------------------------------------
class OptionSelectModal(ModalScreen[list[str]]):
"""A modal that presents a list of options using RadioButtons (for multi-select) and a Proceed button."""
DEFAULT_CSS = """
OptionSelectModal { align: center middle; }
#opt_dialog {
width: 60;
height: auto;
border: solid green;
background: $surface;
padding: 1 2;
}
#opt_title { text-style: bold; margin-bottom: 1; text-align: center; width: 100%; }
#opt_scroll { margin-bottom: 1; border: solid $primary; max-height: 12; padding: 1; }
#opt_buttons { height: auto; }
#opt_buttons Button { width: 1fr; margin: 0 1; }
#opt_batch_buttons { height: auto; margin-bottom: 1; }
#opt_batch_buttons Button { width: 1fr; margin: 0 1; }
"""
def __init__(self, title: str, options: list[tuple[str, str]]):
"""options: list of (option_id, label)"""
super().__init__()
self._title = title
self._options = options
def compose(self) -> ComposeResult:
with Vertical(id="opt_dialog"):
yield Label(self._title, id="opt_title")
with Horizontal(id="opt_batch_buttons"):
yield Button("Select All", id="btn_opt_all")
yield Button("Deselect All", id="btn_opt_none")
with VerticalScroll(id="opt_scroll"):
for opt_id, label in self._options:
yield RadioButton(label, id=f"opt_{opt_id}")
yield Rule()
with Horizontal(id="opt_buttons"):
yield Button("Proceed", variant="success", id="btn_opt_ok")
yield Button("Back", id="btn_opt_back")
def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "btn_opt_back":
self.dismiss(None)
elif event.button.id == "btn_opt_ok":
selected = []
for rb in self.query(RadioButton):
if rb.value:
selected.append(rb.id.split("_", 1)[1])
if selected:
self.dismiss(selected)
else:
self.app.notify("Please select at least one option.", severity="warning")
elif event.button.id == "btn_opt_all":
for rb in self.query(RadioButton):
rb.value = True
elif event.button.id == "btn_opt_none":
for rb in self.query(RadioButton):
rb.value = False
# ---------------------------------------------------------------------------
# ChannelPickerModal single-channel selection (shuttle)
# ---------------------------------------------------------------------------
@ -322,11 +507,11 @@ class ChannelPickerScreen(Screen[tuple]):
yield Rule()
with Horizontal(id="chanpick_buttons"):
yield Button("Select", variant="success", id="btn_pick_ok")
yield Button("Cancel", id="btn_pick_cancel")
yield Button("Back", id="btn_pick_back")
yield Footer()
def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "btn_pick_cancel":
if event.button.id == "btn_pick_back":
self.dismiss(None)
elif event.button.id == "btn_pick_ok":
src_val = None
@ -442,7 +627,7 @@ class ChannelSelectScreen(Screen[dict]):
yield Button("Force Overwrite", variant="error", id="btn_force")
else:
yield Button("Backup", variant="success", id="btn_backup")
yield Button("Cancel", id="btn_cancel_chan")
yield Button("Back", id="btn_cancel_chan")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:

View file

@ -1223,7 +1223,7 @@ class MigrationCLI:
server_name = self.validation_results.get("discord_server_name", "server")
# Check for existing migration
last_migrated_id = self.engine.state.get_last_message_id(str(source_channel.id))
last_migrated_id = self.engine.state.get_last_message_id(str(target_channel.get('id')))
next_msg = None
if last_migrated_id:
try:

View file

@ -20,7 +20,7 @@ from src.core.configuration import load_config
from src.core.base import MigrationContext
from src.core.audit import log_audit_event
from src.ui.modals import (
ProgressScreen, ReportModal, ConfirmModal, SubMenuModal, ChannelPickerScreen,
ProgressScreen, SubMenuModal, ChannelPickerScreen, OptionSelectModal,
)
import src.fluxer.roles_permissions as fluxer_roles
@ -106,9 +106,7 @@ class ShuttlePane(Container):
yield Label("Status: [yellow]Validating...[/yellow]", id="sp_lbl_status")
with Vertical(id="sp_actions"):
yield Button("Clone Server Template", id="sp_clone", disabled=True)
yield Button("Copy Roles & Permissions", id="sp_roles", disabled=True)
yield Button("Copy Emojis & Stickers", id="sp_emojis", disabled=True)
yield Button("Sync Server Profile", id="sp_metadata", disabled=True)
yield Button("Sync Server Settings", id="sp_sync", disabled=True)
yield Button("Migrate Message History", id="sp_messages", disabled=True)
yield Rule()
yield Button("Danger Zone ⚠", id="sp_danger", variant="error", disabled=True)
@ -170,7 +168,7 @@ class ShuttlePane(Container):
self.query_one("#sp_lbl_status", Label).update(f"Status: {val}")
# Buttons
for bid in ("#sp_clone", "#sp_roles", "#sp_emojis", "#sp_metadata", "#sp_messages", "#sp_danger"):
for bid in ("#sp_clone", "#sp_sync", "#sp_messages", "#sp_danger"):
self.query_one(bid, Button).disabled = not self.tokens_valid
# ── validation ────────────────────────────────────────────────────────
@ -271,282 +269,223 @@ class ShuttlePane(Container):
if not bid or not bid.startswith("sp_"):
return
if bid == "sp_clone":
self.run_clone_template()
elif bid == "sp_roles":
self._open_roles_menu()
elif bid == "sp_emojis":
self._open_emoji_menu()
elif bid == "sp_metadata":
self._open_metadata_menu()
self._open_clone_menu()
elif bid == "sp_sync":
self._open_sync_menu()
elif bid == "sp_messages":
self.run_migrate_messages()
elif bid == "sp_danger":
self._open_danger_menu()
# ── (1) clone server template ─────────────────────────────────────────
# ── (1) clone server template (combined) ─────────────────────────────
def _open_clone_menu(self):
options = [
("sub_clone_roles", "Clone Roles & Role Permissions"),
("sub_clone_channels", "Clone Channels & Categories"),
("sub_sync_perms", "Sync 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", "Sync Emojis"),
("sub_sticker", "Sync Stickers"),
("sub_name", "Sync Server Name"),
("sub_icon", "Sync Server Icon"),
("sub_banner", "Sync 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_clone_template(self) -> None:
async def run_batch_clone(self, selections: list[str]) -> None:
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
try:
modal.set_status(f"Awaiting Confirmation for {len(selections)} Operations...")
choice = await modal.phase_wait_confirm()
if choice == "btn_back":
modal.dismiss()
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()
await self.engine.start_connections()
self.engine.is_running = True
for sel in selections:
if not self.engine.is_running: break
if sel == "sub_clone_roles":
await self._logic_clone_roles(modal)
elif sel == "sub_clone_channels":
await self._logic_clone_channels(modal)
elif sel == "sub_sync_perms":
await self._logic_sync_permissions(modal)
modal.phase_report("Clone Template Complete")
modal.write("[bold green]All selected cloning operations finished.[/bold green]")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
modal.phase_report("Batch Operation", "error")
finally:
self.engine.is_running = False
await self.engine.close_connections()
@work(exclusive=True)
async def run_batch_sync(self, selections: list[str]) -> None:
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
try:
modal.set_status("Awaiting Confirmation to Sync Server Settings...")
choice = await modal.phase_wait_confirm()
if choice == "btn_back":
modal.dismiss()
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()
await self.engine.start_connections()
self.engine.is_running = True
# 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:
await self._logic_copy_assets(modal, asset_types)
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:
await self._logic_sync_metadata(modal, meta_comps)
modal.phase_report("Sync Settings Complete")
modal.write("[bold green]All selected synchronization operations finished.[/bold green]")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
modal.phase_report("Batch Operation", "error")
finally:
self.engine.is_running = False
await self.engine.close_connections()
# ── logic blocks (internal) ────────────────────────────────────────
async def _logic_clone_channels(self, modal: ProgressScreen):
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_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_status(f"[{color}]{status}: {item_name}[/{color}]")
modal.set_progress(current, total)
cloned_info = await migrate_channels(self.engine, progress_callback=update_progress, force=False)
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))
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
try:
modal.set_status("Fetching server structure...")
await self.engine.start_connections()
await sync_channel_state(self.engine)
categories = await self.engine.discord_reader.get_categories()
channels = await self.engine.discord_reader.get_channels()
cached = sum(1 for c in categories if self.engine.state.get_fluxer_category_id(str(c.id)))
cached += sum(1 for c in channels if self.engine.state.get_fluxer_channel_id(str(c.id)))
total = len(categories) + len(channels)
modal.write(f"[yellow]Found {total} items, {cached} already cloned.[/yellow]")
modal.set_status("Cloning channels...")
async def update_progress(item_name, status, current, total):
color = "cyan" if status == "Copying" else "yellow"
modal.set_status(f"[{color}]{status}: {item_name}[/{color}]")
modal.set_progress(current, total)
self.engine.is_running = True
cloned_info = await migrate_channels(self.engine, progress_callback=update_progress, force=False)
modal.write("[bold green]Server Template cloned![/bold green]")
if cloned_info and cloned_info.get("structure"):
lines = ["Successfully cloned channels and categories from Discord:"]
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, "Server Template Cloned", "\n".join(lines))
else:
await log_audit_event(self.engine, "Server Template Cloned", "No new items were cloned.")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
finally:
self.engine.is_running = False
await self.engine.close_connections()
modal.set_status("Finished.")
modal.allow_close()
# ── (2) roles & permissions ───────────────────────────────────────────
def _open_roles_menu(self):
options = [
("sub_clone_roles", "Clone Roles & Role Permissions", "primary"),
("sub_sync_perms", "Sync Channel & Category Permissions", "warning"),
]
def on_result(choice):
if choice == "sub_clone_roles":
self.run_clone_roles()
elif choice == "sub_sync_perms":
self.run_sync_permissions()
self.app.push_screen(SubMenuModal("Roles & Permissions", options), on_result)
@work(exclusive=True)
async def run_clone_roles(self) -> None:
async def _logic_clone_roles(self, modal: ProgressScreen):
roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.set_status("Processing Roles...")
await roles_mod.sync_roles_state(self.engine)
async def update(name, current, total):
modal.set_status(f"[cyan]Copying Role: {name}[/cyan]")
modal.set_progress(current, total)
cloned = await roles_mod.migrate_roles(self.engine, progress_callback=update, force=False)
if cloned:
await log_audit_event(self.engine, "Roles Cloned", "Successfully cloned roles:\n" + "\n".join(f"- {r}" for r in cloned))
try:
modal.set_status("Checking existing roles...")
await self.engine.start_connections()
await roles_mod.sync_roles_state(self.engine)
roles = await self.engine.discord_reader.get_roles()
cached = sum(1 for r in roles if self.engine.state.get_target_role_id(str(r.id)))
modal.write(f"[yellow]Found {len(roles)} roles, {cached} already cloned.[/yellow]")
modal.set_status("Cloning roles...")
async def update(name, current, total):
modal.set_status(f"[cyan]Copying: {name}[/cyan]")
modal.set_progress(current, total)
self.engine.is_running = True
cloned = await roles_mod.migrate_roles(self.engine, progress_callback=update, force=False)
modal.write("[bold green]Role migration complete![/bold green]")
if cloned:
desc = "Successfully cloned roles:\n" + "\n".join(f"- {r}" for r in cloned)
await log_audit_event(self.engine, "Roles Cloned", desc)
else:
await log_audit_event(self.engine, "Roles Cloned", "No new roles were cloned.")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
finally:
self.engine.is_running = False
await self.engine.close_connections()
modal.set_status("Finished.")
modal.allow_close()
@work(exclusive=True)
async def run_sync_permissions(self) -> None:
async def _logic_sync_permissions(self, modal: ProgressScreen):
roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.set_status("Syncing Permissions...")
async def update(name, current, total):
modal.set_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))
try:
modal.set_status("Syncing permissions...")
await self.engine.start_connections()
async def update(name, current, total):
modal.set_status(f"[cyan]Syncing: {name}[/cyan]")
modal.set_progress(current, total)
self.engine.is_running = True
synced = await roles_mod.sync_permissions(self.engine, progress_callback=update)
modal.write("[bold green]Permission sync complete![/bold green]")
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))
else:
await log_audit_event(self.engine, "Permissions Synced", "No permissions synchronized.")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
finally:
self.engine.is_running = False
await self.engine.close_connections()
modal.set_status("Finished.")
modal.allow_close()
# ── (3) emojis & stickers ─────────────────────────────────────────────
def _open_emoji_menu(self):
options = [
("sub_emoji", "Sync Emojis only", "primary"),
("sub_sticker", "Sync Stickers only", "primary"),
("sub_both", "Sync Emojis & Stickers", "success"),
]
def on_result(choice):
types = []
if choice == "sub_emoji":
types = ["Emoji"]
elif choice == "sub_sticker":
types = ["Sticker"]
elif choice == "sub_both":
types = ["Emoji", "Sticker"]
if types:
self.run_copy_emojis(types)
self.app.push_screen(SubMenuModal("Emojis & Stickers", options), on_result)
@work(exclusive=True)
async def run_copy_emojis(self, types_to_include: list[str]) -> None:
async def _logic_copy_assets(self, modal: ProgressScreen, types_to_include: list[str]):
asset_mod = stoat_emoji_stickers if self.target_platform == "stoat" else fluxer_emoji_stickers
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.set_status("Processing Assets...")
await asset_mod.sync_assets_state(self.engine)
async def update(name, item_type, current, total):
modal.set_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=False)
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))
try:
modal.set_status("Checking existing assets...")
await self.engine.start_connections()
await asset_mod.sync_assets_state(self.engine)
modal.set_status("Copying assets...")
async def update(name, item_type, current, total):
modal.set_status(f"[cyan]Copying {item_type}: {name}[/cyan]")
modal.set_progress(current, total)
self.engine.is_running = True
cloned = await asset_mod.migrate_emojis(
self.engine,
progress_callback=update,
types_to_include=types_to_include,
force=False,
)
modal.write("[bold green]Asset migration complete![/bold green]")
if cloned and (cloned.get("Emoji") or cloned.get("Sticker")):
lines = []
if cloned.get("Emoji"):
lines.append("Emojis cloned:")
for n in cloned["Emoji"]:
lines.append(f"- {n}")
if cloned.get("Sticker"):
lines.append("Stickers cloned:")
for n in cloned["Sticker"]:
lines.append(f"- {n}")
await log_audit_event(self.engine, "Emojis & Stickers Cloned", "\n".join(lines))
else:
await log_audit_event(self.engine, "Emojis & Stickers Cloned", "No new assets cloned.")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
finally:
self.engine.is_running = False
await self.engine.close_connections()
modal.set_status("Finished.")
modal.allow_close()
# ── (4) server metadata sync ──────────────────────────────────────────
def _open_metadata_menu(self):
options = [
("sub_name", "Sync Name only", "primary"),
("sub_icon", "Sync Icon only", "primary"),
("sub_banner", "Sync Banner only", "primary"),
("sub_all_meta", "Sync Everything", "success"),
]
def on_result(choice):
comps = []
if choice == "sub_name":
comps = ["name"]
elif choice == "sub_icon":
comps = ["icon"]
elif choice == "sub_banner":
comps = ["banner"]
elif choice == "sub_all_meta":
comps = ["name", "icon", "banner"]
if comps:
self.run_sync_metadata(comps)
self.app.push_screen(SubMenuModal("Sync Server Profile", options), on_result)
@work(exclusive=True)
async def run_sync_metadata(self, components: list[str]) -> None:
async def _logic_sync_metadata(self, modal: ProgressScreen, components: list[str]):
meta_mod = fluxer_metadata if self.target_platform == "fluxer" else stoat_metadata
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
try:
modal.set_status("Syncing server metadata...")
await self.engine.start_connections()
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)
modal.write("[bold green]Server profile sync finished![/bold green]")
lines = ["Synchronized Community profile:"]
if "name" in cloned:
lines.append(f"- **Name**: {cloned['name']}")
if "icon" in cloned:
lines.append("- **Icon**")
if "banner" in cloned:
lines.append("- **Banner**")
modal.set_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"
@ -554,16 +493,7 @@ class ShuttlePane(Container):
if "banner" in cloned:
ext = "gif" if cloned["banner"].startswith(b"GIF") else "png"
files.append({"filename": f"banner.{ext}", "data": cloned["banner"]})
if cloned:
await log_audit_event(self.engine, "Server Profile Synced", "\n".join(lines), files=files)
else:
await log_audit_event(self.engine, "Server Profile Synced", "Nothing synchronized.")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
finally:
await self.engine.close_connections()
modal.set_status("Finished.")
modal.allow_close()
await log_audit_event(self.engine, "Profile Synced", "\n".join(lines), files=files)
# ── (5) message migration ─────────────────────────────────────────────
@ -609,62 +539,114 @@ class ShuttlePane(Container):
target_cat_names = {str(c.get("id")): c.get("name") for c in full_f if c.get("type") == 4}
loop = asyncio.get_running_loop()
pick_future = loop.create_future()
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)
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), on_pick)
res = await pick_future
self.app.push_screen(ChannelPickerScreen(d_channels, d_cat_map, f_channels, target_cat_names, platform_name), on_pick)
res = await pick_future
if res is None:
await self.engine.close_connections()
return
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)
if res is None:
await self.engine.close_connections()
return
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
after_id = None
last_migrated = self.engine.state.get_last_message_id(str(source_channel.id))
if last_migrated:
after_id = int(last_migrated)
# Determine after_id status
last_migrated = self.engine.state.get_last_message_id(str(target_channel.get('id')))
has_previous = bool(last_migrated)
# Analyze
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.set_status("Analyzing channel...")
modal.show_stats()
# Analyze
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.set_status("Analyzing channel...")
modal.show_stats()
self.engine.is_running = True
stats_analysis = {"messages": 0, "threads": 0, "attachments": 0}
self.engine.is_running = True
stats = {"messages": 0, "threads": 0, "attachments": 0}
async def update_scan(current_stats):
modal.set_status(f"[cyan]Scanned {current_stats['messages']} items...")
async def update_scan(count):
modal.set_status(f"[cyan]Scanned {count} items...")
stats_analysis = await 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,
)
self.engine.is_running = False
stats = await migrate_mod.analyze_migration(
self.engine,
source_channel_id=source_channel.id,
after_message_id=after_id,
progress_callback=update_scan,
)
self.engine.is_running = False
# Set initial total stats for the confirmation block
modal.update_stats(
messages=stats_analysis['messages'],
threads=stats_analysis['threads'],
files=stats_analysis['attachments']
)
modal.write(f"[cyan]Messages: {stats['messages']}, Threads: {stats['threads']}, Attachments: {stats['attachments']}[/cyan]")
modal.write(f"Migrating Discord [cyan]#{source_channel.name}[/cyan] → {platform_name} [green]#{target_channel.get('name')}[/green]")
# Setup the info container
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]")
# Phase 2: Confirmation
choice = await modal.phase_wait_confirm(show_continue=has_previous)
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:
after_id = int(last_migrated)
elif choice == "btn_start_id":
# Fallback to full for now since we don't have an ID input dialog yet
modal.write("[yellow]Custom Message ID start not fully implemented, starting from beginning.[/yellow]")
after_id = None
# If we are here, we are proceeding with migration
break
# Phase 3: Progress
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
modal.phase_progress()
modal.set_status("Migrating messages...")
total_messages = stats["messages"]
total_messages = stats_analysis["messages"]
total_threads = stats_analysis["threads"]
total_attachments = stats_analysis["attachments"]
self.engine.is_running = True
async def update_msg(count):
modal.set_status(f"[cyan]Migrated {count}/{total_messages} messages...")
modal.set_progress(count, total_messages)
modal.update_stats(messages=count)
async def update_msg(current_stats):
c_msgs = current_stats["messages"]
c_threads = current_stats["threads"]
c_files = current_stats["attachments"]
modal.set_status(f"[cyan]Migrated {c_msgs}/{total_messages} messages...")
modal.set_progress(c_msgs, total_messages)
modal.update_stats(
messages=f"{c_msgs}/{total_messages}",
threads=f"{c_threads}/{total_threads}",
files=f"{c_files}/{total_attachments}"
)
# optionally show a scrolling trace if the backend provided it
modal.write_live(f"Migrated message #{c_msgs}")
result = await migrate_mod.migrate_messages(
self.engine,
@ -676,15 +658,12 @@ class ShuttlePane(Container):
if self.engine.is_running:
modal.write(f"[bold green]Success! {result['messages']} messages migrated.[/bold green]")
event_title = "Message History Migrated"
event_title = "Message Migration"
modal.phase_report(event_title)
else:
modal.write(f"[bold yellow]Interrupted! {result['messages']} messages migrated.[/bold yellow]")
event_title = "Message History Migration Interrupted"
self.app.push_screen(ReportModal(
event_title,
f"Successfully processed {result['messages']} messages, {result['attachments']} attachments, and {result['threads']} threads."
))
event_title = "Message Migration"
modal.phase_report(event_title, "stopped")
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")
@ -696,10 +675,10 @@ class ShuttlePane(Container):
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")
finally:
self.engine.is_running = False
await self.engine.close_connections()
modal.allow_close()
# ── (6) danger zone ───────────────────────────────────────────────────
@ -722,10 +701,22 @@ class ShuttlePane(Container):
self.app.push_screen(SubMenuModal("⚠ DANGER ZONE ⚠", options), on_result)
def _confirm_danger(self, message: str, callback):
def on_confirm(confirmed: bool):
if confirmed:
async def do_confirm():
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.set_status(f"[bold red]DANGER:[/bold red] {message}")
modal.write("[bold red]WARNING: THIS WILL DELETE DATA PERMANENTLY! MUST PROCEED TO CONTINUE.[/bold red]")
choice = await modal.phase_wait_confirm()
modal.dismiss()
if choice == "btn_start_first":
callback()
self.app.push_screen(ConfirmModal(message, danger=True), on_confirm)
elif choice == "btn_main_menu":
self.app.switch_screen("config_selection")
asyncio.create_task(do_confirm())
@work(exclusive=True)
async def run_dz_delete_channels(self) -> None:
@ -737,6 +728,8 @@ class ShuttlePane(Container):
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
modal.phase_progress()
try:
modal.set_status("[red]Deleting channels...")
@ -747,14 +740,15 @@ class ShuttlePane(Container):
modal.set_progress(current, total)
count = await danger_delete_all_channels(self.engine, progress_callback=on_deleted)
modal.write(f"[bold green]{count} channels/categories deleted.[/bold green]")
modal.phase_report("Danger Zone: Channels Wiped")
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.")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
modal.phase_report("Delete Channels", "error")
finally:
await self.engine.close_target_only()
modal.set_status("Finished.")
modal.allow_close()
@work(exclusive=True)
async def run_dz_reset_perms(self) -> None:
@ -766,6 +760,8 @@ class ShuttlePane(Container):
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
modal.phase_progress()
try:
modal.set_status("[red]Resetting permissions...")
@ -776,14 +772,15 @@ class ShuttlePane(Container):
modal.set_progress(current, total)
count = await danger_reset_channel_permissions(self.engine, progress_callback=on_reset)
modal.write(f"[bold green]Permissions reset on {count} items.[/bold green]")
modal.phase_report("Danger Zone: Permissions Wiped")
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.")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
modal.phase_report("Wipe Permissions", "error")
finally:
await self.engine.close_target_only()
modal.set_status("Finished.")
modal.allow_close()
@work(exclusive=True)
async def run_dz_delete_roles(self) -> None:
@ -795,6 +792,8 @@ class ShuttlePane(Container):
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
modal.phase_progress()
try:
modal.set_status("[red]Deleting roles...")
@ -805,14 +804,15 @@ class ShuttlePane(Container):
modal.set_progress(current, total)
count = await danger_delete_all_roles(self.engine, progress_callback=on_deleted)
modal.write(f"[bold green]{count} roles deleted.[/bold green]")
modal.phase_report("Danger Zone: Roles Wiped")
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.")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
modal.phase_report("Wipe Roles", "error")
finally:
await self.engine.close_target_only()
modal.set_status("Finished.")
modal.allow_close()
@work(exclusive=True)
async def run_dz_delete_assets(self) -> None:
@ -824,6 +824,8 @@ class ShuttlePane(Container):
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
modal.phase_progress()
try:
modal.set_status("[red]Deleting assets...")
@ -834,11 +836,12 @@ class ShuttlePane(Container):
modal.set_progress(current, total)
counts = await danger_delete_all_emojis_and_stickers(self.engine, progress_callback=on_deleted)
modal.write(f"[bold green]{counts.get('emojis', 0)} emojis, {counts.get('stickers', 0)} stickers deleted.[/bold green]")
modal.phase_report("Danger Zone: Assets Wiped")
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.")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
modal.phase_report("Wipe Assets", "error")
finally:
await self.engine.close_target_only()
modal.set_status("Finished.")
modal.allow_close()