diff --git a/src/core/backup_reader.py b/src/core/backup_reader.py index 28d5b5c..670b6b9 100644 --- a/src/core/backup_reader.py +++ b/src/core/backup_reader.py @@ -787,12 +787,13 @@ class BackupReader: return results profile = bp / "server_profile" / "profile.json" - if profile.exists(): + structure = bp / "server_profile" / "structure.json" + if profile.exists() and structure.exists(): try: data = json.loads(profile.read_text(encoding="utf-8")) results["token"] = True results["server"] = True - results["bot_name"] = "BackupReader" + results["bot_name"] = "LOCAL BACKUP" results["server_name"] = data.get("name", "Unknown") except Exception: pass diff --git a/src/ui/backup_ops.py b/src/ui/backup_ops.py index f65eb93..3a56c1b 100644 --- a/src/ui/backup_ops.py +++ b/src/ui/backup_ops.py @@ -21,7 +21,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 ProgressScreen, ChannelSelectScreen +from src.ui.modals import ProgressScreen, ChannelSelectScreen, MessageIDInputModal class BackupPane(Container): @@ -306,12 +306,30 @@ class BackupPane(Container): if choice == "btn_back": modal_prog.dismiss() continue + elif choice == "btn_start_id": + loop = asyncio.get_running_loop() + future = loop.create_future() + def id_callback(res: int | None) -> None: + if not future.done(): + future.set_result(res) + + id_modal = MessageIDInputModal(self.engine.discord_reader, selected_channels[0].id) + self.app.push_screen(id_modal, id_callback) + verified_id = await future + + if verified_id is None: + # User cancelled the ID input + continue + + after_id = verified_id elif choice == "btn_main_menu": modal_prog.dismiss() self.app.switch_screen("config_selection") return - # Proceed to progress + # If we are here, proceeding either via Start First or Start from ID (after_id) + if choice == "btn_start_first": + after_id = None break modal_prog.phase_progress() @@ -349,7 +367,7 @@ class BackupPane(Container): accumulated_msgs = await self.exporter.export_channel_messages( chan.id, progress_callback=update_msg_count, force=force_overwrite, - accumulated_count=accumulated_msgs + accumulated_count=accumulated_msgs, after_id=after_id ) accumulated_msgs = await self.exporter.export_threads( chan.id, progress_callback=update_msg_count, force=force_overwrite, diff --git a/src/ui/modals.py b/src/ui/modals.py index e1e04e6..df7d02f 100644 --- a/src/ui/modals.py +++ b/src/ui/modals.py @@ -699,3 +699,108 @@ class ChannelSelectScreen(Screen[dict]): self.dismiss({"channels": selected, "force": force}) elif event.button.id == "btn_cancel_chan": self.dismiss(None) + +# --------------------------------------------------------------------------- +# MessageIDInputModal – Input a Discord Message ID and verify it +# --------------------------------------------------------------------------- + +class MessageIDInputModal(ModalScreen[int | None]): + """Modal to input a message ID, verify it exists, and then confirm start.""" + + DEFAULT_CSS = """ + MessageIDInputModal { align: center middle; } + #msg_id_dialog { + width: 80%; + height: auto; + border: solid yellow; + padding: 1 2; + background: $surface; + } + #msg_preview_container { + border: solid $primary; + padding: 1 2; + margin: 1 0; + height: auto; + min-height: 5; + } + #msg_id_buttons { + height: auto; + dock: bottom; + margin-top: 1; + } + #msg_id_buttons Button { width: 1fr; margin: 0 1; } + """ + + def __init__(self, reader, channel_id: int): + super().__init__() + self.reader = reader + self.channel_id = channel_id + self.verified_id: int | None = None + + def compose(self) -> ComposeResult: + with Container(id="msg_id_dialog"): + yield Label("[bold yellow]Start from specific Message ID[/bold yellow]") + yield Input(placeholder="Enter Discord Message ID (e.g., 123456789012345678)", id="input_msg_id", type="number") + with Container(id="msg_preview_container"): + yield Label("Enter an ID and click Verify to preview.", id="lbl_msg_preview") + + with Horizontal(id="msg_id_buttons"): + yield Button("Verify", variant="primary", id="btn_verify_start", disabled=True) + yield Button("Back", variant="warning", id="btn_cancel_msg_id") + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "input_msg_id": + btn = self.query_one("#btn_verify_start", Button) + self.verified_id = None + btn.label = "Verify" + btn.variant = "primary" + + val = event.input.value.strip() + if val and val.isdigit(): + btn.disabled = False + else: + btn.disabled = True + + preview = self.query_one("#lbl_msg_preview", Label) + preview.update("Click Verify to fetch message.") + + async def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_cancel_msg_id": + self.dismiss(None) + + elif event.button.id == "btn_verify_start": + # If already verified, we are starting + if self.verified_id is not None: + self.dismiss(self.verified_id) + return + + # Otherwise we verify + btn = event.button + inp = self.query_one("#input_msg_id", Input) + preview = self.query_one("#lbl_msg_preview", Label) + + msg_id_str = inp.value.strip() + try: + msg_id = int(msg_id_str) + except ValueError: + preview.update("[bold red]Invalid ID format. Must be numeric.[/bold red]") + return + + btn.disabled = True + preview.update("[cyan]Fetching message...[/cyan]") + + try: + msg = await self.reader.get_message(self.channel_id, msg_id) + if not msg: + preview.update("[bold red]Message not found in this channel.[/bold red]") + else: + self.verified_id = msg_id + content = msg.content or (f"[dim]({len(msg.attachments)} attachments)[/dim]" if msg.attachments else "[dim](no content)[/dim]") + preview.update(f"[bold green]Message Found![/bold green]\n\n[bold]{msg.author.display_name}:[/bold] {content[:300]}") + + btn.label = "Start Migration" + btn.variant = "success" + except Exception as e: + preview.update(f"[bold red]Error fetching message:[/bold red] {e}") + finally: + btn.disabled = False diff --git a/src/ui/shuttle_ops.py b/src/ui/shuttle_ops.py index 3cd0c81..346a9a2 100644 --- a/src/ui/shuttle_ops.py +++ b/src/ui/shuttle_ops.py @@ -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 ( - ProgressScreen, SubMenuModal, ChannelPickerScreen, OptionSelectModal, + ProgressScreen, SubMenuModal, ChannelPickerScreen, OptionSelectModal, MessageIDInputModal, ) import src.fluxer.roles_permissions as fluxer_roles @@ -161,13 +161,15 @@ class ShuttlePane(Container): s_disp = f'[green]"{d_name}"[/green]' b_disp = f'[green]{d_bot}[/green]' elif v.get("discord_token") is False: - s_disp, b_disp = "[red]INVALID TOKEN[/red]", "[red]INVALID TOKEN[/red]" + if self.config.tool_mode == "backup_transfer": + s_disp, b_disp = "[red]NOT FOUND[/red]", "[red]NOT FOUND[/red]" + else: + s_disp, b_disp = "[red]INVALID TOKEN[/red]", "[red]INVALID TOKEN[/red]" else: s_disp, b_disp = "[red]NOT SET UP[/red]", "[red]NOT SET UP[/red]" self.query_one("#sp_lbl_d_server", Label).update(f"Server: {s_disp}") if self.config.tool_mode == "backup_transfer": - b_disp = "[green]LOCAL BACKUP[/green]" if v.get("discord_server") else "[red]NOT FOUND[/red]" self.query_one("#sp_lbl_d_bot", Label).update(f"Source: {b_disp}") else: self.query_one("#sp_lbl_d_bot", Label).update(f"Bot: {b_disp}") @@ -227,7 +229,10 @@ class ShuttlePane(Container): "000000000000000000", "DISCORD_SERVER_ID", "FLUXER_COMMUNITY_ID", "STOAT_SERVER_ID", "TARGET_SERVER_ID", "", None, ] - d_dummy = self.config.discord_bot_token in fillers or self.config.discord_server_id in fillers + if self.config.tool_mode == "backup_transfer": + d_dummy = self.config.discord_server_id in fillers + else: + d_dummy = self.config.discord_bot_token in fillers or 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 = {} @@ -894,10 +899,23 @@ class ShuttlePane(Container): logger.info("Proceeding with 'Continue Migration' (incremental sink).") after_id = int(last_migrated) elif choice == "btn_start_id": - # Fallback to full for now since we don't have an ID input dialog yet - modal.write("[yellow]Custom Message ID start not fully implemented, starting from beginning.[/yellow]") - logger.info("Proceeding with 'Start from ID' (fallback to begin).") - after_id = None + loop = asyncio.get_running_loop() + future = loop.create_future() + def id_callback(res: int | None) -> None: + if not future.done(): + future.set_result(res) + + id_modal = MessageIDInputModal(self.engine.discord_reader, source_channel.id) + self.app.push_screen(id_modal, id_callback) + verified_id = await future + + if verified_id is None: + # User cancelled the ID input, stay on the progress modal + logger.info("User cancelled 'Start from ID' input.") + continue + + logger.info(f"Proceeding with 'Start from ID': {verified_id}") + after_id = verified_id else: logger.info("Proceeding with 'Start from First' (clean sink).")