From 03e0778ef3dfb27e4d34034eba31337051f7d84e Mon Sep 17 00:00:00 2001 From: rambros Date: Thu, 12 Mar 2026 22:24:17 +0530 Subject: [PATCH] improve UX to bypass waiting time in migration UI --- src/core/backup_reader.py | 6 +- src/core/discord_reader.py | 9 +- src/fluxer/migrate_message.py | 7 +- src/stoat/migrate_message.py | 7 +- src/ui/main_app.py | 150 +++++++++++----------- src/ui/modals.py | 72 +++++++---- src/ui/shuttle_ops.py | 228 +++++++++++++++++++++++----------- 7 files changed, 293 insertions(+), 186 deletions(-) diff --git a/src/core/backup_reader.py b/src/core/backup_reader.py index e3af158..b85c5a2 100644 --- a/src/core/backup_reader.py +++ b/src/core/backup_reader.py @@ -940,14 +940,14 @@ class BackupReader: ) async def get_message(self, channel_id: int, message_id: int) -> BackupMessage | None: - messages = self._load_channel_messages(channel_id) + messages = self._load_channel_messages_data(channel_id) for m in messages: if int(m["messageID"]) == message_id: return self._hydrate_message(m, channel_id) return None async def get_first_message(self, channel_id: int) -> BackupMessage | None: - messages = self._load_channel_messages(channel_id) + messages = self._load_channel_messages_data(channel_id) if messages: return self._hydrate_message(messages[0], channel_id) return None @@ -960,7 +960,7 @@ class BackupReader: inclusive: bool = False ) -> AsyncGenerator["BackupMessage", None]: """Yields BackupMessages from the backup, respecting after_id and limit.""" - messages = self._load_channel_messages(channel_id) + messages = self._load_channel_messages_data(channel_id) count = 0 for m in messages: diff --git a/src/core/discord_reader.py b/src/core/discord_reader.py index 22bb6a2..fbdd4ab 100644 --- a/src/core/discord_reader.py +++ b/src/core/discord_reader.py @@ -216,7 +216,14 @@ class DiscordReader: """Returns a specific message.""" channel = await self.get_channel(channel_id) if hasattr(channel, "fetch_message"): - return await channel.fetch_message(message_id) + try: + return await channel.fetch_message(message_id) + except discord.NotFound: + logger.warning(f"Message {message_id} not found in channel {channel_id}.") + return None + except Exception as e: + logger.error(f"Error fetching message {message_id}: {e}") + return None return None async def get_first_message(self, channel_id: int): diff --git a/src/fluxer/migrate_message.py b/src/fluxer/migrate_message.py index 605091a..575a80a 100644 --- a/src/fluxer/migrate_message.py +++ b/src/fluxer/migrate_message.py @@ -94,13 +94,13 @@ 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[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, int]: +async def analyze_migration(context: MigrationContext, source_channel_id: int, after_message_id: int | None = None, inclusive: bool = False, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, int]: """ Scans channel history to count messages, threads, and attachments. """ stats = {"messages": 0, "threads": 0, "attachments": 0} - async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id, inclusive=True): + async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id, inclusive=inclusive): if not context.is_running: break @@ -131,6 +131,7 @@ async def migrate_messages( source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, + inclusive: bool = False, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None, thread_id: str | None = None, parent_target_id: str | None = None, @@ -152,7 +153,7 @@ async def migrate_messages( logger.info(f"Resuming migration from after message ID: {after_message_id}") try: - async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id, inclusive=True): + async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id, inclusive=inclusive): if not context.is_running: logger.warning("Migration interrupted by user (is_running=False)") break diff --git a/src/stoat/migrate_message.py b/src/stoat/migrate_message.py index 8b9cf99..dd95995 100644 --- a/src/stoat/migrate_message.py +++ b/src/stoat/migrate_message.py @@ -94,7 +94,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[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, int]: +async def analyze_migration(context: MigrationContext, source_channel_id: int, after_message_id: int | None = None, inclusive: bool = False, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, int]: """ Scans channel history to count messages, threads, and attachments. """ @@ -106,7 +106,7 @@ async def analyze_migration(context: MigrationContext, source_channel_id: int, a "last_message_url": "" } - async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id, inclusive=True): + async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id, inclusive=inclusive): if not context.is_running: break @@ -137,6 +137,7 @@ async def migrate_messages( source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, + inclusive: bool = False, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None, thread_id: str | None = None, parent_target_id: str | None = None, @@ -158,7 +159,7 @@ async def migrate_messages( logger.info(f"Resuming migration from after message ID: {after_message_id}") try: - async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id, inclusive=True): + async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id, inclusive=inclusive): if not context.is_running: logger.warning("Migration interrupted by user (is_running=False)") break diff --git a/src/ui/main_app.py b/src/ui/main_app.py index 369766e..953cc16 100644 --- a/src/ui/main_app.py +++ b/src/ui/main_app.py @@ -16,7 +16,7 @@ from textual.screen import Screen, ModalScreen from src.core.configuration import ( get_available_configs, create_new_config, load_config, save_config, ) -from src.ui.widgets import RamDisplay +from src.ui.widgets import RamDisplay, Footnote # ────────────────────────────────────────────────────────────────────────────── @@ -101,6 +101,7 @@ class ConfigSelectionScreen(Screen): yield Button("New Config", id="btn_new_config", variant="success", tooltip="Create a new configuration folder") yield Button("Exit", id="btn_exit", variant="error") yield Footer() + yield Footnote() yield RamDisplay() def on_mount(self) -> None: @@ -320,92 +321,80 @@ class ConfigScreen(Screen): initial=True )) - async def _do_fetch_guilds(self, token: str, initial: bool = False) -> None: - """Background worker to fetch guilds and update the Select widget.""" - from src.core.discord_reader import DiscordReader + async def _fetch_and_populate( + self, + fetch_coro, + btn_id: str, + select_id: str, + error_msg: str, + saved_id: str | None = None, + initial: bool = False + ) -> None: + """Generic helper to fetch data and update a Select widget.""" try: - guilds = await DiscordReader.fetch_guilds(token) - - if not guilds: - self.query_one("#btn_fetch_guilds", Button).variant = "warning" - self.query_one("#inp_discord_server", Select).prompt = "No servers found" + results = await fetch_coro + if not results: + try: + self.query_one(btn_id, Button).variant = "warning" + self.query_one(select_id, Select).prompt = "No results found" + except Exception: pass if not initial: - self.notify("No Discord servers found or invalid token.", severity="warning") + self.notify(error_msg, severity="warning") return - self.query_one("#btn_fetch_guilds", Button).variant = "success" - options = [(name, gid) for name, gid in guilds] - select_widget = self.query_one("#inp_discord_server", Select) - select_widget.prompt = "Select a server" - select_widget.set_options(options) - - # Restore saved value if it exists in the fetched list - saved_id = self.config.discord_server_id - if saved_id and any(gid == saved_id for _, gid in guilds): - select_widget.value = saved_id + try: + self.query_one(btn_id, Button).variant = "success" + options = [(label, sid) for label, sid in results] + select_widget = self.query_one(select_id, Select) + select_widget.prompt = "Select an item" + select_widget.set_options(options) + + if saved_id and any(sid == saved_id for _, sid in results): + select_widget.value = saved_id + except Exception: pass except Exception as e: - self.query_one("#btn_fetch_guilds", Button).variant = "warning" - self.query_one("#inp_discord_server", Select).prompt = "Invalid token" + try: + self.query_one(btn_id, Button).variant = "warning" + self.query_one(select_id, Select).prompt = "Invalid token" + except Exception: pass if not initial: - self.notify(f"Failed to fetch Discord servers: {e}", severity="error") + self.notify(f"Fetch failed: {e}", severity="error") + + async def _do_fetch_guilds(self, token: str, initial: bool = False) -> None: + from src.core.discord_reader import DiscordReader + await self._fetch_and_populate( + DiscordReader.fetch_guilds(token), + "#btn_fetch_guilds", + "#inp_discord_server", + "No Discord servers found.", + self.config.discord_server_id, + initial + ) async def _do_fetch_target_servers(self, token: str = None, api_url: str = None, platform: str = None, initial: bool = False) -> None: - """Background worker to fetch target platform servers.""" - if not platform: - platform = self._get_selected_platform() - if not token: - token = self.query_one("#inp_target_token", Input).value.strip() - if not api_url: - api_url = self.query_one("#inp_target_api", Input).value.strip() or "default" - - if not token: - return - - servers = [] + if not platform: platform = self._get_selected_platform() try: - if platform == "fluxer": - from src.fluxer.writer import FluxerWriter - servers = await FluxerWriter.fetch_guilds(token, api_url) - elif platform == "stoat": - from src.stoat.writer import StoatWriter - servers = await StoatWriter.fetch_guilds(token, api_url) - else: - return - except Exception as e: - logger.error(f"Failed to fetch {platform} servers: {e}") - try: - self.query_one("#btn_fetch_target_servers", Button).variant = "warning" - self.query_one("#inp_target_server", Select).prompt = "Invalid token" - except Exception as dom_err: - logger.debug(f"Could not update target server UI elements: {dom_err}") + if not token: token = self.query_one("#inp_target_token", Input).value.strip() + if not api_url: api_url = self.query_one("#inp_target_api", Input).value.strip() or "default" + except Exception: return + if not token: return - if not initial: - self.notify(f"Failed to fetch {platform} servers: {e}", severity="error") - return + if platform == "fluxer": + from src.fluxer.writer import FluxerWriter + coro = FluxerWriter.fetch_guilds(token, api_url) + elif platform == "stoat": + from src.stoat.writer import StoatWriter + coro = StoatWriter.fetch_guilds(token, api_url) + else: return - if not servers: - try: - self.query_one("#btn_fetch_target_servers", Button).variant = "warning" - self.query_one("#inp_target_server", Select).prompt = "No servers found" - except Exception: - pass - if not initial: - self.notify(f"No {platform} servers found or invalid token.", severity="warning") - return - - try: - self.query_one("#btn_fetch_target_servers", Button).variant = "success" - options = [(label, sid) for label, sid in servers] - select_widget = self.query_one("#inp_target_server", Select) - select_widget.prompt = "Select a server" - select_widget.set_options(options) - - # Restore saved value - saved_id = self.config.target_server_id - if saved_id and any(sid == saved_id for _, sid in servers): - select_widget.value = saved_id - except Exception as dom_err: - logger.debug(f"Could not update target server DOM: {dom_err}") + await self._fetch_and_populate( + coro, + "#btn_fetch_target_servers", + "#inp_target_server", + f"No {platform} servers found.", + self.config.target_server_id, + initial + ) def on_button_pressed(self, event: Button.Pressed) -> None: @@ -499,6 +488,15 @@ class ReaperApp(App): margin-left: 2; color: green; } + Footnote { + dock: bottom; + width: 100%; + height: 1; + text-align: right; + padding-right: 1; + background: $surface; + color: $text; + } """ def on_mount(self) -> None: diff --git a/src/ui/modals.py b/src/ui/modals.py index 8455ea3..56a9446 100644 --- a/src/ui/modals.py +++ b/src/ui/modals.py @@ -251,17 +251,53 @@ class ProgressScreen(Screen[None]): btn_id_tooltip: str | None = None ): """Phase 2: Wait for user confirmation after analysis.""" + # Hide loading state if it was still running try: self.query_one("#prog_loader", LoadingIndicator).display = False except Exception: pass - try: self.query_one("#prog_timer", Label).display = False + try: self.query_one("#prog_status", Label).styles.color = None # Reset potential colors except Exception: pass + + # Update labels and show buttons + self.show_early_buttons( + show_continue=show_continue, + show_id=show_id, + btn_start_label=btn_start_label, + btn_continue_label=btn_continue_label, + btn_id_label=btn_id_label, + btn_start_variant=btn_start_variant, + btn_start_tooltip=btn_start_tooltip, + btn_continue_tooltip=btn_continue_tooltip, + btn_id_tooltip=btn_id_tooltip + ) + + loop = asyncio.get_running_loop() + if not self.confirm_future or self.confirm_future.done(): + self.confirm_future = loop.create_future() + # Wait until the user clicks one of the buttons + choice = await self.confirm_future + return choice + + def show_early_buttons( + self, + show_continue: bool = False, + show_id: bool = True, + btn_start_label: str = "Start from First", + btn_continue_label: str = "Continue Migration", + btn_id_label: str = "Start from ID", + btn_start_variant: str = "primary", + btn_start_tooltip: str | None = None, + btn_continue_tooltip: str | None = None, + btn_id_tooltip: str | None = None + ): + """Show the operation buttons early (e.g. during analysis) so user can skip wait.""" # Update button labels, variants and tooltips try: btn_start = self.query_one("#btn_start_first", Button) btn_start.label = btn_start_label btn_start.variant = btn_start_variant + btn_start.disabled = False if btn_start_tooltip: btn_start.tooltip = btn_start_tooltip except Exception: pass @@ -269,6 +305,8 @@ class ProgressScreen(Screen[None]): try: btn_cont = self.query_one("#btn_continue", Button) btn_cont.label = btn_continue_label + btn_cont.disabled = not show_continue + btn_cont.display = show_continue if btn_continue_tooltip: btn_cont.tooltip = btn_continue_tooltip except Exception: pass @@ -276,11 +314,13 @@ class ProgressScreen(Screen[None]): try: btn_id = self.query_one("#btn_start_id", Button) btn_id.label = btn_id_label + btn_id.disabled = not show_id + btn_id.display = show_id if btn_id_tooltip: btn_id.tooltip = btn_id_tooltip except Exception: pass - # Show confirmation buttons + # Show confirmation button rows try: self.query_one("#prog_actions_row1", Horizontal).display = True except Exception: pass try: self.query_one("#prog_actions_row2", Horizontal).display = True @@ -288,30 +328,10 @@ class ProgressScreen(Screen[None]): 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: - btn_id = self.query_one("#btn_start_id", Button) - btn_id.disabled = not show_id - btn_id.display = show_id - except Exception: pass - - try: - 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 + # Ensure confirm_future is ready for clicks + if not self.confirm_future or self.confirm_future.done(): + loop = asyncio.get_running_loop() + self.confirm_future = loop.create_future() def phase_progress(self): """Phase 3: The actual operation begins. Only Cancel visible.""" diff --git a/src/ui/shuttle_ops.py b/src/ui/shuttle_ops.py index 363e0a8..febcdc8 100644 --- a/src/ui/shuttle_ops.py +++ b/src/ui/shuttle_ops.py @@ -171,7 +171,13 @@ class OperationPane(Container): return f"ReaperFiles-{self.cfg_name}" def _rebuild_engine(self): - source = "backup" if self.config.tool_mode == "backup_transfer" else "live" + # In backup_transfer mode, the Backup tab reads from LIVE discord, + # while the Shuttle tab reads from the LOCAL BACKUP. + if self.view_mode == "backup": + source = "live" + else: + source = "backup" if self.config.tool_mode == "backup_transfer" else "live" + self.engine = MigrationContext(self.config, self.target_platform, source_mode=source, base_dir=self._base_dir()) if self.view_mode == "backup": self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=self._base_dir()) @@ -214,9 +220,16 @@ class OperationPane(Container): elif v.get("discord_token") and v.get("discord_server"): s_disp = f'[green]"{d_name}"[/green]' b_disp = f'[green]{d_bot}[/green]' + elif v.get("discord_token") and not v.get("discord_server"): + s_disp, b_disp = "[red]SERVER NOT SELECTED[/red]", f"[green]{d_bot}[/green]" elif v.get("discord_token") is False: if self.config.tool_mode == "backup_transfer" and self.view_mode == "shuttle": - s_disp, b_disp = "[red]NOT FOUND[/red]", "[red]NOT FOUND[/red]" + # Check if it's missing because no server was selected + fillers = ["DISCORD_SERVER_ID", "000000000000000000", "", None] + if self.config.discord_server_id in fillers: + s_disp, b_disp = "[red]SERVER NOT SELECTED[/red]", "[red]SERVER NOT SELECTED[/red]" + else: + s_disp, b_disp = "[red]NOT FOUND[/red]", "[red]NOT FOUND[/red]" else: s_disp, b_disp = "[red]INVALID TOKEN[/red]", "[red]INVALID TOKEN[/red]" else: @@ -243,6 +256,8 @@ class OperationPane(Container): if v.get("discord_token") and v.get("discord_server") and not d_missing: d_status = "[green]VALID[/green]" + elif v.get("discord_token") and not v.get("discord_server"): + d_status = "[red]SERVER NOT SET[/red]" elif v.get("discord_timeout"): d_status = "[red]TIMEOUT[/red]" elif d_err: @@ -345,19 +360,23 @@ class OperationPane(Container): ] # Check dummies - d_dummy = False - if self.view_mode == "backup" or self.config.tool_mode != "backup_transfer": - d_dummy = self.config.discord_bot_token in fillers or self.config.discord_server_id in fillers - else: - # In shuttle mode of backup_transfer, we look at the source backup dir - d_dummy = self.config.discord_server_id in fillers - - t_dummy = (self.config.target_bot_token or "") in fillers or (self.config.target_server_id or "") in fillers + d_token_dummy = self.config.discord_bot_token in fillers + d_server_dummy = self.config.discord_server_id in fillers + + t_token_dummy = (self.config.target_bot_token or "") in fillers + t_server_dummy = (self.config.target_server_id or "") in fillers tasks = {} - if not d_dummy: - tasks["discord"] = asyncio.create_task(self.engine.discord_reader.validate()) - if not t_dummy and self.view_mode == "shuttle": + # We always validate Discord token if it's not a dummy, even if server ID is missing + if self.config.tool_mode == "backup_transfer" and self.view_mode == "shuttle": + # Backup validation: only if we have a server ID to search for + if not d_server_dummy: + tasks["discord"] = asyncio.create_task(self.engine.discord_reader.validate()) + else: + if not d_token_dummy: + tasks["discord"] = asyncio.create_task(self.engine.discord_reader.validate()) + + if self.view_mode == "shuttle" and not t_token_dummy: tasks["target"] = asyncio.create_task(self.engine.writer.validate()) all_tasks = list(tasks.values()) @@ -1003,61 +1022,8 @@ class OperationPane(Container): modal.set_status("Analyzing channel...") modal.show_stats() - - self.engine.is_running = True - stats_analysis = {"messages": 0, "threads": 0, "attachments": 0} - - async def update_scan(current_stats): - modal.set_status(f"[cyan]Scanned {current_stats['messages']} items...") - - logger.info(f"Analyzing message history for Discord #{source_channel.name}...") - 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, - ) - logger.info(f"Analysis complete: {stats_analysis['messages']} new messages found.") - 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'] - ) - - # 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) - - # Fetch and display message previews - try: - first_msg = await self.engine.discord_reader.get_first_message(source_channel.id) - if first_msg: - content = first_msg.content or (f"[dim]({len(first_msg.attachments)} attachments)[/dim]" if first_msg.attachments else "[dim](no content)[/dim]") - modal.write("[bold cyan]Start from first message:[/bold cyan]") - modal.write(f"[bold]{first_msg.author.display_name}:[/bold] {content[:200]}") - modal.write("") - - if has_previous and last_migrated: - try: - prev_msg = await self.engine.discord_reader.get_message(source_channel.id, int(last_migrated)) - if prev_msg: - content = prev_msg.content or (f"[dim]({len(prev_msg.attachments)} attachments)[/dim]" if prev_msg.attachments else "[dim](no content)[/dim]") - modal.write("[bold yellow]Continue from previous migration:[/bold yellow]") - modal.write(f"[bold]{prev_msg.author.display_name}:[/bold] {content[:200]}") - modal.write("") - except Exception as e: - logger.warning(f"Could not fetch previous message {last_migrated}: {e}") - except Exception as e: - logger.warning(f"Error fetching message previews: {e}") - - 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 buttons early so user can skip analysis + modal.show_early_buttons( show_continue=has_previous, show_id=True, btn_start_label="Start from\nFirst Message", @@ -1067,7 +1033,104 @@ class OperationPane(Container): btn_continue_tooltip="Resume from the last successfully migrated message", btn_id_tooltip="Start migrating from a specific Discord message ID" ) + + self.engine.is_running = True + stats_analysis = {"messages": 0, "threads": 0, "attachments": 0} + + async def update_scan(current_stats): + modal.set_status(f"[cyan]Scanned {current_stats['messages']} items...") + + logger.info(f"Analyzing message history for Discord #{source_channel.name}...") + + # Run analysis and wait for confirmation concurrently + analysis_task = asyncio.create_task(migrate_mod.analyze_migration( + self.engine, + source_channel_id=source_channel.id, + after_message_id=int(last_migrated) if last_migrated else None, + progress_callback=update_scan, + )) + + # Fetch and display message previews as background tasks + async def fetch_previews(): + try: + first_msg_task = asyncio.create_task(self.engine.discord_reader.get_first_message(source_channel.id)) + prev_msg_task = None + if has_previous and last_migrated: + prev_msg_task = asyncio.create_task(self.engine.discord_reader.get_message(source_channel.id, int(last_migrated))) + + first_msg = await first_msg_task + if first_msg: + content = first_msg.content or (f"[dim]({len(first_msg.attachments)} attachments)[/dim]" if first_msg.attachments else "[dim](no content)[/dim]") + modal.write("[bold cyan]Start from first message:[/bold cyan]") + modal.write(f"[bold]{first_msg.author.display_name}:[/bold] {content[:200]}\n") + + if prev_msg_task: + try: + prev_msg = await prev_msg_task + if prev_msg: + content = prev_msg.content or (f"[dim]({len(prev_msg.attachments)} attachments)[/dim]" if prev_msg.attachments else "[dim](no content)[/dim]") + modal.write("[bold yellow]Continue from previous migration:[/bold yellow]") + modal.write(f"[bold]{prev_msg.author.display_name}:[/bold] {content[:200]}\n") + except Exception as e: + logger.warning(f"Could not fetch previous message {last_migrated}: {e}") + except Exception as e: + logger.warning(f"Error fetching message previews: {e}") + + preview_task = asyncio.create_task(fetch_previews()) + + # Cleanup function for confirmation phase + def cleanup_preview(): + if not preview_task.done(): + preview_task.cancel() + + # Create a task for waiting for confirmation + confirm_task = asyncio.create_task(modal.phase_wait_confirm( + show_continue=has_previous, + show_id=True, + btn_start_label="Start from\nFirst Message", + btn_continue_label="Continue\nMigration", + btn_id_label="Start from\nmessage ID", + btn_start_tooltip="Start migrating from the earliest available message", + btn_continue_tooltip="Resume from the last successfully migrated message", + btn_id_tooltip="Start migrating from a specific Discord message ID" + )) + + # Wait for either analysis to finish OR user to make a choice + done, pending = await asyncio.wait( + [analysis_task, confirm_task], + return_when=asyncio.FIRST_COMPLETED + ) + + if confirm_task in done: + # User clicked a button early + choice = confirm_task.result() + if not analysis_task.done(): + analysis_task.cancel() + logger.info("Analysis cancelled by early user choice.") + else: + # Analysis finished first + stats_analysis = analysis_task.result() + logger.info(f"Analysis complete: {stats_analysis['messages']} new messages found.") + + # Update stats in UI + modal.update_stats( + messages=stats_analysis['messages'], + threads=stats_analysis['threads'], + files=stats_analysis['attachments'] + ) + m_status = "[bold yellow]Previous Migration Detected[/bold yellow]" if has_previous else "[bold cyan]No previous migration data.[/bold cyan]" + i_status = f"[bold]{stats_analysis['messages']}[/bold] New Messages, [bold]{stats_analysis['threads']}[/bold] Threads." + modal.show_info(m_status, i_status) + + modal.set_status(f"Awaiting Confirmation to migrate Discord [cyan]#{source_channel.name}[/cyan] → {platform_name} [green]#{target_channel.get('name')}[/green]") + + # Now wait for the choice if not already made + choice = await confirm_task + + self.engine.is_running = False + cleanup_preview() logger.info(f"User confirmation choice: {choice}") + if choice == "btn_back": modal.dismiss() continue # Return to channel picker @@ -1104,17 +1167,29 @@ class OperationPane(Container): logger.info("Proceeding with 'Start from First' (clean sink).") after_id = None + is_inclusive = (choice == "btn_start_id") + # If after_id changed from the initial analysis, we must re-analyze # to get the correct total count for the UI fraction (e.g. Messages: 8/8 instead of 8/1) initial_after = int(last_migrated) if last_migrated else None + + # User selected a different start point, transition UI immediately if after_id != initial_after: - modal.set_status("Re-analyzing channel from new starting point...") + modal.phase_progress() # Hide buttons immediately + if choice == "btn_start_first": + modal.set_status("Starting from first message...") + elif choice == "btn_start_id": + modal.set_status(f"Starting from ID [cyan]{after_id}[/cyan]...") + else: + modal.set_status("Re-analyzing channel from new starting point...") + try: self.engine.is_running = True stats_analysis = await migrate_mod.analyze_migration( self.engine, source_channel_id=source_channel.id, after_message_id=after_id, + inclusive=is_inclusive, progress_callback=update_scan, ) modal.update_stats( @@ -1163,13 +1238,17 @@ class OperationPane(Container): c_threads = current_stats["threads"] c_files = current_stats["attachments"] - modal.set_item_status(f"[cyan]Migrated {c_msgs}/{total_messages} messages...") - modal.set_progress(c_msgs, total_messages) + msg_stat = f"{c_msgs}/{total_messages}" if total_messages > 0 else str(c_msgs) + thr_stat = f"{c_threads}/{total_threads}" if total_threads > 0 else str(c_threads) + fil_stat = f"{c_files}/{total_attachments}" if total_attachments > 0 else str(c_files) + + modal.set_item_status(f"[cyan]Migrated {msg_stat} messages...") + modal.set_progress(c_msgs, total_messages or 100) # Fallback total for bar animation modal.update_stats( - messages=f"{c_msgs}/{total_messages}", - threads=f"{c_threads}/{total_threads}", - files=f"{c_files}/{total_attachments}" + messages=msg_stat, + threads=thr_stat, + files=fil_stat ) # optionally show a scrolling trace if the backend provided it @@ -1187,6 +1266,7 @@ class OperationPane(Container): source_channel_id=source_channel.id, target_channel_id=target_channel.get("id"), after_message_id=after_id, + inclusive=is_inclusive, progress_callback=update_msg, )