improve proress tracking

This commit is contained in:
rambros 2026-03-03 03:47:59 +05:30
parent 1cf5463da6
commit 6c51fcd9ae
4 changed files with 384 additions and 196 deletions

View file

@ -18,7 +18,7 @@ from textual import work
from src.core.configuration import load_config
from src.core.base import MigrationContext
from src.disco_reaper.exporter import DiscordExporter
from src.ui.modals import ProgressModal, ChannelSelectModal
from src.ui.modals import ProgressScreen, ChannelSelectScreen, ReportModal
class BackupPane(Container):
@ -123,7 +123,7 @@ class BackupPane(Container):
@work(exclusive=True, thread=True)
async def run_backup_profile(self) -> None:
modal = ProgressModal()
modal = ProgressScreen()
self.app.call_from_thread(self.app.push_screen, modal)
await asyncio.sleep(0.1)
@ -152,12 +152,12 @@ class BackupPane(Container):
self.app.call_from_thread(modal.write, f"[bold red]Error: {e}[/bold red]")
finally:
await self.engine.close_connections()
self.app.call_from_thread(modal.set_status, "Finished.")
self.app.call_from_thread(modal.allow_close)
self.app.call_from_thread(self.app.push_screen, ReportModal("Backup Complete", "Server profile and structure successfully exported."))
@work(exclusive=True, thread=True)
async def run_backup_messages(self) -> None:
modal_prog = ProgressModal()
modal_prog = ProgressScreen()
self.app.call_from_thread(self.app.push_screen, modal_prog)
await asyncio.sleep(0.1)
@ -199,7 +199,7 @@ class BackupPane(Container):
self.app.call_from_thread(
self.app.push_screen,
ChannelSelectModal(eligible_channels, cat_map, backed_up_ids, any_found),
ChannelSelectScreen(eligible_channels, cat_map, backed_up_ids, any_found),
check_channels,
)
@ -241,12 +241,12 @@ class BackupPane(Container):
self.app.call_from_thread(modal_prog.write, f"[bold red]Message backup failed: {e}[/bold red]")
finally:
await self.engine.close_connections()
self.app.call_from_thread(modal_prog.set_status, "Finished.")
self.app.call_from_thread(modal_prog.allow_close)
self.app.call_from_thread(self.app.push_screen, ReportModal("Backup Complete", "Channel messages and threads successfully backed up."))
@work(exclusive=True, thread=True)
async def run_backup_sync(self) -> None:
modal_prog = ProgressModal()
modal_prog = ProgressScreen()
self.app.call_from_thread(self.app.push_screen, modal_prog)
await asyncio.sleep(0.1)
@ -293,5 +293,5 @@ class BackupPane(Container):
self.app.call_from_thread(modal_prog.write, f"[bold red]Sync failed: {e}[/bold red]")
finally:
await self.engine.close_connections()
self.app.call_from_thread(modal_prog.set_status, "Finished.")
self.app.call_from_thread(modal_prog.allow_close)
self.app.call_from_thread(self.app.push_screen, ReportModal("Sync Complete", "Backup cleanly synced with latest Discord data."))

View file

@ -3,47 +3,183 @@ Shared modals used by backup and shuttle operations.
"""
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Button, Label, Input, ProgressBar, RichLog, Rule, RadioButton
from textual.screen import ModalScreen
from textual.containers import Horizontal, Vertical, VerticalScroll, Container, Center
from textual.widgets import Button, Label, Input, ProgressBar, RichLog, Rule, RadioButton, LoadingIndicator, Header, Footer, RadioSet
from textual.screen import ModalScreen, Screen
import time
# ---------------------------------------------------------------------------
# ProgressModal unified progress / log display
# ProgressScreen unified full-screen progress / log / stats display
# ---------------------------------------------------------------------------
class ProgressModal(ModalScreen[None]):
"""Modal to display progress for any long-running operation."""
class ProgressScreen(Screen[None]):
"""Screen to display progress for any operation, with stats and logs."""
DEFAULT_CSS = """
ProgressScreen { align: center middle; }
#prog_outer { width: 100%; height: 100%; align: center top; }
#prog_dialog {
width: 80%;
height: 100%;
layout: vertical;
border: solid green;
padding: 1 2;
margin: 2 0;
background: $surface;
}
#prog_header { height: auto; margin-bottom: 1; dock: top; }
#prog_status { text-style: bold; width: 1fr; content-align: left middle; }
#prog_timer { text-style: bold; width: 20; content-align: right middle; color: yellow; }
#prog_stats {
height: auto;
layout: horizontal;
border: solid cyan;
padding: 1;
margin-bottom: 1;
display: none;
}
.stat_label { width: 1fr; content-align: center middle; text-style: bold; }
#prog_log { height: 1fr; margin-bottom: 1; border: solid $primary; }
#prog_loader { margin-bottom: 1; }
#prog_bar_container { height: auto; width: 100%; }
#prog_bar { margin-bottom: 1; width: 80%; }
#prog_actions { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; }
#btn_close_progress { width: 1fr; margin: 0 1; }
"""
def compose(self) -> ComposeResult:
with Vertical(id="progress_dialog"):
yield Label("Operation Status", id="progress_status")
yield ProgressBar(total=None, show_eta=False, id="progress_bar")
yield RichLog(id="progress_log", highlight=True, markup=True)
yield Header(show_clock=True)
with Container(id="prog_outer"):
with Container(id="prog_dialog"):
with Horizontal(id="prog_header"):
yield Label("Operation Status...", id="prog_status")
yield Label("00:00", id="prog_timer")
with Horizontal(id="prog_stats"):
yield Label("Messages: 0", id="stat_messages", classes="stat_label")
yield Label("Threads: 0", id="stat_threads", classes="stat_label")
yield Label("Files: 0", id="stat_files", classes="stat_label")
yield LoadingIndicator(id="prog_loader")
with Center(id="prog_bar_container"):
pb = ProgressBar(total=None, show_eta=False, id="prog_bar")
pb.display = False
yield pb
yield RichLog(id="prog_log", highlight=True, markup=True)
with Horizontal(id="prog_actions"):
yield Button("Close", id="btn_close_progress", disabled=True)
yield Footer()
def on_mount(self):
self.start_time = time.time()
self.timer_event = self.set_interval(1.0, self.update_timer)
def update_timer(self):
elapsed = int(time.time() - self.start_time)
mins, secs = divmod(elapsed, 60)
try:
self.query_one("#prog_timer", Label).update(f"Elapsed: {mins:02d}:{secs:02d}")
except Exception:
pass
def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "btn_close_progress":
if self.timer_event:
self.timer_event.stop()
self.dismiss(None)
def write(self, message: str):
self.query_one("#progress_log", RichLog).write(message)
try:
self.query_one("#prog_log", RichLog).write(message)
except Exception:
pass
# alias used by backup code
write_to_log = write
def set_status(self, status: str):
self.query_one("#progress_status", Label).update(status)
try:
self.query_one("#prog_status", Label).update(status)
except Exception:
pass
def set_progress(self, current: int, total: int):
bar = self.query_one("#progress_bar", ProgressBar)
try:
self.query_one("#prog_loader", LoadingIndicator).display = False
bar = self.query_one("#prog_bar", ProgressBar)
bar.display = True
bar.update(total=total, progress=current)
except Exception:
pass
def show_stats(self):
try:
self.query_one("#prog_stats", Horizontal).display = True
except Exception:
pass
def update_stats(self, **kwargs):
# kwargs can be messages, threads, files
for key, val in kwargs.items():
try:
self.query_one(f"#stat_{key}", Label).update(f"{key.capitalize()}: {val}")
except Exception:
pass
def allow_close(self):
if self.timer_event:
self.timer_event.stop()
try:
btn = self.query_one("#btn_close_progress", Button)
btn.disabled = False
btn.variant = "success"
self.query_one("#progress_bar", ProgressBar).update(total=100, progress=100)
self.query_one("#prog_loader", LoadingIndicator).display = False
bar = self.query_one("#prog_bar", ProgressBar)
bar.display = True
bar.update(total=100, progress=100)
except Exception:
pass
# ---------------------------------------------------------------------------
# ReportModal simple post-operation report
# ---------------------------------------------------------------------------
class ReportModal(ModalScreen[None]):
"""Modal to display a post-operation report."""
DEFAULT_CSS = """
ReportModal { align: center middle; }
#report_dialog {
width: 60;
height: auto;
border: thick $background 80%;
background: $surface;
padding: 1 2;
}
#report_title { text-style: bold; margin-bottom: 1; content-align: center middle; width: 100%; color: green; }
#report_content { margin-bottom: 2; height: auto; }
#report_btn { width: 1fr; margin: 0 1; }
"""
def __init__(self, title: str, report_text: str):
super().__init__()
self.report_title = title
self.report_text = report_text
def compose(self) -> ComposeResult:
with Vertical(id="report_dialog"):
yield Label(self.report_title, id="report_title")
yield Label(self.report_text, id="report_content")
yield Button("OK", variant="primary", id="report_btn")
def on_button_pressed(self, event: Button.Pressed):
self.dismiss(None)
# ---------------------------------------------------------------------------
@ -52,6 +188,7 @@ class ProgressModal(ModalScreen[None]):
class ConfirmModal(ModalScreen[bool]):
"""Simple Yes / No confirmation modal."""
DEFAULT_CSS = "ConfirmModal { align: center middle; }"
def __init__(self, message: str, danger: bool = False):
super().__init__()
@ -75,6 +212,7 @@ class ConfirmModal(ModalScreen[bool]):
class SubMenuModal(ModalScreen[str]):
"""A generic sub-menu modal that presents a list of labelled buttons."""
DEFAULT_CSS = "SubMenuModal { align: center middle; }"
def __init__(self, title: str, options: list[tuple[str, str, str]]):
"""options: list of (button_id, label, variant)"""
@ -101,27 +239,63 @@ class SubMenuModal(ModalScreen[str]):
# ChannelPickerModal single-channel selection (shuttle)
# ---------------------------------------------------------------------------
class ChannelPickerModal(ModalScreen[int]):
"""Modal listing Discord channels for single-channel selection."""
class ChannelPickerScreen(Screen[tuple]):
"""Screen listing Discord channels (left) and Target platforms channels (right) for dual selection."""
def __init__(self, channels: list, categories: dict, label: str = "Select Channel"):
DEFAULT_CSS = """
ChannelPickerScreen { align: center middle; }
#cp_outer { width: 100%; height: 100%; align: center top; }
#chanpick_dialog {
width: 80%;
height: 100%;
layout: vertical;
border: solid green;
padding: 1 2;
margin: 2 0;
background: $surface;
}
#chanpick_title { text-style: bold; margin-bottom: 1; content-align: center middle; width: 100%; }
#chanpick_split {
height: 1fr;
layout: horizontal;
}
.split_pane {
width: 1fr;
height: 100%;
border: solid $primary;
margin: 0 1;
padding: 0 1;
}
.pane_title { text-style: bold; margin-bottom: 1; color: cyan; margin-left: 1; }
.category_header {
margin-top: 1;
background: $primary 10%;
text-style: bold;
padding-left: 1;
}
#chanpick_buttons { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; }
#chanpick_buttons Button { width: 1fr; margin: 0 1; }
"""
def __init__(self, src_channels: list, src_cat_map: dict, tgt_channels: list, tgt_cat_map: dict, tgt_name: str = "Fluxer"):
super().__init__()
self._channels = channels
self._categories = categories
self._label = label
self.src_channels = src_channels
self.src_cat_map = src_cat_map
self.tgt_channels = tgt_channels
self.tgt_cat_map = tgt_cat_map
self.tgt_name = tgt_name
def compose(self) -> ComposeResult:
with Vertical(id="chanpick_dialog"):
yield Label(self._label, id="chanpick_title")
with VerticalScroll(id="chanpick_scroll"):
def _render_pane(self, channels, categories, pane_id, prefix):
cat_grouped: dict[int | None, list] = {}
for c in self._channels:
for c in channels:
cat_id = getattr(c, "category_id", None) if not isinstance(c, dict) else c.get("parent_id")
cat_grouped.setdefault(cat_id, []).append(c)
for cat_id in sorted(cat_grouped, key=lambda k: self._categories.get(k, "") if k else ""):
if cat_id is not None and cat_id in self._categories:
yield Label(f"[cyan]{self._categories[cat_id]}[/cyan]", classes="category_header")
with VerticalScroll(classes="split_pane", id=pane_id):
with RadioSet(id=f"{prefix}_radioset"):
for cat_id in sorted(cat_grouped, key=lambda k: categories.get(k, "") if k else ""):
if cat_id is not None and cat_id in categories:
yield Label(f"[cyan]{categories[cat_id]}[/cyan]", classes="category_header")
for c in cat_grouped[cat_id]:
if isinstance(c, dict):
name = c.get("name", "Unnamed")
@ -129,30 +303,91 @@ class ChannelPickerModal(ModalScreen[int]):
else:
name = c.name
cid = c.id
yield RadioButton(name, value=False, id=f"chpk_{cid}")
yield RadioButton(name, value=False, id=f"{prefix}_{cid}")
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Container(id="cp_outer"):
with Container(id="chanpick_dialog"):
yield Label("Select Source and Target Channels", id="chanpick_title")
with Horizontal(id="chanpick_split"):
with Vertical():
yield Label("Source: Discord", classes="pane_title")
yield from self._render_pane(self.src_channels, self.src_cat_map, "pane_src", "src")
with Vertical():
yield Label(f"Target: {self.tgt_name}", classes="pane_title")
yield from self._render_pane(self.tgt_channels, self.tgt_cat_map, "pane_tgt", "tgt")
yield Rule()
with Horizontal(id="chanpick_buttons"):
yield Button("Select", variant="success", id="btn_pick_ok")
yield Button("Cancel", id="btn_pick_cancel")
yield Footer()
def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "btn_pick_cancel":
self.dismiss(None)
elif event.button.id == "btn_pick_ok":
for rb in self.query(RadioButton):
if rb.value and rb.id and rb.id.startswith("chpk_"):
self.dismiss(int(rb.id.split("_", 1)[1]))
return
# Nothing selected
return
src_val = None
tgt_val = None
src_rset = self.query_one("#src_radioset", RadioSet)
if src_rset.pressed_button:
src_val = src_rset.pressed_button.id.split("_", 1)[1]
tgt_rset = self.query_one("#tgt_radioset", RadioSet)
if tgt_rset.pressed_button:
tgt_val = tgt_rset.pressed_button.id.split("_", 1)[1]
if src_val and tgt_val:
self.dismiss((int(src_val), tgt_val)) # Source is guaranteed Discord ID (int), Target could be UUID/string
else:
self.notify("Please select one channel from both lists.", severity="warning")
# ---------------------------------------------------------------------------
# ChannelSelectModal multi-channel selection with sync/force (backup)
# ---------------------------------------------------------------------------
class ChannelSelectModal(ModalScreen[dict]):
"""Modal for selecting channels using a simple checkbox list."""
class ChannelSelectScreen(Screen[dict]):
"""Screen for selecting channels using a simple checkbox list."""
DEFAULT_CSS = """
ChannelSelectScreen { align: center middle; }
#cs_outer { width: 100%; height: 100%; align: center top; }
#channel_dialog {
width: 80%;
height: 100%;
layout: vertical;
border: solid green;
padding: 1 2;
margin: 2 0;
background: $surface;
}
#chan_title { text-style: bold; margin-bottom: 1; }
#channel_list_scroll {
height: 1fr;
border: solid $primary;
margin-bottom: 1;
padding: 0 1;
}
.category_header {
margin-top: 1;
background: $primary 10%;
text-style: bold;
padding-left: 1;
}
.label_warning {
padding-top: 1;
padding-right: 1;
color: yellow;
}
#select_all_buttons { height: auto; margin-bottom: 1; }
#select_all_buttons Button { width: auto; margin-right: 1; }
#confirm_buttons { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; }
#confirm_buttons Button { width: 1fr; margin: 0 1; }
"""
def __init__(self, channels: list, categories: dict, backed_up_ids: set, any_found: bool):
super().__init__()
@ -169,7 +404,9 @@ class ChannelSelectModal(ModalScreen[dict]):
self.any_found = any_found
def compose(self) -> ComposeResult:
with Vertical(id="channel_dialog"):
yield Header(show_clock=True)
with Container(id="cs_outer"):
with Container(id="channel_dialog"):
yield Label("Select Channels to Backup", id="chan_title")
with VerticalScroll(id="channel_list_scroll"):
@ -197,6 +434,7 @@ class ChannelSelectModal(ModalScreen[dict]):
yield Button("Select All", id="btn_all")
yield Button("Deselect All", id="btn_none")
yield Rule()
with Horizontal(id="confirm_buttons"):
if self.any_found:
yield Label("Existing backups found:", classes="label_warning")
@ -205,6 +443,7 @@ class ChannelSelectModal(ModalScreen[dict]):
else:
yield Button("Backup", variant="success", id="btn_backup")
yield Button("Cancel", id="btn_cancel_chan")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn_all":

View file

@ -22,7 +22,7 @@ class ModeScreen(Screen):
"""Unified mode screen — adapts content based on tool_mode."""
CSS = """
#main_scroll {
#main_outer {
align: center top;
height: 100%;
}
@ -48,33 +48,22 @@ class ModeScreen(Screen):
margin: 0 1;
}
/* Modal styling (shared across both panes) */
#progress_dialog {
ModalScreen {
align: center middle;
}
#progress_dialog, #shuttle_config_dialog, #platform_select_dialog, #submenu_dialog, #confirm_dialog, #chanpick_dialog, #channel_dialog {
width: 80%;
height: 80%;
border: thick $background 80%;
background: $surface;
height: 100%;
layout: vertical;
border: solid green;
padding: 1 2;
margin: 2 0;
background: $surface;
}
#progress_status { text-style: bold; margin-bottom: 1; }
#progress_bar { margin-bottom: 1; }
#progress_loader { margin-bottom: 1; }
#progress_log { height: 1fr; margin-bottom: 1; border: solid $primary; }
#shuttle_config_dialog, #platform_select_dialog, #submenu_dialog, #confirm_dialog {
width: 60%;
height: auto;
max-height: 80%;
border: thick $background 80%;
background: $surface;
padding: 1 2;
}
#chanpick_dialog {
width: 70%;
height: 75%;
border: thick $background 80%;
background: $surface;
padding: 1 2;
}
#chanpick_scroll {
height: 1fr;
border: solid $primary;
@ -96,14 +85,6 @@ class ModeScreen(Screen):
#config_buttons Button, #confirm_buttons Button, #chanpick_buttons Button {
width: 1fr; margin: 0 1;
}
#channel_dialog {
width: 80%;
height: 80%;
border: thick $background 80%;
background: $surface;
padding: 1 2;
}
#channel_list_scroll {
height: 1fr;
border: solid $primary;
@ -134,7 +115,7 @@ class ModeScreen(Screen):
mode = self.config.tool_mode or "backup_only"
with VerticalScroll(id="main_scroll"):
with Container(id="main_outer"):
with Container(id="main_container"):
if mode == "backup_only":
yield BackupPane(self.cfg_name, self.config_path, id="pane_backup")
@ -162,9 +143,17 @@ class ModeScreen(Screen):
from src.ui.main_app import ConfigScreen
def reload_screen(saved: bool = False):
if saved:
new_cfg = load_config(self.config_path)
if new_cfg.tool_mode != self.config.tool_mode:
self.app.pop_screen()
from src.ui.mode_screen import ModeScreen
self.app.push_screen(ModeScreen(self.cfg_name, self.config_path))
else:
self.config = new_cfg
for pane in self.query(BackupPane):
pane.reload_config()
for pane in self.query(ShuttlePane):
pane.reload_config()
self.app.push_screen(ConfigScreen(self.cfg_name, self.config_path), reload_screen)
elif bid == "btn_switch":
self._toggle_pane()
@ -181,22 +170,3 @@ class ModeScreen(Screen):
switcher.current = "pane_backup"
btn.label = "Switch to Migrate ⇄"
self._showing_backup = True
def on_screen_resume(self) -> None:
"""Reload config when returning from ConfigScreen.
If the mode changed, pop back to ConfigSelectionScreen so the user
re-enters with the correct layout.
"""
old_mode = self.config.tool_mode
self.config = load_config(self.config_path)
new_mode = self.config.tool_mode
if new_mode != old_mode:
self.app.pop_screen()
return
# Propagate config reload to panes
for pane in self.query(BackupPane):
pane.reload_config()
for pane in self.query(ShuttlePane):
pane.reload_config()

View file

@ -20,7 +20,7 @@ from src.core.configuration import load_config
from src.core.base import MigrationContext
from src.core.audit import log_audit_event
from src.ui.modals import (
ProgressModal, ConfirmModal, SubMenuModal, ChannelPickerModal,
ProgressScreen, ReportModal, ConfirmModal, SubMenuModal, ChannelPickerScreen,
)
import src.fluxer.roles_permissions as fluxer_roles
@ -292,7 +292,7 @@ class ShuttlePane(Container):
else:
from src.stoat.clone_server import sync_channel_state, migrate_channels
modal = ProgressModal()
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
@ -356,7 +356,7 @@ class ShuttlePane(Container):
@work(exclusive=True)
async def run_clone_roles(self) -> None:
roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles
modal = ProgressModal()
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
@ -394,7 +394,7 @@ class ShuttlePane(Container):
@work(exclusive=True)
async def run_sync_permissions(self) -> None:
roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles
modal = ProgressModal()
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
@ -453,7 +453,7 @@ class ShuttlePane(Container):
@work(exclusive=True)
async def run_copy_emojis(self, types_to_include: list[str]) -> None:
asset_mod = stoat_emoji_stickers if self.target_platform == "stoat" else fluxer_emoji_stickers
modal = ProgressModal()
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
@ -524,7 +524,7 @@ class ShuttlePane(Container):
@work(exclusive=True)
async def run_sync_metadata(self, components: list[str]) -> None:
meta_mod = fluxer_metadata if self.target_platform == "fluxer" else stoat_metadata
modal = ProgressModal()
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
@ -575,7 +575,7 @@ class ShuttlePane(Container):
migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate
platform_name = self.target_platform.capitalize()
modal = ProgressModal()
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
@ -593,65 +593,38 @@ class ShuttlePane(Container):
modal.allow_close()
return
# Pick source channel
self.app.pop_screen()
loop = asyncio.get_running_loop()
src_future = loop.create_future()
def on_src(cid):
if not src_future.done():
src_future.set_result(cid)
self.app.push_screen(ChannelPickerModal(d_channels, d_cat_map, "Select Source Discord Channel"), on_src)
src_id = await src_future
if src_id is None:
await self.engine.close_connections()
return
source_channel = next(c for c in d_channels if c.id == src_id)
# Fetch target channels
modal2_status = ProgressModal()
self.app.push_screen(modal2_status)
await asyncio.sleep(0.1)
modal2_status.set_status(f"Fetching {platform_name} channels...")
modal.set_status(f"Fetching {platform_name} channels...")
full_f = await self.engine.writer.get_channels()
f_channels = [c for c in full_f if c.get("name") not in ["reaper_logs", "reaper-logs"] and c.get("type") not in [2, 4]]
if not f_channels:
modal2_status.write(f"[yellow]No channels found in {platform_name} community.[/yellow]")
modal2_status.allow_close()
modal.write(f"[yellow]No channels found in {platform_name} community.[/yellow]")
modal.allow_close()
await self.engine.close_connections()
return
# Auto-match
mapped_id = self.engine.state.get_fluxer_channel_id(str(source_channel.id))
recommended = None
if mapped_id:
recommended = next((c for c in f_channels if str(c.get("id", "")) == mapped_id), None)
if not recommended:
recommended = next((c for c in f_channels if c.get("name") == source_channel.name), None)
self.app.pop_screen()
target_cat_names = {str(c.get("id")): c.get("name") for c in full_f if c.get("type") == 4}
tgt_future = loop.create_future()
loop = asyncio.get_running_loop()
pick_future = loop.create_future()
def on_tgt(cid):
if not tgt_future.done():
tgt_future.set_result(cid)
def on_pick(result):
if not pick_future.done():
pick_future.set_result(result)
self.app.push_screen(ChannelPickerModal(f_channels, target_cat_names, f"Select Target {platform_name} Channel"), on_tgt)
tgt_id = await tgt_future
self.app.push_screen(ChannelPickerScreen(d_channels, d_cat_map, f_channels, target_cat_names, platform_name), on_pick)
res = await pick_future
if tgt_id is None:
if res is None:
await self.engine.close_connections()
return
src_id, tgt_id = res
source_channel = next(c for c in d_channels if c.id == src_id)
target_channel = next(c for c in f_channels if c.get("id") == tgt_id)
# Determine after_id
@ -661,10 +634,11 @@ class ShuttlePane(Container):
after_id = int(last_migrated)
# Analyze
modal = ProgressModal()
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.set_status("Analyzing channel...")
modal.show_stats()
self.engine.is_running = True
stats = {"messages": 0, "threads": 0, "attachments": 0}
@ -690,6 +664,7 @@ class ShuttlePane(Container):
async def update_msg(count):
modal.set_status(f"[cyan]Migrated {count}/{total_messages} messages...")
modal.set_progress(count, total_messages)
modal.update_stats(messages=count)
result = await migrate_mod.migrate_messages(
self.engine,
@ -706,6 +681,11 @@ class ShuttlePane(Container):
modal.write(f"[bold yellow]Interrupted! {result['messages']} messages migrated.[/bold yellow]")
event_title = "Message History Migration Interrupted"
self.app.push_screen(ReportModal(
event_title,
f"Successfully processed {result['messages']} messages, {result['attachments']} attachments, and {result['threads']} threads."
))
lines = [f"Migrated Discord #{source_channel.name}{platform_name} #{target_channel.get('name')}:"]
lines.append(f"{result['messages']} messages, {result['attachments']} attachments, {result['threads']} threads")
await log_audit_event(self.engine, event_title, "\n".join(lines))
@ -719,7 +699,6 @@ class ShuttlePane(Container):
finally:
self.engine.is_running = False
await self.engine.close_connections()
modal.set_status("Finished.")
modal.allow_close()
# ── (6) danger zone ───────────────────────────────────────────────────
@ -755,7 +734,7 @@ class ShuttlePane(Container):
else:
from src.stoat.danger_zone import danger_delete_all_channels
modal = ProgressModal()
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
@ -784,7 +763,7 @@ class ShuttlePane(Container):
else:
from src.stoat.danger_zone import danger_reset_channel_permissions
modal = ProgressModal()
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
@ -813,7 +792,7 @@ class ShuttlePane(Container):
else:
from src.stoat.danger_zone import danger_delete_all_roles
modal = ProgressModal()
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
@ -842,7 +821,7 @@ class ShuttlePane(Container):
else:
from src.stoat.danger_zone import danger_delete_all_emojis_and_stickers
modal = ProgressModal()
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)