From a30d02eb2edeb50fca389a061f03ca835646e0ef Mon Sep 17 00:00:00 2001 From: rambros Date: Thu, 5 Mar 2026 17:12:26 +0530 Subject: [PATCH] complete backup structure refactor --- BACKUP.md | 2 +- src/core/backup_reader.py | 1 - src/core/base.py | 12 ++-- src/disco_reaper/exporter.py | 9 ++- src/ui/backup_ops.py | 130 ++++++++++++++++++++++++++++++----- src/ui/modals.py | 16 +++-- src/ui/shuttle_ops.py | 85 +++++++++++++++++++++-- 7 files changed, 216 insertions(+), 39 deletions(-) diff --git a/BACKUP.md b/BACKUP.md index 1989de5..8d7441b 100644 --- a/BACKUP.md +++ b/BACKUP.md @@ -289,7 +289,7 @@ Reconstructing the hierarchy requires specific pointer logic: 1. **Forums**: - Read `message_backup/{forum_id}/messages.json`. - - Each message in this file is a `Thread_starter_message`. + - Each message in this file is a `ThreadStarter`. - The `messageID` of the starter message *is usually* the same as the `thread_id`. - To load the full thread, open `message_backup/{forum_id}/{thread_id}/thread_messages.json`. 2. **Regular Threads**: diff --git a/src/core/backup_reader.py b/src/core/backup_reader.py index ee3642e..8a0569d 100644 --- a/src/core/backup_reader.py +++ b/src/core/backup_reader.py @@ -501,7 +501,6 @@ class BackupMessage: "Default": MessageType.default, "Reply": MessageType.reply, "ThreadStarter": MessageType.thread_starter_message, - "Thread_starter_message": MessageType.thread_starter_message, "Forward": MessageType.default, } diff --git a/src/core/base.py b/src/core/base.py index 413d663..e8c6de6 100644 --- a/src/core/base.py +++ b/src/core/base.py @@ -78,15 +78,17 @@ class MigrationContext: @staticmethod def _find_backup_path(server_id: str) -> Path: - """Searches workspace for a DISCORD_BACKUP-{server_id} directory.""" + """Searches workspace for a DISCORD_BACKUP-{server_id} directory. Creates it if missing.""" for d in Path(".").rglob(f"DISCORD_BACKUP-{server_id}"): if d.is_dir(): logger.info(f"Found backup directory: {d}") return d - raise FileNotFoundError( - f"No backup found for server {server_id}. " - f"Expected a directory named DISCORD_BACKUP-{server_id}" - ) + + # If not found, create it in the current directory + new_path = Path(".") / f"DISCORD_BACKUP-{server_id}" + logger.info(f"No existing backup directory found for {server_id}. Creating new one: {new_path}") + new_path.mkdir(exist_ok=True) + return new_path async def validate_all(self) -> Dict[str, Any]: """Returns connection validation status as a dictionary.""" diff --git a/src/disco_reaper/exporter.py b/src/disco_reaper/exporter.py index effd875..490c8b3 100644 --- a/src/disco_reaper/exporter.py +++ b/src/disco_reaper/exporter.py @@ -612,9 +612,12 @@ class DiscordExporter: }) # Determine message type (Override if it's a thread starter or forward) - msg_type = str(msg.type).split(".")[-1].capitalize() - if msg.thread: + raw_repr = str(msg.type).lower() + + if "thread_starter" in raw_repr or msg.thread: msg_type = "ThreadStarter" + else: + msg_type = raw_repr.split(".")[-1].capitalize() # Check for forwarded flags (newer discord.py feature) try: @@ -733,7 +736,7 @@ class DiscordExporter: "../../users/avatars" # Two levels up from {forum_id}/{thread_id}/ ) # Override type and add title for forum starter messages - msg_data["type"] = "Thread_starter_message" + msg_data["type"] = "ThreadStarter" msg_data["title"] = thread.name # Store applied tag IDs (as strings) — names are resolvable via the forum's available_tags diff --git a/src/ui/backup_ops.py b/src/ui/backup_ops.py index f528099..4a02255 100644 --- a/src/ui/backup_ops.py +++ b/src/ui/backup_ops.py @@ -14,7 +14,7 @@ from datetime import datetime logger = logging.getLogger(__name__) from textual.app import ComposeResult -from textual.containers import Container, Vertical, VerticalScroll +from textual.containers import Container, Vertical, VerticalScroll, Horizontal from textual.widgets import Button, Label, Rule from textual import work @@ -30,8 +30,14 @@ class BackupPane(Container): DEFAULT_CSS = """ BackupPane { height: auto; width: 100%; } BackupPane #bp_info { - height: auto; border: tall cyan; padding: 1; margin-bottom: 1; + height: auto; border: tall cyan; padding: 1; margin-bottom: 1; layout: vertical; } + #bp_info_split { height: auto; layout: horizontal; width: 100%; margin-bottom: 1; } + .info_pane { width: 1fr; height: auto; } + .info_pane Label { width: 100%; } + .pane_header { text-style: bold; color: $accent; margin-bottom: 1; } + #bp_lbl_backup { text-style: bold; margin-top: 1; } + BackupPane #bp_actions { height: auto; } BackupPane #bp_actions Button { width: 100%; margin-bottom: 1; } """ @@ -47,9 +53,17 @@ class BackupPane(Container): def compose(self) -> ComposeResult: with VerticalScroll(): with Vertical(id="bp_info"): - yield Label("Loading...", id="bp_lbl_server") - yield Label("", id="bp_lbl_bot") - yield Label("", id="bp_lbl_backup") + with Horizontal(id="bp_info_split"): + with Vertical(classes="info_pane"): + yield Label("Discord", classes="pane_header") + yield Label("Server: -", id="bp_lbl_server") + yield Label("Source: -", id="bp_lbl_bot") + with Vertical(classes="info_pane", id="bp_target_pane"): + # Hidden in backup mode + pass + + yield Rule() + yield Label("Status: -", id="bp_lbl_backup") with Vertical(id="bp_actions"): yield Button("Backup Server Profile", id="bp_backup_profile", disabled=True) yield Button("Backup Channel Messages", id="bp_backup_msgs", disabled=True, variant="primary") @@ -105,11 +119,18 @@ class BackupPane(Container): return None def _update_ui(self, server_text, bot_text, backup_text, enabled): - self.query_one("#bp_lbl_server", Label).update(f"Source Server: {server_text}") - self.query_one("#bp_lbl_bot", Label).update(f"Bot: {bot_text}") - self.query_one("#bp_lbl_backup", Label).update(backup_text) - for bid in ("#bp_backup_profile", "#bp_backup_msgs", "#bp_backup_sync"): - self.query_one(bid, Button).disabled = not enabled + try: + self.query_one("#bp_lbl_server", Label).update(f"Server: {server_text}") + self.query_one("#bp_lbl_bot", Label).update(f"Source: {bot_text}") + self.query_one("#bp_lbl_backup", Label).update(f"Status: {backup_text}") + + # Hide target pane in backup + self.query_one("#bp_target_pane").display = False + + for bid in ("#bp_backup_profile", "#bp_backup_msgs", "#bp_backup_sync"): + self.query_one(bid, Button).disabled = not enabled + except Exception: + pass # ── button routing ──────────────────────────────────────────────────── @@ -136,6 +157,35 @@ class BackupPane(Container): await self.engine.discord_reader.start() await self.exporter.setup() + # Gather and print summary + server = getattr(self.engine.discord_reader, 'current_server', None) + if server: + modal.write(f"[bold cyan]Server Profile to Backup:[/bold cyan]") + modal.write(f" Name: [green]{server.name}[/green]") + modal.write(f" Icon: [green]{'Present' if server.icon else 'None'}[/green]") + modal.write(f" Roles: [green]{len(server.roles)}[/green]") + modal.write(f" Emojis: [green]{len(server.emojis)}[/green]") + modal.write(f" Channels: [green]{len(server.channels)}[/green]") + modal.write("") + + modal.show_info("[bold cyan]Profile Backup Ready[/bold cyan]", f"Overview: {len(server.channels) if server else '?'} Channels, {len(server.roles) if server else '?'} Roles") + modal.set_status("Awaiting Confirmation to Backup Profile...") + + choice = await modal.phase_wait_confirm( + btn_start_label="Start Backup", + show_id=False + ) + if choice in ("btn_back", "btn_main_menu"): + modal.dismiss() + self.engine.is_running = False + await self.engine.close_connections() + if choice == "btn_main_menu": + self.app.switch_screen("config_selection") + return + + modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) + modal.phase_progress() + modal.set_status("Exporting Server Structure...") modal.write("[yellow]Backing up server profile & skeleton...[/yellow]") await self.exporter.export_metadata() await self.exporter.download_server_assets() @@ -227,18 +277,30 @@ class BackupPane(Container): self.app.push_screen(modal_prog) await asyncio.sleep(0.1) - msg = "Backup Channels" if not force_overwrite else "Overwriting existing backups" - target_preview = ", ".join([c.name for c in selected_channels[:3]]) - if len(selected_channels) > 3: - target_preview += "..." + new_channels = [c for c in selected_channels if c.id not in backed_up_ids] + existing_channels = [c for c in selected_channels if c.id in backed_up_ids] + + server = getattr(self.engine.discord_reader, 'current_server', None) + if server: + modal_prog.write(f"[bold cyan]Server Profile:[/bold cyan]") + modal_prog.write(f" Name: [green]{server.name}[/green]") + modal_prog.write(f" Icon: [green]{'Present' if server.icon else 'None'}[/green]") + modal_prog.write("") modal_prog.set_status(f"Confirm to proceed with Backup of [bold]{len(selected_channels)}[/bold] channels") - modal_prog.show_info(f"[cyan]{msg}[/cyan]", f"Targets: {target_preview}") + modal_prog.show_info(f"[cyan]Backup Channels[/cyan]", f"{len(new_channels)} new, {len(existing_channels)} existing") - # Show full target list in the bottom log - modal_prog.write("[bold]Target Channels:[/bold]") - for idx, c in enumerate(selected_channels): - modal_prog.write(f" {idx+1}. #{c.name}") + # Show categorized channel lists in the bottom log + if new_channels: + modal_prog.write("[bold green]New Backups to be created:[/bold green]") + for idx, c in enumerate(new_channels): + modal_prog.write(f" {idx+1}. #{c.name}") + + if existing_channels: + action = "Overwritten" if force_overwrite else "Updated" + modal_prog.write(f"[bold yellow]\nExisting backups to be {action}:[/bold yellow]") + for idx, c in enumerate(existing_channels): + modal_prog.write(f" {idx+1}. #{c.name}") choice = await modal_prog.phase_wait_confirm(btn_start_label="Start Channel Backup", show_id=False) if choice == "btn_back": @@ -328,6 +390,36 @@ class BackupPane(Container): await self.engine.discord_reader.start() await self.exporter.setup() + # Gather and print summary + server = getattr(self.engine.discord_reader, 'current_server', None) + if server: + modal_prog.write(f"[bold cyan]Server Profile to Sync:[/bold cyan]") + modal_prog.write(f" Name: [green]{server.name}[/green]") + modal_prog.write(f" Icon: [green]{'Present' if server.icon else 'None'}[/green]") + modal_prog.write(f" Roles: [green]{len(server.roles)}[/green]") + modal_prog.write(f" Emojis: [green]{len(server.emojis)}[/green]") + modal_prog.write(f" Channels: [green]{len(server.channels)}[/green]") + modal_prog.write("\n[dim]This operation will update the profile and scan existing baked-up channels for new messages.[/dim]") + modal_prog.write("") + + modal_prog.show_info("[bold green]Sync Ready[/bold green]", f"Overview: {len(server.channels) if server else '?'} Channels") + modal_prog.set_status("Awaiting Confirmation to Sync Profile and Messages...") + + choice = await modal_prog.phase_wait_confirm( + btn_start_label="Start Sync", + show_id=False + ) + if choice in ("btn_back", "btn_main_menu"): + modal_prog.dismiss() + self.engine.is_running = False + await self.engine.close_connections() + if choice == "btn_main_menu": + self.app.switch_screen("config_selection") + return + + modal_prog.cancel_callback = lambda: setattr(self.engine, "is_running", False) + modal_prog.phase_progress() + modal_prog.set_status("Updating structure...") modal_prog.write("Updating structure...") await self.exporter.export_metadata() await self.exporter.download_server_assets() diff --git a/src/ui/modals.py b/src/ui/modals.py index 5389e94..0f5c486 100644 --- a/src/ui/modals.py +++ b/src/ui/modals.py @@ -67,8 +67,8 @@ class ProgressScreen(Screen[None]): #prog_bar { margin-bottom: 1; width: 80%; } #prog_item_status { margin-bottom: 1; text-style: bold; color: cyan; width: 100%; text-align: center; } - #info_container { height: auto; layout: vertical; border: solid $secondary; padding: 1; margin-bottom: 1; display: none; } - .info_label { text-style: bold; content-align: center middle; width: 100%; color: $secondary-lighten-2; } + #info_container { height: auto; layout: vertical; border: solid cyan; padding: 1; margin-bottom: 1; display: none; } + .info_label { text-style: bold; content-align: center middle; width: 100%; color: cyan; } #prog_actions { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; layout: vertical; } .action_row { height: auto; layout: horizontal; } @@ -91,8 +91,8 @@ class ProgressScreen(Screen[None]): with Vertical(id="info_container"): - yield Label("No previous migration data.", id="info_migration_status", classes="info_label") - yield Label("New items detected.", id="info_new_items", classes="info_label") + yield Label("", id="info_migration_status", classes="info_label") + yield Label("", id="info_new_items", classes="info_label") # Progress bar moved inside info container with Center(id="prog_bar_container"): @@ -257,6 +257,9 @@ class ProgressScreen(Screen[None]): try: self.query_one("#prog_bar", ProgressBar).display = False except Exception: pass + + try: self.query_one("#prog_timer", Label).display = False + except Exception: pass # Update button labels try: self.query_one("#btn_start_first", Button).label = btn_start_label @@ -313,6 +316,11 @@ class ProgressScreen(Screen[None]): try: self.query_one("#prog_loader", LoadingIndicator).display = True except Exception: pass + + # Show timer and reset it for the operation + try: self.query_one("#prog_timer", Label).display = True + except Exception: pass + self.start_time = time.time() try: self.query_one("#info_migration_status", Label).display = False except Exception: pass diff --git a/src/ui/shuttle_ops.py b/src/ui/shuttle_ops.py index 8a91222..ae2444d 100644 --- a/src/ui/shuttle_ops.py +++ b/src/ui/shuttle_ops.py @@ -83,12 +83,13 @@ class ShuttlePane(Container): DEFAULT_CSS = """ ShuttlePane { height: auto; width: 100%; } ShuttlePane #sp_info { - height: auto; border: tall cyan; padding: 1; margin-bottom: 1; + height: auto; border: tall cyan; padding: 1; margin-bottom: 1; layout: vertical; } - #sp_info_split { height: auto; layout: horizontal; } + #sp_info_split { height: auto; layout: horizontal; width: 100%; margin-bottom: 1; } .info_pane { width: 1fr; height: auto; } .info_pane Label { width: 100%; } .pane_header { text-style: bold; color: $accent; margin-bottom: 1; } + #sp_lbl_status { text-style: bold; margin-top: 1; } ShuttlePane #sp_actions { height: auto; } ShuttlePane #sp_actions Button { width: 100%; margin-bottom: 1; } @@ -340,6 +341,7 @@ class ShuttlePane(Container): self.run_batch_sync(choices) self.app.push_screen(OptionSelectModal("Sync Server Settings", options), on_result) + # ── batch workers ────────────────────────────────────────────────── @work(exclusive=True) @@ -357,11 +359,29 @@ class ShuttlePane(Container): except Exception as e: logger.warning(f"Could not pre-connect for Clone preview: {e}") + # Show info container early + modal.show_info("[bold cyan]Clone Template Ready[/bold cyan]", f"{len(selections)} categories/roles selected.") + modal.set_status(f"Awaiting Confirmation for {len(selections)} Operations...") # Fetch and display live preview with presence highlighting preview = await self._fetch_clone_preview(selections) if connections_started else {} - + + if connections_started: + src_server = getattr(self.engine.discord_reader, 'current_server', None) + tgt_server_info = await self.engine.writer.validate() + tgt_server_name = tgt_server_info.get("community_name", "target community") + + if src_server: + modal.write(f"[bold cyan]Source Server Profile:[/bold cyan]") + modal.write(f" Name: [green]{src_server.name}[/green]") + modal.write(f" Icon: [green]{'Present' if src_server.icon else 'None'}[/green]") + modal.write(f" Roles: [green]{len(src_server.roles)}[/green]") + modal.write(f" Emojis: [green]{len(src_server.emojis)}[/green]") + modal.write(f" Channels: [green]{len(src_server.channels)}[/green]") + modal.write("") + modal.write(f"[bold cyan]Target Community:[/bold cyan] [green]{tgt_server_name}[/green]\n") + if "roles" in preview: roles = preview["roles"] modal.write(f"[bold cyan]Roles to be Cloned ({len(roles)}):[/bold cyan]") @@ -459,6 +479,33 @@ class ShuttlePane(Container): self.app.push_screen(modal) await asyncio.sleep(0.1) try: + # Connect early to get metadata + modal.set_status("Connecting to Source and Target Servers for Preview...") + connections_started = False + try: + await self.engine.start_connections() + connections_started = True + except Exception as e: + logger.warning(f"Could not pre-connect for Sync preview: {e}") + + if connections_started: + src_server = getattr(self.engine.discord_reader, 'current_server', None) + tgt_server_info = await self.engine.writer.validate() + tgt_server_name = tgt_server_info.get("community_name", "target community") + + if src_server: + modal.write(f"[bold cyan]Source Server Profile:[/bold cyan]") + modal.write(f" Name: [green]{src_server.name}[/green]") + modal.write(f" Icon: [green]{'Present' if src_server.icon else 'None'}[/green]") + modal.write(f" Roles: [green]{len(src_server.roles)}[/green]") + modal.write(f" Emojis: [green]{len(src_server.emojis)}[/green]") + modal.write(f" Channels: [green]{len(src_server.channels)}[/green]") + modal.write("") + modal.write(f"[bold cyan]Target Community:[/bold cyan] [green]{tgt_server_name}[/green]\n") + + # Show info container + modal.show_info("[bold yellow]Sync Ready[/bold yellow]", "Comparing server configurations...") + modal.set_status("Awaiting Confirmation to Sync Server Settings...") choice = await modal.phase_wait_confirm( btn_start_label="Start Syncing", @@ -478,7 +525,8 @@ class ShuttlePane(Container): modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) modal.phase_progress() modal.set_status("Syncing Server Settings") - await self.engine.start_connections() + if not connections_started: + await self.engine.start_connections() self.engine.is_running = True results = {} @@ -690,6 +738,9 @@ class ShuttlePane(Container): await asyncio.sleep(0.1) try: + # Show info container + modal.show_info("[bold cyan]Message Migration Ready[/bold cyan]", "Checking channel permissions...") + modal.set_status("Fetching channels...") await self.engine.start_connections() @@ -752,6 +803,18 @@ class ShuttlePane(Container): modal = ProgressScreen(log_level=self.config.migration.log_level) self.app.push_screen(modal) await asyncio.sleep(0.1) + + src_server = getattr(self.engine.discord_reader, 'current_server', None) + tgt_server_info = await self.engine.writer.validate() + tgt_server_name = tgt_server_info.get("community_name", "target community") + + if src_server: + modal.write(f"[bold cyan]Source Server Profile:[/bold cyan]") + modal.write(f" Name: [green]{src_server.name}[/green]") + modal.write(f" Icon: [green]{'Present' if src_server.icon else 'None'}[/green]") + modal.write("") + modal.write(f"[bold cyan]Target Community:[/bold cyan] [green]{tgt_server_name}[/green]\n") + modal.set_status("Analyzing channel...") modal.show_stats() @@ -942,9 +1005,19 @@ class ShuttlePane(Container): except Exception as e: logger.warning(f"Could not pre-connect for DZ preview: {e}") + if target_started: + tgt_server_info = await self.engine.writer.validate() + tgt_server_name = tgt_server_info.get("community_name", "target community") + modal.write(f"[bold red]Target Community:[/bold red] [green]{tgt_server_name}[/green]") + modal.write(f"[bold red]WARNING: THE ACTIONS BELOW WILL DELETE DATA PERMANENTLY IN: {tgt_server_name}![/bold red]") + modal.write("") + else: + modal.write("[bold red]WARNING: THIS WILL DELETE DATA PERMANENTLY! MUST PROCEED TO CONTINUE.[/bold red]") + modal.write("") + + # Show info container + modal.show_info("[bold red]Danger Zone Ready[/bold red]", "Fetching target entities...") modal.set_status(f"Awaiting Confirmation for {len(selections)} Destructive Operations...") - modal.write("[bold red]WARNING: THIS WILL DELETE DATA PERMANENTLY! MUST PROCEED TO CONTINUE.[/bold red]") - modal.write("") # Fetch and display live item names from target server preview = await self._fetch_dz_preview(selections) if target_started else {}