diff --git a/src/core/state.py b/src/core/state.py index 5fece7d..a7d4bd7 100644 --- a/src/core/state.py +++ b/src/core/state.py @@ -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() diff --git a/src/disco_reaper/exporter.py b/src/disco_reaper/exporter.py index 7bcda77..a3a3232 100644 --- a/src/disco_reaper/exporter.py +++ b/src/disco_reaper/exporter.py @@ -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 diff --git a/src/fluxer/migrate_message.py b/src/fluxer/migrate_message.py index 82f9c91..6d5672b 100644 --- a/src/fluxer/migrate_message.py +++ b/src/fluxer/migrate_message.py @@ -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 diff --git a/src/stoat/migrate_message.py b/src/stoat/migrate_message.py index 82a064a..53ff12d 100644 --- a/src/stoat/migrate_message.py +++ b/src/stoat/migrate_message.py @@ -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): diff --git a/src/ui/backup_ops.py b/src/ui/backup_ops.py index fae7131..59a2d06 100644 --- a/src/ui/backup_ops.py +++ b/src/ui/backup_ops.py @@ -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.")) diff --git a/src/ui/modals.py b/src/ui/modals.py index 2344513..3f57234 100644 --- a/src/ui/modals.py +++ b/src/ui/modals.py @@ -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: diff --git a/src/ui/shuttle_app.py b/src/ui/shuttle_app.py index 9028cbd..e49b8a0 100644 --- a/src/ui/shuttle_app.py +++ b/src/ui/shuttle_app.py @@ -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: diff --git a/src/ui/shuttle_ops.py b/src/ui/shuttle_ops.py index 1722a0e..8e3f2c1 100644 --- a/src/ui/shuttle_ops.py +++ b/src/ui/shuttle_ops.py @@ -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()