improve proress tracking
This commit is contained in:
parent
1cf5463da6
commit
6c51fcd9ae
4 changed files with 384 additions and 196 deletions
|
|
@ -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."))
|
||||
|
|
|
|||
317
src/ui/modals.py
317
src/ui/modals.py
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue