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:
|
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:
|
for m in messages:
|
||||||
if int(m["messageID"]) == message_id:
|
if int(m["messageID"]) == message_id:
|
||||||
return self._hydrate_message(m, channel_id)
|
return self._hydrate_message(m, channel_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_first_message(self, channel_id: int) -> BackupMessage | 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:
|
if messages:
|
||||||
return self._hydrate_message(messages[0], channel_id)
|
return self._hydrate_message(messages[0], channel_id)
|
||||||
return None
|
return None
|
||||||
|
|
@ -960,7 +960,7 @@ class BackupReader:
|
||||||
inclusive: bool = False
|
inclusive: bool = False
|
||||||
) -> AsyncGenerator["BackupMessage", None]:
|
) -> AsyncGenerator["BackupMessage", None]:
|
||||||
"""Yields BackupMessages from the backup, respecting after_id and limit."""
|
"""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
|
count = 0
|
||||||
|
|
||||||
for m in messages:
|
for m in messages:
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,14 @@ class DiscordReader:
|
||||||
"""Returns a specific message."""
|
"""Returns a specific message."""
|
||||||
channel = await self.get_channel(channel_id)
|
channel = await self.get_channel(channel_id)
|
||||||
if hasattr(channel, "fetch_message"):
|
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
|
return None
|
||||||
|
|
||||||
async def get_first_message(self, channel_id: int):
|
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
|
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.
|
Scans channel history to count messages, threads, and attachments.
|
||||||
"""
|
"""
|
||||||
stats = {"messages": 0, "threads": 0, "attachments": 0}
|
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:
|
if not context.is_running:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -131,6 +131,7 @@ async def migrate_messages(
|
||||||
source_channel_id: int,
|
source_channel_id: int,
|
||||||
target_channel_id: str,
|
target_channel_id: str,
|
||||||
after_message_id: int | None = None,
|
after_message_id: int | None = None,
|
||||||
|
inclusive: bool = False,
|
||||||
progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None,
|
progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
thread_id: str | None = None,
|
thread_id: str | None = None,
|
||||||
parent_target_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}")
|
logger.info(f"Resuming migration from after message ID: {after_message_id}")
|
||||||
|
|
||||||
try:
|
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:
|
if not context.is_running:
|
||||||
logger.warning("Migration interrupted by user (is_running=False)")
|
logger.warning("Migration interrupted by user (is_running=False)")
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ def clean_mentions(content: str, guild, user_mentions=None, role_mentions=None,
|
||||||
return content
|
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.
|
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": ""
|
"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:
|
if not context.is_running:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -137,6 +137,7 @@ async def migrate_messages(
|
||||||
source_channel_id: int,
|
source_channel_id: int,
|
||||||
target_channel_id: str,
|
target_channel_id: str,
|
||||||
after_message_id: int | None = None,
|
after_message_id: int | None = None,
|
||||||
|
inclusive: bool = False,
|
||||||
progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None,
|
progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
thread_id: str | None = None,
|
thread_id: str | None = None,
|
||||||
parent_target_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}")
|
logger.info(f"Resuming migration from after message ID: {after_message_id}")
|
||||||
|
|
||||||
try:
|
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:
|
if not context.is_running:
|
||||||
logger.warning("Migration interrupted by user (is_running=False)")
|
logger.warning("Migration interrupted by user (is_running=False)")
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from textual.screen import Screen, ModalScreen
|
||||||
from src.core.configuration import (
|
from src.core.configuration import (
|
||||||
get_available_configs, create_new_config, load_config, save_config,
|
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("New Config", id="btn_new_config", variant="success", tooltip="Create a new configuration folder")
|
||||||
yield Button("Exit", id="btn_exit", variant="error")
|
yield Button("Exit", id="btn_exit", variant="error")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
yield Footnote()
|
||||||
yield RamDisplay()
|
yield RamDisplay()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
|
|
@ -320,92 +321,80 @@ class ConfigScreen(Screen):
|
||||||
initial=True
|
initial=True
|
||||||
))
|
))
|
||||||
|
|
||||||
async def _do_fetch_guilds(self, token: str, initial: bool = False) -> None:
|
async def _fetch_and_populate(
|
||||||
"""Background worker to fetch guilds and update the Select widget."""
|
self,
|
||||||
from src.core.discord_reader import DiscordReader
|
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:
|
try:
|
||||||
guilds = await DiscordReader.fetch_guilds(token)
|
results = await fetch_coro
|
||||||
|
if not results:
|
||||||
if not guilds:
|
try:
|
||||||
self.query_one("#btn_fetch_guilds", Button).variant = "warning"
|
self.query_one(btn_id, Button).variant = "warning"
|
||||||
self.query_one("#inp_discord_server", Select).prompt = "No servers found"
|
self.query_one(select_id, Select).prompt = "No results found"
|
||||||
|
except Exception: pass
|
||||||
if not initial:
|
if not initial:
|
||||||
self.notify("No Discord servers found or invalid token.", severity="warning")
|
self.notify(error_msg, severity="warning")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.query_one("#btn_fetch_guilds", Button).variant = "success"
|
try:
|
||||||
options = [(name, gid) for name, gid in guilds]
|
self.query_one(btn_id, Button).variant = "success"
|
||||||
select_widget = self.query_one("#inp_discord_server", Select)
|
options = [(label, sid) for label, sid in results]
|
||||||
select_widget.prompt = "Select a server"
|
select_widget = self.query_one(select_id, Select)
|
||||||
select_widget.set_options(options)
|
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(sid == saved_id for _, sid in results):
|
||||||
if saved_id and any(gid == saved_id for _, gid in guilds):
|
select_widget.value = saved_id
|
||||||
select_widget.value = saved_id
|
except Exception: pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.query_one("#btn_fetch_guilds", Button).variant = "warning"
|
try:
|
||||||
self.query_one("#inp_discord_server", Select).prompt = "Invalid token"
|
self.query_one(btn_id, Button).variant = "warning"
|
||||||
|
self.query_one(select_id, Select).prompt = "Invalid token"
|
||||||
|
except Exception: pass
|
||||||
if not initial:
|
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:
|
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 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 = []
|
|
||||||
try:
|
try:
|
||||||
if platform == "fluxer":
|
if not token: token = self.query_one("#inp_target_token", Input).value.strip()
|
||||||
from src.fluxer.writer import FluxerWriter
|
if not api_url: api_url = self.query_one("#inp_target_api", Input).value.strip() or "default"
|
||||||
servers = await FluxerWriter.fetch_guilds(token, api_url)
|
except Exception: return
|
||||||
elif platform == "stoat":
|
if not token: return
|
||||||
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 initial:
|
if platform == "fluxer":
|
||||||
self.notify(f"Failed to fetch {platform} servers: {e}", severity="error")
|
from src.fluxer.writer import FluxerWriter
|
||||||
return
|
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:
|
await self._fetch_and_populate(
|
||||||
try:
|
coro,
|
||||||
self.query_one("#btn_fetch_target_servers", Button).variant = "warning"
|
"#btn_fetch_target_servers",
|
||||||
self.query_one("#inp_target_server", Select).prompt = "No servers found"
|
"#inp_target_server",
|
||||||
except Exception:
|
f"No {platform} servers found.",
|
||||||
pass
|
self.config.target_server_id,
|
||||||
if not initial:
|
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}")
|
|
||||||
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
|
@ -499,6 +488,15 @@ class ReaperApp(App):
|
||||||
margin-left: 2;
|
margin-left: 2;
|
||||||
color: green;
|
color: green;
|
||||||
}
|
}
|
||||||
|
Footnote {
|
||||||
|
dock: bottom;
|
||||||
|
width: 100%;
|
||||||
|
height: 1;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 1;
|
||||||
|
background: $surface;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -251,17 +251,53 @@ class ProgressScreen(Screen[None]):
|
||||||
btn_id_tooltip: str | None = None
|
btn_id_tooltip: str | None = None
|
||||||
):
|
):
|
||||||
"""Phase 2: Wait for user confirmation after analysis."""
|
"""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
|
try: self.query_one("#prog_loader", LoadingIndicator).display = False
|
||||||
except Exception: pass
|
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
|
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
|
# Update button labels, variants and tooltips
|
||||||
try:
|
try:
|
||||||
btn_start = self.query_one("#btn_start_first", Button)
|
btn_start = self.query_one("#btn_start_first", Button)
|
||||||
btn_start.label = btn_start_label
|
btn_start.label = btn_start_label
|
||||||
btn_start.variant = btn_start_variant
|
btn_start.variant = btn_start_variant
|
||||||
|
btn_start.disabled = False
|
||||||
if btn_start_tooltip:
|
if btn_start_tooltip:
|
||||||
btn_start.tooltip = btn_start_tooltip
|
btn_start.tooltip = btn_start_tooltip
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
|
|
@ -269,6 +305,8 @@ class ProgressScreen(Screen[None]):
|
||||||
try:
|
try:
|
||||||
btn_cont = self.query_one("#btn_continue", Button)
|
btn_cont = self.query_one("#btn_continue", Button)
|
||||||
btn_cont.label = btn_continue_label
|
btn_cont.label = btn_continue_label
|
||||||
|
btn_cont.disabled = not show_continue
|
||||||
|
btn_cont.display = show_continue
|
||||||
if btn_continue_tooltip:
|
if btn_continue_tooltip:
|
||||||
btn_cont.tooltip = btn_continue_tooltip
|
btn_cont.tooltip = btn_continue_tooltip
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
|
|
@ -276,11 +314,13 @@ class ProgressScreen(Screen[None]):
|
||||||
try:
|
try:
|
||||||
btn_id = self.query_one("#btn_start_id", Button)
|
btn_id = self.query_one("#btn_start_id", Button)
|
||||||
btn_id.label = btn_id_label
|
btn_id.label = btn_id_label
|
||||||
|
btn_id.disabled = not show_id
|
||||||
|
btn_id.display = show_id
|
||||||
if btn_id_tooltip:
|
if btn_id_tooltip:
|
||||||
btn_id.tooltip = btn_id_tooltip
|
btn_id.tooltip = btn_id_tooltip
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
|
|
||||||
# Show confirmation buttons
|
# Show confirmation button rows
|
||||||
try: self.query_one("#prog_actions_row1", Horizontal).display = True
|
try: self.query_one("#prog_actions_row1", Horizontal).display = True
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
try: self.query_one("#prog_actions_row2", Horizontal).display = True
|
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
|
try: self.query_one("#prog_actions_cancel", Horizontal).display = False
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
|
|
||||||
try: self.query_one("#btn_start_first", Button).disabled = False
|
# Ensure confirm_future is ready for clicks
|
||||||
except Exception: pass
|
if not self.confirm_future or self.confirm_future.done():
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
try:
|
self.confirm_future = loop.create_future()
|
||||||
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
|
|
||||||
|
|
||||||
def phase_progress(self):
|
def phase_progress(self):
|
||||||
"""Phase 3: The actual operation begins. Only Cancel visible."""
|
"""Phase 3: The actual operation begins. Only Cancel visible."""
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,13 @@ class OperationPane(Container):
|
||||||
return f"ReaperFiles-{self.cfg_name}"
|
return f"ReaperFiles-{self.cfg_name}"
|
||||||
|
|
||||||
def _rebuild_engine(self):
|
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())
|
self.engine = MigrationContext(self.config, self.target_platform, source_mode=source, base_dir=self._base_dir())
|
||||||
if self.view_mode == "backup":
|
if self.view_mode == "backup":
|
||||||
self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=self._base_dir())
|
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"):
|
elif v.get("discord_token") and v.get("discord_server"):
|
||||||
s_disp = f'[green]"{d_name}"[/green]'
|
s_disp = f'[green]"{d_name}"[/green]'
|
||||||
b_disp = f'[green]{d_bot}[/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:
|
elif v.get("discord_token") is False:
|
||||||
if self.config.tool_mode == "backup_transfer" and self.view_mode == "shuttle":
|
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:
|
else:
|
||||||
s_disp, b_disp = "[red]INVALID TOKEN[/red]", "[red]INVALID TOKEN[/red]"
|
s_disp, b_disp = "[red]INVALID TOKEN[/red]", "[red]INVALID TOKEN[/red]"
|
||||||
else:
|
else:
|
||||||
|
|
@ -243,6 +256,8 @@ class OperationPane(Container):
|
||||||
|
|
||||||
if v.get("discord_token") and v.get("discord_server") and not d_missing:
|
if v.get("discord_token") and v.get("discord_server") and not d_missing:
|
||||||
d_status = "[green]VALID[/green]"
|
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"):
|
elif v.get("discord_timeout"):
|
||||||
d_status = "[red]TIMEOUT[/red]"
|
d_status = "[red]TIMEOUT[/red]"
|
||||||
elif d_err:
|
elif d_err:
|
||||||
|
|
@ -345,19 +360,23 @@ class OperationPane(Container):
|
||||||
]
|
]
|
||||||
|
|
||||||
# Check dummies
|
# Check dummies
|
||||||
d_dummy = False
|
d_token_dummy = self.config.discord_bot_token in fillers
|
||||||
if self.view_mode == "backup" or self.config.tool_mode != "backup_transfer":
|
d_server_dummy = self.config.discord_server_id in fillers
|
||||||
d_dummy = self.config.discord_bot_token in fillers or self.config.discord_server_id in fillers
|
|
||||||
else:
|
t_token_dummy = (self.config.target_bot_token or "") in fillers
|
||||||
# In shuttle mode of backup_transfer, we look at the source backup dir
|
t_server_dummy = (self.config.target_server_id or "") in fillers
|
||||||
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
|
|
||||||
|
|
||||||
tasks = {}
|
tasks = {}
|
||||||
if not d_dummy:
|
# We always validate Discord token if it's not a dummy, even if server ID is missing
|
||||||
tasks["discord"] = asyncio.create_task(self.engine.discord_reader.validate())
|
if self.config.tool_mode == "backup_transfer" and self.view_mode == "shuttle":
|
||||||
if not t_dummy 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())
|
tasks["target"] = asyncio.create_task(self.engine.writer.validate())
|
||||||
|
|
||||||
all_tasks = list(tasks.values())
|
all_tasks = list(tasks.values())
|
||||||
|
|
@ -1003,61 +1022,8 @@ class OperationPane(Container):
|
||||||
|
|
||||||
modal.set_status("Analyzing channel...")
|
modal.set_status("Analyzing channel...")
|
||||||
modal.show_stats()
|
modal.show_stats()
|
||||||
|
# Show buttons early so user can skip analysis
|
||||||
self.engine.is_running = True
|
modal.show_early_buttons(
|
||||||
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_continue=has_previous,
|
show_continue=has_previous,
|
||||||
show_id=True,
|
show_id=True,
|
||||||
btn_start_label="Start from\nFirst Message",
|
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_continue_tooltip="Resume from the last successfully migrated message",
|
||||||
btn_id_tooltip="Start migrating from a specific Discord message ID"
|
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}")
|
logger.info(f"User confirmation choice: {choice}")
|
||||||
|
|
||||||
if choice == "btn_back":
|
if choice == "btn_back":
|
||||||
modal.dismiss()
|
modal.dismiss()
|
||||||
continue # Return to channel picker
|
continue # Return to channel picker
|
||||||
|
|
@ -1104,17 +1167,29 @@ class OperationPane(Container):
|
||||||
logger.info("Proceeding with 'Start from First' (clean sink).")
|
logger.info("Proceeding with 'Start from First' (clean sink).")
|
||||||
after_id = None
|
after_id = None
|
||||||
|
|
||||||
|
is_inclusive = (choice == "btn_start_id")
|
||||||
|
|
||||||
# If after_id changed from the initial analysis, we must re-analyze
|
# 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)
|
# 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
|
initial_after = int(last_migrated) if last_migrated else None
|
||||||
|
|
||||||
|
# User selected a different start point, transition UI immediately
|
||||||
if after_id != initial_after:
|
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:
|
try:
|
||||||
self.engine.is_running = True
|
self.engine.is_running = True
|
||||||
stats_analysis = await migrate_mod.analyze_migration(
|
stats_analysis = await migrate_mod.analyze_migration(
|
||||||
self.engine,
|
self.engine,
|
||||||
source_channel_id=source_channel.id,
|
source_channel_id=source_channel.id,
|
||||||
after_message_id=after_id,
|
after_message_id=after_id,
|
||||||
|
inclusive=is_inclusive,
|
||||||
progress_callback=update_scan,
|
progress_callback=update_scan,
|
||||||
)
|
)
|
||||||
modal.update_stats(
|
modal.update_stats(
|
||||||
|
|
@ -1163,13 +1238,17 @@ class OperationPane(Container):
|
||||||
c_threads = current_stats["threads"]
|
c_threads = current_stats["threads"]
|
||||||
c_files = current_stats["attachments"]
|
c_files = current_stats["attachments"]
|
||||||
|
|
||||||
modal.set_item_status(f"[cyan]Migrated {c_msgs}/{total_messages} messages...")
|
msg_stat = f"{c_msgs}/{total_messages}" if total_messages > 0 else str(c_msgs)
|
||||||
modal.set_progress(c_msgs, total_messages)
|
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(
|
modal.update_stats(
|
||||||
messages=f"{c_msgs}/{total_messages}",
|
messages=msg_stat,
|
||||||
threads=f"{c_threads}/{total_threads}",
|
threads=thr_stat,
|
||||||
files=f"{c_files}/{total_attachments}"
|
files=fil_stat
|
||||||
)
|
)
|
||||||
|
|
||||||
# optionally show a scrolling trace if the backend provided it
|
# optionally show a scrolling trace if the backend provided it
|
||||||
|
|
@ -1187,6 +1266,7 @@ class OperationPane(Container):
|
||||||
source_channel_id=source_channel.id,
|
source_channel_id=source_channel.id,
|
||||||
target_channel_id=target_channel.get("id"),
|
target_channel_id=target_channel.get("id"),
|
||||||
after_message_id=after_id,
|
after_message_id=after_id,
|
||||||
|
inclusive=is_inclusive,
|
||||||
progress_callback=update_msg,
|
progress_callback=update_msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue