improve UX to bypass waiting time in migration UI

This commit is contained in:
rambros 2026-03-12 22:24:17 +05:30
parent 057d5a94fc
commit 03e0778ef3
7 changed files with 293 additions and 186 deletions

View file

@ -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:

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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)
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)
# 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
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:

View file

@ -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."""

View file

@ -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
d_token_dummy = self.config.discord_bot_token in fillers
d_server_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
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,
)