improve UX to bypass waiting time in migration UI
This commit is contained in:
parent
057d5a94fc
commit
03e0778ef3
7 changed files with 293 additions and 186 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue