complete backup structure refactor

This commit is contained in:
rambros 2026-03-05 17:12:26 +05:30
parent bed2668ad2
commit a30d02eb2e
7 changed files with 216 additions and 39 deletions

View file

@ -289,7 +289,7 @@ Reconstructing the hierarchy requires specific pointer logic:
1. **Forums**: 1. **Forums**:
- Read `message_backup/{forum_id}/messages.json`. - 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`. - 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`. - To load the full thread, open `message_backup/{forum_id}/{thread_id}/thread_messages.json`.
2. **Regular Threads**: 2. **Regular Threads**:

View file

@ -501,7 +501,6 @@ class BackupMessage:
"Default": MessageType.default, "Default": MessageType.default,
"Reply": MessageType.reply, "Reply": MessageType.reply,
"ThreadStarter": MessageType.thread_starter_message, "ThreadStarter": MessageType.thread_starter_message,
"Thread_starter_message": MessageType.thread_starter_message,
"Forward": MessageType.default, "Forward": MessageType.default,
} }

View file

@ -78,15 +78,17 @@ class MigrationContext:
@staticmethod @staticmethod
def _find_backup_path(server_id: str) -> Path: 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}"): for d in Path(".").rglob(f"DISCORD_BACKUP-{server_id}"):
if d.is_dir(): if d.is_dir():
logger.info(f"Found backup directory: {d}") logger.info(f"Found backup directory: {d}")
return d return d
raise FileNotFoundError(
f"No backup found for server {server_id}. " # If not found, create it in the current directory
f"Expected a directory named DISCORD_BACKUP-{server_id}" 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]: async def validate_all(self) -> Dict[str, Any]:
"""Returns connection validation status as a dictionary.""" """Returns connection validation status as a dictionary."""

View file

@ -612,9 +612,12 @@ class DiscordExporter:
}) })
# Determine message type (Override if it's a thread starter or forward) # Determine message type (Override if it's a thread starter or forward)
msg_type = str(msg.type).split(".")[-1].capitalize() raw_repr = str(msg.type).lower()
if msg.thread:
if "thread_starter" in raw_repr or msg.thread:
msg_type = "ThreadStarter" msg_type = "ThreadStarter"
else:
msg_type = raw_repr.split(".")[-1].capitalize()
# Check for forwarded flags (newer discord.py feature) # Check for forwarded flags (newer discord.py feature)
try: try:
@ -733,7 +736,7 @@ class DiscordExporter:
"../../users/avatars" # Two levels up from {forum_id}/{thread_id}/ "../../users/avatars" # Two levels up from {forum_id}/{thread_id}/
) )
# Override type and add title for forum starter messages # Override type and add title for forum starter messages
msg_data["type"] = "Thread_starter_message" msg_data["type"] = "ThreadStarter"
msg_data["title"] = thread.name msg_data["title"] = thread.name
# Store applied tag IDs (as strings) — names are resolvable via the forum's available_tags # Store applied tag IDs (as strings) — names are resolvable via the forum's available_tags

View file

@ -14,7 +14,7 @@ from datetime import datetime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from textual.app import ComposeResult 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.widgets import Button, Label, Rule
from textual import work from textual import work
@ -30,8 +30,14 @@ class BackupPane(Container):
DEFAULT_CSS = """ DEFAULT_CSS = """
BackupPane { height: auto; width: 100%; } BackupPane { height: auto; width: 100%; }
BackupPane #bp_info { 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 { height: auto; }
BackupPane #bp_actions Button { width: 100%; margin-bottom: 1; } BackupPane #bp_actions Button { width: 100%; margin-bottom: 1; }
""" """
@ -47,9 +53,17 @@ class BackupPane(Container):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with VerticalScroll(): with VerticalScroll():
with Vertical(id="bp_info"): with Vertical(id="bp_info"):
yield Label("Loading...", id="bp_lbl_server") with Horizontal(id="bp_info_split"):
yield Label("", id="bp_lbl_bot") with Vertical(classes="info_pane"):
yield Label("", id="bp_lbl_backup") 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"): with Vertical(id="bp_actions"):
yield Button("Backup Server Profile", id="bp_backup_profile", disabled=True) yield Button("Backup Server Profile", id="bp_backup_profile", disabled=True)
yield Button("Backup Channel Messages", id="bp_backup_msgs", disabled=True, variant="primary") yield Button("Backup Channel Messages", id="bp_backup_msgs", disabled=True, variant="primary")
@ -105,11 +119,18 @@ class BackupPane(Container):
return None return None
def _update_ui(self, server_text, bot_text, backup_text, enabled): def _update_ui(self, server_text, bot_text, backup_text, enabled):
self.query_one("#bp_lbl_server", Label).update(f"Source Server: {server_text}") try:
self.query_one("#bp_lbl_bot", Label).update(f"Bot: {bot_text}") self.query_one("#bp_lbl_server", Label).update(f"Server: {server_text}")
self.query_one("#bp_lbl_backup", Label).update(backup_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"): for bid in ("#bp_backup_profile", "#bp_backup_msgs", "#bp_backup_sync"):
self.query_one(bid, Button).disabled = not enabled self.query_one(bid, Button).disabled = not enabled
except Exception:
pass
# ── button routing ──────────────────────────────────────────────────── # ── button routing ────────────────────────────────────────────────────
@ -136,6 +157,35 @@ class BackupPane(Container):
await self.engine.discord_reader.start() await self.engine.discord_reader.start()
await self.exporter.setup() 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]") modal.write("[yellow]Backing up server profile & skeleton...[/yellow]")
await self.exporter.export_metadata() await self.exporter.export_metadata()
await self.exporter.download_server_assets() await self.exporter.download_server_assets()
@ -227,17 +277,29 @@ class BackupPane(Container):
self.app.push_screen(modal_prog) self.app.push_screen(modal_prog)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
msg = "Backup Channels" if not force_overwrite else "Overwriting existing backups" new_channels = [c for c in selected_channels if c.id not in backed_up_ids]
target_preview = ", ".join([c.name for c in selected_channels[:3]]) existing_channels = [c for c in selected_channels if c.id in backed_up_ids]
if len(selected_channels) > 3:
target_preview += "..." 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.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 # Show categorized channel lists in the bottom log
modal_prog.write("[bold]Target Channels:[/bold]") if new_channels:
for idx, c in enumerate(selected_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}") modal_prog.write(f" {idx+1}. #{c.name}")
choice = await modal_prog.phase_wait_confirm(btn_start_label="Start Channel Backup", show_id=False) choice = await modal_prog.phase_wait_confirm(btn_start_label="Start Channel Backup", show_id=False)
@ -328,6 +390,36 @@ class BackupPane(Container):
await self.engine.discord_reader.start() await self.engine.discord_reader.start()
await self.exporter.setup() 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...") modal_prog.write("Updating structure...")
await self.exporter.export_metadata() await self.exporter.export_metadata()
await self.exporter.download_server_assets() await self.exporter.download_server_assets()

View file

@ -67,8 +67,8 @@ class ProgressScreen(Screen[None]):
#prog_bar { margin-bottom: 1; width: 80%; } #prog_bar { margin-bottom: 1; width: 80%; }
#prog_item_status { margin-bottom: 1; text-style: bold; color: cyan; width: 100%; text-align: center; } #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_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: $secondary-lighten-2; } .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; } #prog_actions { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; layout: vertical; }
.action_row { height: auto; layout: horizontal; } .action_row { height: auto; layout: horizontal; }
@ -91,8 +91,8 @@ class ProgressScreen(Screen[None]):
with Vertical(id="info_container"): with Vertical(id="info_container"):
yield Label("No previous migration data.", id="info_migration_status", classes="info_label") yield Label("", id="info_migration_status", classes="info_label")
yield Label("New items detected.", id="info_new_items", classes="info_label") yield Label("", id="info_new_items", classes="info_label")
# Progress bar moved inside info container # Progress bar moved inside info container
with Center(id="prog_bar_container"): with Center(id="prog_bar_container"):
@ -258,6 +258,9 @@ class ProgressScreen(Screen[None]):
try: self.query_one("#prog_bar", ProgressBar).display = False try: self.query_one("#prog_bar", ProgressBar).display = False
except Exception: pass except Exception: pass
try: self.query_one("#prog_timer", Label).display = False
except Exception: pass
# Update button labels # Update button labels
try: self.query_one("#btn_start_first", Button).label = btn_start_label try: self.query_one("#btn_start_first", Button).label = btn_start_label
except Exception: pass except Exception: pass
@ -314,6 +317,11 @@ class ProgressScreen(Screen[None]):
try: self.query_one("#prog_loader", LoadingIndicator).display = True try: self.query_one("#prog_loader", LoadingIndicator).display = True
except Exception: pass 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 try: self.query_one("#info_migration_status", Label).display = False
except Exception: pass except Exception: pass

View file

@ -83,12 +83,13 @@ class ShuttlePane(Container):
DEFAULT_CSS = """ DEFAULT_CSS = """
ShuttlePane { height: auto; width: 100%; } ShuttlePane { height: auto; width: 100%; }
ShuttlePane #sp_info { 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 { width: 1fr; height: auto; }
.info_pane Label { width: 100%; } .info_pane Label { width: 100%; }
.pane_header { text-style: bold; color: $accent; margin-bottom: 1; } .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 { height: auto; }
ShuttlePane #sp_actions Button { width: 100%; margin-bottom: 1; } ShuttlePane #sp_actions Button { width: 100%; margin-bottom: 1; }
@ -340,6 +341,7 @@ class ShuttlePane(Container):
self.run_batch_sync(choices) self.run_batch_sync(choices)
self.app.push_screen(OptionSelectModal("Sync Server Settings", options), on_result) self.app.push_screen(OptionSelectModal("Sync Server Settings", options), on_result)
# ── batch workers ────────────────────────────────────────────────── # ── batch workers ──────────────────────────────────────────────────
@work(exclusive=True) @work(exclusive=True)
@ -357,11 +359,29 @@ class ShuttlePane(Container):
except Exception as e: except Exception as e:
logger.warning(f"Could not pre-connect for Clone preview: {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...") modal.set_status(f"Awaiting Confirmation for {len(selections)} Operations...")
# Fetch and display live preview with presence highlighting # Fetch and display live preview with presence highlighting
preview = await self._fetch_clone_preview(selections) if connections_started else {} 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: if "roles" in preview:
roles = preview["roles"] roles = preview["roles"]
modal.write(f"[bold cyan]Roles to be Cloned ({len(roles)}):[/bold cyan]") 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) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
try: 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...") modal.set_status("Awaiting Confirmation to Sync Server Settings...")
choice = await modal.phase_wait_confirm( choice = await modal.phase_wait_confirm(
btn_start_label="Start Syncing", btn_start_label="Start Syncing",
@ -478,6 +525,7 @@ class ShuttlePane(Container):
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
modal.phase_progress() modal.phase_progress()
modal.set_status("Syncing Server Settings") modal.set_status("Syncing Server Settings")
if not connections_started:
await self.engine.start_connections() await self.engine.start_connections()
self.engine.is_running = True self.engine.is_running = True
@ -690,6 +738,9 @@ class ShuttlePane(Container):
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
try: try:
# Show info container
modal.show_info("[bold cyan]Message Migration Ready[/bold cyan]", "Checking channel permissions...")
modal.set_status("Fetching channels...") modal.set_status("Fetching channels...")
await self.engine.start_connections() await self.engine.start_connections()
@ -752,6 +803,18 @@ class ShuttlePane(Container):
modal = ProgressScreen(log_level=self.config.migration.log_level) modal = ProgressScreen(log_level=self.config.migration.log_level)
self.app.push_screen(modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) 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.set_status("Analyzing channel...")
modal.show_stats() modal.show_stats()
@ -942,10 +1005,20 @@ class ShuttlePane(Container):
except Exception as e: except Exception as e:
logger.warning(f"Could not pre-connect for DZ preview: {e}") logger.warning(f"Could not pre-connect for DZ preview: {e}")
modal.set_status(f"Awaiting Confirmation for {len(selections)} Destructive Operations...") 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("[bold red]WARNING: THIS WILL DELETE DATA PERMANENTLY! MUST PROCEED TO CONTINUE.[/bold red]")
modal.write("") 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...")
# Fetch and display live item names from target server # Fetch and display live item names from target server
preview = await self._fetch_dz_preview(selections) if target_started else {} preview = await self._fetch_dz_preview(selections) if target_started else {}