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**:
- 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**:

View file

@ -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,
}

View file

@ -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."""

View file

@ -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

View file

@ -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)
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,17 +277,29 @@ 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):
# 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)
@ -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()

View file

@ -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"):
@ -258,6 +258,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
except Exception: pass
@ -314,6 +317,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

View file

@ -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,6 +525,7 @@ class ShuttlePane(Container):
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
modal.phase_progress()
modal.set_status("Syncing Server Settings")
if not connections_started:
await self.engine.start_connections()
self.engine.is_running = True
@ -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,10 +1005,20 @@ class ShuttlePane(Container):
except Exception as 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("")
# 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
preview = await self._fetch_dz_preview(selections) if target_started else {}