dedupicate UI components for shuttle and backup panes

This commit is contained in:
rambros 2026-03-12 19:29:56 +05:30
parent 0c97d14a2b
commit 975369c784
7 changed files with 730 additions and 761 deletions

View file

@ -97,16 +97,25 @@ class DiscordReader:
"server": False,
"bot_name": None,
"server_name": None,
"error_reason": None,
"intents": {"message_content": False},
"permissions": {"view_channel": False, "read_message_history": False}
}
temp_client = self._create_client()
try:
try:
await temp_client.login(self.token)
results["token"] = True
if temp_client.user:
results["bot_name"] = temp_client.user.display_name
except discord.LoginFailure:
results["error_reason"] = "Invalid Discord Token"
return results
except Exception as e:
results["error_reason"] = f"Login Error: {str(e)}"
return results
try:
guild = await temp_client.fetch_guild(self.server_id)
if guild is not None:
results["server"] = True
@ -116,17 +125,22 @@ class DiscordReader:
results["intents"]["message_content"] = temp_client.intents.message_content
# Check permissions
# We need to fetch the member to check permissions
try:
member = await guild.fetch_member(temp_client.user.id)
perms = member.guild_permissions
results["permissions"]["view_channel"] = perms.view_channel
results["permissions"]["read_message_history"] = perms.read_message_history
except Exception:
except Exception as e:
logger.debug(f"Member fetch failed: {e}")
# Fallback if member fetch fails, though it shouldn't for the bot itself
pass
except Exception:
pass
except discord.Forbidden:
results["error_reason"] = "Missing Access to Server"
except discord.NotFound:
results["error_reason"] = "Server Not Found"
except Exception as e:
results["error_reason"] = f"Server Error: {str(e)}"
except Exception as e:
results["error_reason"] = str(e)
finally:
if not temp_client.is_closed():
await temp_client.close()

View file

@ -105,6 +105,7 @@ class FluxerWriter:
is_community_valid = False
bot_name = None
community_name = None
error_reason = None
permissions = {
"manage_channels": False,
"manage_messages": False,
@ -116,6 +117,7 @@ class FluxerWriter:
try:
# Check token by fetching me
me_id = None
try:
if self.bot and self.bot.user:
is_token_valid = True
bot_name = self.bot.user.username
@ -126,8 +128,19 @@ class FluxerWriter:
is_token_valid = True
bot_name = me.get("username")
me_id = int(me["id"])
except Exception as e:
error_reason = f"Token Error: {str(e)}"
return {
"token": False,
"community": False,
"bot_name": None,
"community_name": None,
"error_reason": error_reason,
"permissions": permissions
}
# Check community
try:
guild_data = await self.client.get_guild(self.community_id)
if guild_data:
is_community_valid = True
@ -157,16 +170,7 @@ class FluxerWriter:
if r_id == fluxer_guild_id or r_id in member_role_ids:
total_perms |= int(r_data.get("permissions", 0))
# Debugging
# print(f"DEBUG: me_id={me_id}, roles={member_role_ids}, total_perms={total_perms}")
# Bitmask Mapping (Discord standard)
# Administrator: 1 << 3
# Manage Channels: 1 << 4
# Manage Messages: 1 << 13
# Manage Roles: 1 << 28
# Manage Webhooks: 1 << 29
# Manage Emojis/Stickers: 1 << 30
is_admin = bool(total_perms & (1 << 3))
permissions["manage_channels"] = is_admin or bool(total_perms & (1 << 4))
@ -175,18 +179,22 @@ class FluxerWriter:
permissions["manage_webhooks"] = is_admin or bool(total_perms & (1 << 29))
permissions["manage_emojis_stickers"] = is_admin or bool(total_perms & (1 << 30))
if is_admin:
logger.info(f"Fluxer bot {bot_name} has Administrator permission.")
except Exception as e:
logger.error(f"Failed to calculate Fluxer permissions: {e}")
except Exception:
pass
error_reason = f"Permission error: {str(e)}"
else:
error_reason = "Community not found"
except Exception as e:
error_reason = f"Community Error: {str(e)}"
except Exception as e:
error_reason = str(e)
return {
"token": is_token_valid,
"community": is_community_valid,
"bot_name": bot_name,
"community_name": community_name,
"error_reason": error_reason,
"permissions": permissions
}

View file

@ -116,6 +116,7 @@ class StoatWriter:
"community": False,
"bot_name": "N/A",
"community_name": "N/A",
"error_reason": None,
"permissions": {
"manage_channels": False,
"manage_server": False,
@ -143,7 +144,10 @@ class StoatWriter:
results["token"] = True
results["bot_name"] = current_user.display_name or current_user.name
except stoat.Unauthorized:
logger.error("Invalid Stoat token.")
results["error_reason"] = "Invalid Stoat Token"
return results
except Exception as e:
results["error_reason"] = f"Token Error: {str(e)}"
return results
# Validate server access
@ -172,21 +176,20 @@ class StoatWriter:
"mention_roles": perms.mention_roles
}
except stoat.NotFound:
logger.error(f"Bot member {current_user.id} not found in Stoat server {self.community_id}.")
results["error_reason"] = "Bot not member of server"
except stoat.Forbidden:
logger.error(f"Bot lacks permissions to fetch its own member data in Stoat server {self.community_id}.")
results["error_reason"] = "Missing member data access"
except Exception as e:
logger.error(f"Error fetching Stoat member permissions: {e}")
results["error_reason"] = f"Permission error: {str(e)}"
except stoat.NotFound:
logger.error(f"Stoat server {self.community_id} not found.")
results["error_reason"] = "Server not found"
except stoat.Forbidden:
logger.error(f"Bot has no access to Stoat server {self.community_id}.")
results["error_reason"] = "Missing access to server"
except Exception as e:
logger.error(f"Error validating Stoat server: {e}")
results["error_reason"] = f"Server error: {str(e)}"
except Exception as e:
logger.error(f"Stoat validation failed: {str(e)}")
results["error_reason"] = f"Stoat validation failed: {str(e)}"
self._validation_cache = results
return results

View file

@ -1,553 +0,0 @@
"""
BackupPane self-contained backup-operations widget.
Embedded inside ModeScreen's "Backup" tab.
"""
import asyncio
import json
import re
import logging
import traceback
from pathlib import Path
from datetime import datetime
from typing import Any, Optional, Union, List, Dict, Callable
logger = logging.getLogger(__name__)
from textual.app import ComposeResult
from textual.containers import Container, Vertical, VerticalScroll, Horizontal
from textual.widgets import Button, Label, Rule
from textual import work
from src.core.configuration import load_config
from src.core.base import MigrationContext
from src.core.exporter import DiscordExporter
from src.ui.modals import ProgressScreen, ChannelSelectScreen, MessageIDInputModal
class BackupPane(Container):
"""Backup operations pane — profile, messages, sync."""
DEFAULT_CSS = """
BackupPane { height: auto; width: 100%; }
BackupPane #bp_info {
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; }
.pane_status { text-style: bold; margin-top: 1; }
#bp_info_split Rule { height: 100%; margin: 0 2; color: $accent; }
#bp_lbl_backup { display: none; }
BackupPane #bp_actions { height: auto; }
BackupPane #bp_actions Button { width: 100%; margin-bottom: 1; }
"""
def __init__(self, cfg_name: str, cfg_path: Path, *args, **kwargs):
super().__init__(*args, **kwargs)
self.cfg_name = cfg_name
self.config_path = cfg_path
self.config = load_config(cfg_path)
self.engine = MigrationContext(self.config, target_platform=self.config.target_platform or "fluxer", base_dir=f"ReaperFiles-{self.cfg_name}")
self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=f"ReaperFiles-{self.cfg_name}")
def compose(self) -> ComposeResult:
with VerticalScroll():
with Vertical(id="bp_info"):
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")
yield Label("Status: -", id="bp_lbl_d_status", classes="pane_status")
yield Rule(orientation="vertical", id="bp_vrule")
with Vertical(classes="info_pane", id="bp_target_pane"):
yield Label("Target", classes="pane_header")
yield Label("Community: -", id="bp_lbl_t_comm")
yield Label("Bot: -", id="bp_lbl_t_bot")
yield Label("Status: -", id="bp_lbl_t_status", classes="pane_status")
yield Label("", id="bp_lbl_backup")
with Vertical(id="bp_actions"):
yield Button("Backup Server Profile", id="bp_backup_profile", disabled=True, tooltip="Backup Discord server roles, emojis, and channel structure")
yield Button("Backup Channel Messages", id="bp_backup_msgs", disabled=True, variant="primary", tooltip="Select and backup message history from text channels")
yield Button("Update Existing Backup", id="bp_backup_sync", disabled=True, variant="success", tooltip="Scan for new messages\n& Update existing backup")
yield Rule(id="bp_backup_stats_rule")
yield Button("Backup Stats", id="bp_backup_stats", variant="warning", flat=True,disabled=True, tooltip="View detailed statistics, storage, and entity metrics for the current backup profile")
def on_mount(self) -> None:
self._validate()
def on_show(self) -> None:
"""Re-validate when the pane regains visibility (e.g. after a backup operation finishes)."""
self._validate()
def reload_config(self) -> None:
self.config = load_config(self.config_path)
self.engine = MigrationContext(self.config, target_platform=self.config.target_platform or "fluxer", base_dir=f"ReaperFiles-{self.cfg_name}")
self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=f"ReaperFiles-{self.cfg_name}")
self._validate()
# ── validation ────────────────────────────────────────────────────────
@work(exclusive=True)
async def _validate(self) -> None:
fillers = ["DISCORD_BOT_TOKEN", "000000000000000000", "DISCORD_SERVER_ID", "", None]
d_token = self.config.discord_bot_token
if d_token in fillers or self.config.discord_server_id in fillers:
self._update_ui("[red]NOT CONFIGURED[/red]", "", "", False)
return
try:
res = await self.engine.discord_reader.validate()
valid = res.get("token", False) and res.get("server", False)
server_name = res.get("server_name", "Unknown")
bot_name = res.get("bot_name", "Unknown")
s_text = f'[green]"{server_name}"[/green]' if valid else "[red]INVALID[/red]"
b_text = f"[green]{bot_name}[/green]" if valid else "[red]INVALID[/red]"
backup_text = ""
info = self._get_backup_info()
has_backup = False
if info:
backup_text = f"Last backup: [cyan]{info}[/cyan]"
has_backup = True
self._update_ui(s_text, b_text, backup_text, valid, has_backup)
except Exception as e:
self._update_ui(f"[red]Error: {e}[/red]", "", "", False, False)
def _get_backup_info(self) -> str | None:
if not self.config or not self.config.discord_server_id:
return None
target_dir = Path(f"ReaperFiles-{self.cfg_name}") / f"DISCORD_BACKUP-{self.config.discord_server_id}"
if not target_dir.exists():
return None
profile_file = target_dir / "server_profile" / "profile.json"
if not profile_file.exists():
return None
try:
with open(profile_file, "r", encoding="utf-8") as f:
data = json.load(f)
ts_str = data.get("last_backup")
if ts_str:
dt = datetime.fromisoformat(ts_str)
return dt.strftime("%d-%b-%Y %H:%M")
except Exception:
pass
return None
def _update_ui(self, server_text, bot_text, backup_text, enabled, has_backup: bool = False):
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}")
# Status for Discord side
d_status = "[green][VALID][/green]" if enabled else "[red][INVALID][/red]"
self.query_one("#bp_lbl_d_status", Label).update(f"Status: {d_status}")
# Legacy label for safety if called elsewhere
self.query_one("#bp_lbl_backup", Label).update(f"Status: {backup_text}")
# Hide target side in backup mode completely
self.query_one("#bp_vrule").display = False
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
self.query_one("#bp_backup_stats", Button).display = has_backup
self.query_one("#bp_backup_stats", Button).disabled = not has_backup
self.query_one("#bp_backup_stats_rule", Rule).display = has_backup
except Exception:
pass
# ── button routing ────────────────────────────────────────────────────
def on_button_pressed(self, event: Button.Pressed) -> None:
bid = event.button.id
if bid == "bp_backup_profile":
self.run_backup_profile()
elif bid == "bp_backup_msgs":
self.run_backup_messages()
elif bid == "bp_backup_sync":
self.run_backup_sync()
elif bid == "bp_backup_stats":
from src.ui.backup_stats import BackupStatsScreen
target_dir = Path(f"ReaperFiles-{self.cfg_name}") / f"DISCORD_BACKUP-{self.config.discord_server_id}"
self.app.push_screen(BackupStatsScreen(self.cfg_name, target_dir))
# ── workers ───────────────────────────────────────────────────────────
@work(exclusive=True)
async def run_backup_profile(self) -> None:
modal = ProgressScreen(log_level=self.config.log_level)
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.phase_progress()
try:
modal.set_status("Starting readers...")
await self.engine.discord_reader.start()
await self.exporter.setup()
# Gather and print summary
server = getattr(self.engine.discord_reader, 'guild', 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(getattr(server, 'roles', []))}[/green]")
modal.write(f" Emojis: [green]{len(getattr(server, 'emojis', []))}[/green]")
modal.write(f" Channels: [green]{len(getattr(server, 'channels', []))}[/green]")
modal.write("")
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()
modal.write("Exporting structure...")
_, cat_count, chan_count = await self.exporter.export_channels_structure()
modal.write("Exporting roles...")
roles = await self.exporter.export_roles()
modal.write("Exporting assets...")
e_count, s_count = await self.exporter.export_assets()
modal.write(f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]")
modal.write(f"- {len(roles)} roles, {e_count} emojis, {s_count} stickers.")
modal.phase_report("Profile Backup", show_back=False)
except self.engine.discord_reader.Forbidden as e:
modal.write(f"[bold red]Backup failed: {e}[/bold red]")
modal.phase_report("Profile Backup", "error")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
modal.phase_report("Profile Backup", "error")
finally:
await self.engine.close_connections()
self._validate()
@work(exclusive=True)
async def run_backup_messages(self) -> None:
modal_prog = ProgressScreen(log_level=self.config.log_level)
self.app.push_screen(modal_prog)
await asyncio.sleep(0.1)
try:
modal_prog.set_status("Fetching channels...")
await self.engine.discord_reader.start()
await self.exporter.setup()
await self.exporter.export_channels_structure()
all_channels = await self.engine.discord_reader.get_channels()
all_categories = await self.engine.discord_reader.get_categories()
cat_map = {c.id: c.name for c in all_categories}
eligible_channels = [
c for c in all_channels
if c.type in [
self.engine.discord_reader.CHANNEL_TYPE_TEXT,
self.engine.discord_reader.CHANNEL_TYPE_NEWS,
self.engine.discord_reader.CHANNEL_TYPE_FORUM
]
]
if not eligible_channels:
modal_prog.write("[yellow]No text/news channels found to backup.[/yellow]")
modal_prog.allow_close()
return
any_found = False
backed_up_ids = set()
for chan in eligible_channels:
if (self.exporter.export_path / "message_backup" / str(chan.id) / "messages.json").exists():
any_found = True
backed_up_ids.add(chan.id)
self.app.pop_screen()
while True:
loop = asyncio.get_running_loop()
future = loop.create_future()
def check_channels(reply: dict | None) -> None:
if not future.done():
future.set_result(reply)
self.app.push_screen(
ChannelSelectScreen(eligible_channels, cat_map, backed_up_ids, any_found),
check_channels,
)
reply = await future
if not reply:
return
selected_ids = reply["channels"]
force_overwrite = reply["force"]
selected_channels = [c for c in eligible_channels if c.id in selected_ids]
# Phase 2: Confirmation
modal_prog = ProgressScreen(log_level=self.config.log_level) # Re-instantiate to avoid Textual re-push UI freeze
self.app.push_screen(modal_prog)
await asyncio.sleep(0.1)
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, 'guild', 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]Backup Channels[/cyan]", f"{len(new_channels)} new, {len(existing_channels)} existing")
# 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":
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
# 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()
modal_prog.show_stats()
# Reset running flag and set cancel callback
self.exporter.is_running = True
modal_prog.cancel_callback = lambda: setattr(self.exporter, "is_running", False)
total_chans = len(selected_channels)
modal_prog.set_status("Backing up messages...")
modal_prog.write(f"[yellow]Starting backup for {total_chans} channels...[/yellow]")
accumulated_msgs = 0
for i, chan in enumerate(selected_channels):
if not self.exporter.is_running:
modal_prog.write("[bold red]Backup cancelled by user.[/bold red]")
break
await asyncio.sleep(0.01) # Yield to UI thread to keep it responsive
backup_exists = (self.exporter.export_path / "message_backup" / str(chan.id) / "messages.json").exists()
is_sync = backup_exists and not force_overwrite
label = "Syncing Backup" if is_sync else "Backing up"
modal_prog.set_item_status(f"[cyan]Processing ({i+1}/{total_chans}): #{chan.name}[/cyan]")
modal_prog.set_progress(i, total_chans)
modal_prog.write(f"[cyan]{label}: {chan.name}[/cyan]")
logger.info(f"{label} for channel: #{chan.name} ({chan.id})")
_msg_log_counter = 0
async def update_msg_count(name, count, author_name=None, message_preview=None):
nonlocal _msg_log_counter
modal_prog.update_stats(messages=str(count))
_msg_log_counter += 1
if author_name and message_preview and _msg_log_counter % 10 == 0:
modal_prog.write(f"[bold]{author_name}:[/bold] {message_preview}")
accumulated_msgs = await self.exporter.export_channel_messages(
chan.id, progress_callback=update_msg_count, force=force_overwrite,
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,
accumulated_count=accumulated_msgs
)
modal_prog.write(f"[green]Completed: {chan.name}[/green]")
if not self.exporter.is_running:
modal_prog.set_item_status("[bold red]Backup Cancelled.[/bold red]")
modal_prog.phase_report("Message Backup", "stopped", show_back=False)
return
modal_prog.set_progress(total_chans, total_chans)
modal_prog.set_item_status("[bold green]Backup completed successfully![/bold green]")
await self.exporter.export_metadata()
modal_prog.write("[bold green]Message backup complete![/bold green]")
logger.info("Message backup operation completed successfully.")
modal_prog.phase_report("Message Backup", show_back=False)
except Exception as e:
logger.error(f"Message backup failed: {e}\n{traceback.format_exc()}")
modal_prog.write(f"[bold red]Message backup failed: {e}[/bold red]")
modal_prog.phase_report("Message Backup", "error", show_back=False)
finally:
await self.engine.close_connections()
self._validate()
@work(exclusive=True)
async def run_backup_sync(self) -> None:
modal_prog = ProgressScreen(log_level=self.config.log_level)
self.app.push_screen(modal_prog)
await asyncio.sleep(0.1)
modal_prog.phase_progress()
try:
modal_prog.set_status("Starting sync...")
await self.engine.discord_reader.start()
await self.exporter.setup()
# Gather and print summary
server = getattr(self.engine.discord_reader, 'guild', 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(getattr(server, 'roles', []))}[/green]")
modal_prog.write(f" Emojis: [green]{len(getattr(server, 'emojis', []))}[/green]")
modal_prog.write(f" Channels: [green]{len(getattr(server, 'channels', []))}[/green]")
modal_prog.write("\n[dim]This operation will update the profile and scan existing backed-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()
await self.exporter.export_channels_structure()
await self.exporter.export_assets()
all_channels = await self.engine.discord_reader.get_channels()
eligible_channels = [
c for c in all_channels
if c.type in [
self.engine.discord_reader.CHANNEL_TYPE_TEXT,
self.engine.discord_reader.CHANNEL_TYPE_NEWS,
self.engine.discord_reader.CHANNEL_TYPE_FORUM
]
]
selected_channels = [
c for c in eligible_channels
if (self.exporter.export_path / "message_backup" / str(c.id) / "messages.json").exists()
]
if not selected_channels:
modal_prog.write("[yellow]No existing backups found to sync.[/yellow]")
else:
total_chans = len(selected_channels)
modal_prog.show_stats()
modal_prog.set_status("Syncing messages...")
modal_prog.write(f"[yellow]Syncing {total_chans} channels...[/yellow]")
# Reset running flag and set cancel callback
self.exporter.is_running = True
modal_prog.cancel_callback = lambda: setattr(self.exporter, "is_running", False)
accumulated_msgs = 0
for i, chan in enumerate(selected_channels):
if not self.exporter.is_running:
modal_prog.write("[bold red]Sync cancelled by user.[/bold red]")
break
await asyncio.sleep(0.01) # Yield to UI thread
modal_prog.set_item_status(f"[cyan]Syncing ({i+1}/{total_chans}): #{chan.name}[/cyan]")
modal_prog.set_progress(i, total_chans)
modal_prog.write(f"[cyan]Syncing: {chan.name}[/cyan]")
logger.info(f"Syncing backup for channel: #{chan.name} ({chan.id})")
_msg_log_counter = 0
async def update_msg_count(name, count, author_name=None, message_preview=None):
nonlocal _msg_log_counter
modal_prog.update_stats(messages=str(count))
_msg_log_counter += 1
if author_name and message_preview and _msg_log_counter % 10 == 0:
modal_prog.write(f"[bold]{author_name}:[/bold] {message_preview}")
accumulated_msgs = await self.exporter.export_channel_messages(
chan.id, progress_callback=update_msg_count, force=False,
accumulated_count=accumulated_msgs
)
accumulated_msgs = await self.exporter.export_threads(
chan.id, progress_callback=update_msg_count, force=False,
accumulated_count=accumulated_msgs
)
modal_prog.write(f"[green]Synced: {chan.name}[/green]")
if not self.exporter.is_running:
modal_prog.set_item_status("[bold red]Sync Cancelled.[/bold red]")
modal_prog.phase_report("Backup Sync", "stopped", show_back=False)
return
modal_prog.set_progress(total_chans, total_chans)
modal_prog.set_item_status("[bold green]Sync operation complete![/bold green]")
await self.exporter.export_metadata()
modal_prog.write("[bold green]Sync operation complete![/bold green]")
logger.info("Sync operation completed successfully.")
modal_prog.phase_report("Backup Sync", show_back=False)
except Exception as e:
logger.error(f"Sync failed: {e}\n{traceback.format_exc()}")
modal_prog.write(f"[bold red]Sync failed: {e}[/bold red]")
modal_prog.phase_report("Backup Sync", "error", show_back=False)
finally:
await self.engine.close_connections()
self._validate()

View file

@ -370,19 +370,27 @@ class ConfigScreen(Screen):
return
except Exception as e:
logger.error(f"Failed to fetch {platform} servers: {e}")
try:
self.query_one("#btn_fetch_target_servers", Button).variant = "warning"
self.query_one("#inp_target_server", Select).prompt = "Invalid token"
except Exception as dom_err:
logger.debug(f"Could not update target server UI elements: {dom_err}")
if not initial:
self.notify(f"Failed to fetch {platform} servers: {e}", severity="error")
return
if not servers:
try:
self.query_one("#btn_fetch_target_servers", Button).variant = "warning"
self.query_one("#inp_target_server", Select).prompt = "No servers found"
except Exception:
pass
if not initial:
self.notify(f"No {platform} servers found or invalid token.", severity="warning")
return
try:
self.query_one("#btn_fetch_target_servers", Button).variant = "success"
options = [(label, sid) for label, sid in servers]
select_widget = self.query_one("#inp_target_server", Select)
@ -393,6 +401,9 @@ class ConfigScreen(Screen):
saved_id = self.config.target_server_id
if saved_id and any(sid == saved_id for _, sid in servers):
select_widget.value = saved_id
except Exception as dom_err:
logger.debug(f"Could not update target server DOM: {dom_err}")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn_fetch_guilds":

View file

@ -14,8 +14,7 @@ from textual.widgets import Header, Footer, Button, ContentSwitcher, Rule
from textual.screen import Screen
from src.core.configuration import load_config
from src.ui.backup_ops import BackupPane
from src.ui.shuttle_ops import ShuttlePane
from src.ui.shuttle_ops import OperationPane
class ModeScreen(Screen):
@ -122,13 +121,13 @@ class ModeScreen(Screen):
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")
yield OperationPane(self.cfg_name, self.config_path, view_mode="backup", id="pane_backup")
elif mode == "direct_transfer":
yield ShuttlePane(self.cfg_name, self.config_path, id="pane_migrate")
yield OperationPane(self.cfg_name, self.config_path, view_mode="shuttle", id="pane_migrate")
else: # backup_transfer
with ContentSwitcher(initial="pane_backup", id="switcher"):
yield BackupPane(self.cfg_name, self.config_path, id="pane_backup")
yield ShuttlePane(self.cfg_name, self.config_path, id="pane_migrate")
yield OperationPane(self.cfg_name, self.config_path, view_mode="backup", id="pane_backup")
yield OperationPane(self.cfg_name, self.config_path, view_mode="shuttle", id="pane_migrate")
with Vertical(id="mode_footer"):
yield Rule(id="footer_rule")
@ -155,9 +154,7 @@ class ModeScreen(Screen):
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):
for pane in self.query(OperationPane):
pane.reload_config()
self.app.push_screen(ConfigScreen(self.cfg_name, self.config_path), reload_screen)
elif bid == "btn_switch":

View file

@ -1,6 +1,6 @@
"""
ShuttlePane self-contained shuttle (migration) operations widget.
Embedded inside ModeScreen's "Migrate" tab.
OperationPane unified operations widget for both Backup and Migration.
Handles Discord backups and multi-platform migrations.
"""
import asyncio
@ -20,8 +20,10 @@ from textual import work
from src.core.configuration import load_config
from src.core.base import MigrationContext
from src.core.audit import log_audit_event
from src.core.exporter import DiscordExporter
from src.ui.modals import (
ProgressScreen, SubMenuModal, ChannelPickerScreen, OptionSelectModal, MessageIDInputModal,
ChannelSelectScreen
)
import src.fluxer.roles_permissions as fluxer_roles
@ -78,67 +80,85 @@ class RateLimitHandler(logging.Handler):
pass
class ShuttlePane(Container):
"""Shuttle (migration) operations pane — clone, roles, emojis, metadata, messages, danger zone."""
class OperationPane(Container):
"""Unified operations pane — Backup, Clone, Sync, Migrate."""
DEFAULT_CSS = """
ShuttlePane { height: auto; width: 100%; }
ShuttlePane #sp_info {
OperationPane { height: auto; width: 100%; }
OperationPane #op_info {
height: auto; border: tall yellow; padding: 1; margin-bottom: 1; layout: vertical;
}
#sp_info_split { height: auto; layout: horizontal; width: 100%; margin-bottom: 1; }
#op_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; }
.pane_status { text-style: bold; margin-top: 1; }
#sp_info_split Rule { height: 100%; margin: 0 2; color: $accent; }
#sp_lbl_status { display: none; }
#op_info_split Rule { height: 100%; margin: 0 2; color: $accent; }
#op_lbl_backup { display: none; }
ShuttlePane #sp_actions { height: auto; }
ShuttlePane #sp_actions Button { width: 100%; margin-bottom: 1; }
OperationPane #op_actions { height: auto; }
OperationPane #op_actions Button { width: 100%; margin-bottom: 1; }
#footer_rule { margin: 0; }
"""
def __init__(self, cfg_name: str, cfg_path: Path, *args, **kwargs):
def __init__(self, cfg_name: str, cfg_path: Path, view_mode: str = "shuttle", *args, **kwargs):
super().__init__(*args, **kwargs)
self.cfg_name = cfg_name
self.config_path = cfg_path
self.view_mode = view_mode # "backup" or "shuttle" (migrate)
self.config = load_config(cfg_path)
self.target_platform = self.config.target_platform or "fluxer"
self.engine: MigrationContext | None = None
self.exporter: DiscordExporter | None = None
self.validation_results: dict = {}
self.tokens_valid = False
self.permissions_complete = False
self.has_backup = False
def compose(self) -> ComposeResult:
with VerticalScroll():
with Vertical(id="sp_info"):
with Horizontal(id="sp_info_split"):
with Vertical(id="op_info"):
with Horizontal(id="op_info_split"):
with Vertical(classes="info_pane"):
yield Label("Discord", classes="pane_header")
yield Label("Server: [yellow]Loading...[/yellow]", id="sp_lbl_d_server")
yield Label("Bot: [yellow]Loading...[/yellow]", id="sp_lbl_d_bot")
yield Label("Status: [yellow]Validating...[/yellow]", id="sp_lbl_d_status", classes="pane_status")
yield Label("Server: [yellow]Loading...[/yellow]", id="op_lbl_d_server")
if self.view_mode == "backup":
yield Label("Source: [yellow]Loading...[/yellow]", id="op_lbl_d_bot")
else:
yield Label("Bot: [yellow]Loading...[/yellow]", id="op_lbl_d_bot")
yield Label("Status: [yellow]Validating...[/yellow]", id="op_lbl_d_status", classes="pane_status")
yield Rule(orientation="vertical")
yield Rule(orientation="vertical", id="op_vrule")
with Vertical(classes="info_pane"):
yield Label("Target", id="sp_lbl_t_header", classes="pane_header")
yield Label("Community: [yellow]Loading...[/yellow]", id="sp_lbl_t_comm")
yield Label("Bot: [yellow]Loading...[/yellow]", id="sp_lbl_t_bot")
yield Label("Status: [yellow]Validating...[/yellow]", id="sp_lbl_t_status", classes="pane_status")
with Vertical(classes="info_pane", id="op_target_pane"):
yield Label("Target", id="op_lbl_t_header", classes="pane_header")
yield Label("Community: [yellow]Loading...[/yellow]", id="op_lbl_t_comm")
yield Label("Bot: [yellow]Loading...[/yellow]", id="op_lbl_t_bot")
yield Label("Status: [yellow]Validating...[/yellow]", id="op_lbl_t_status", classes="pane_status")
yield Label("", id="sp_lbl_status")
with Vertical(id="sp_actions"):
yield Button("Clone Server Template", id="sp_clone", disabled=True, tooltip="Clone server roles, categories, and channels to the target community")
yield Button("Sync Server Settings", id="sp_sync", disabled=True, tooltip="Sync emojis, stickers, server name, and icon to the target community")
yield Button("Migrate Message History", id="sp_messages", disabled=True, variant="primary", tooltip="Migrate message history from Discord to the target platform")
yield Label("", id="op_lbl_backup")
with Vertical(id="op_actions"):
if self.view_mode == "backup":
yield Button("Backup Server Profile", id="op_backup_profile", disabled=True, tooltip="Backup Discord server roles, emojis, and channel structure")
yield Button("Backup Channel Messages", id="op_backup_msgs", disabled=True, variant="primary", tooltip="Select and backup message history from text channels")
yield Button("Update Existing Backup", id="op_backup_sync", disabled=True, variant="success", tooltip="Scan for new messages\n& Update existing backup")
yield Rule(id="op_backup_stats_rule")
yield Button("Backup Stats", id="op_backup_stats", variant="warning", flat=True, disabled=True, tooltip="View detailed statistics, storage, and entity metrics for the current backup profile")
else:
yield Button("Clone Server Template", id="op_clone", disabled=True, tooltip="Clone server roles, categories, and channels to the target community")
yield Button("Sync Server Settings", id="op_sync", disabled=True, tooltip="Sync emojis, stickers, server name, and icon to the target community")
yield Button("Migrate Message History", id="op_messages", disabled=True, variant="primary", tooltip="Migrate message history from Discord to the target platform")
yield Rule(id="footer_rule")
yield Button("Danger Zone ⚠", id="sp_danger", variant="error", disabled=True, flat=True, tooltip="Dangerous operations:\ndelete channels, roles, emojis on target\n(use with caution)")
yield Button("Danger Zone ⚠", id="op_danger", variant="error", disabled=True, flat=True, tooltip="Dangerous operations:\ndelete channels, roles, emojis on target\n(use with caution)")
def on_mount(self) -> None:
self._rebuild_engine()
# run_validate is handled by the writer's internal caching now to prevent log flooding
self.run_validate()
def on_show(self) -> None:
"""Re-validate when the pane regains visibility."""
if self.view_mode == "backup" or self.config.tool_mode == "backup_transfer":
self.run_validate()
def reload_config(self) -> None:
@ -153,6 +173,32 @@ class ShuttlePane(Container):
def _rebuild_engine(self):
source = "backup" if self.config.tool_mode == "backup_transfer" else "live"
self.engine = MigrationContext(self.config, self.target_platform, source_mode=source, base_dir=self._base_dir())
if self.view_mode == "backup":
self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=self._base_dir())
def _get_backup_info(self) -> str | None:
import json
if not self.config or not self.config.discord_server_id:
return None
target_dir = Path(self._base_dir()) / f"DISCORD_BACKUP-{self.config.discord_server_id}"
if not target_dir.exists():
return None
profile_file = target_dir / "server_profile" / "profile.json"
if not profile_file.exists():
return None
try:
from datetime import datetime
with open(profile_file, "r", encoding="utf-8") as f:
data = json.load(f)
ts_str = data.get("last_backup")
if ts_str:
dt = datetime.fromisoformat(ts_str)
return dt.strftime("%d-%b-%Y %H:%M")
except Exception:
pass
return None
# ── labels ────────────────────────────────────────────────────────────
@ -169,34 +215,68 @@ class ShuttlePane(Container):
s_disp = f'[green]"{d_name}"[/green]'
b_disp = f'[green]{d_bot}[/green]'
elif v.get("discord_token") is False:
if self.config.tool_mode == "backup_transfer":
if self.config.tool_mode == "backup_transfer" and self.view_mode == "shuttle":
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":
self.query_one("#sp_lbl_d_bot", Label).update(f"Source: {b_disp}")
self.query_one("#op_lbl_d_server", Label).update(f"Server: {s_disp}")
if self.view_mode == "backup":
self.query_one("#op_lbl_d_bot", Label).update(f"Source: {b_disp}")
elif self.config.tool_mode == "backup_transfer":
self.query_one("#op_lbl_d_bot", Label).update(f"Source: {b_disp}")
else:
self.query_one("#sp_lbl_d_bot", Label).update(f"Bot: {b_disp}")
self.query_one("#op_lbl_d_bot", Label).update(f"Bot: {b_disp}")
# Discord Side Status
if v.get("discord_token") and v.get("discord_server"):
d_status = "[green][VALID][/green]"
elif v.get("discord_timeout"):
d_status = "[red][TIMEOUT][/red]"
else:
d_status = "[red][INVALID][/red]"
self.query_one("#sp_lbl_d_status", Label).update(f"Status: {d_status}")
d_err = v.get("discord_error")
di = v.get("discord_intents", {})
dp = v.get("discord_permissions", {})
d_missing = []
if d_err is None and v.get("discord_token") and v.get("discord_server"):
if not di.get("message_content"): d_missing.append("Message Intent")
if not dp.get("view_channel"): d_missing.append("View Channel")
if not dp.get("read_message_history"): d_missing.append("Msg History")
if v.get("discord_token") and v.get("discord_server") and not d_missing:
d_status = "[green]VALID[/green]"
elif v.get("discord_timeout"):
d_status = "[red]TIMEOUT[/red]"
elif d_err:
d_status = f"[red]{d_err}[/red]"
elif d_missing:
d_status = f"[yellow]MISSING: {', '.join(d_missing)}[/yellow]"
else:
d_status = "[red]INVALID[/red]"
self.query_one("#op_lbl_d_status", Label).update(f"Status: {d_status}")
# Target / Backup Info
if self.view_mode == "backup":
backup_text = v.get("backup_info_text", "")
self.query_one("#op_lbl_backup", Label).update(backup_text)
self.query_one("#op_lbl_backup", Label).display = bool(backup_text)
# Hide target side in backup mode completely
self.query_one("#op_vrule").display = False
self.query_one("#op_target_pane").display = False
enabled = (v.get("discord_token") and v.get("discord_server") and not d_missing)
for bid in ("#op_backup_profile", "#op_backup_msgs", "#op_backup_sync"):
self.query_one(bid, Button).disabled = not enabled
self.query_one("#op_backup_stats", Button).display = self.has_backup
self.query_one("#op_backup_stats", Button).disabled = not self.has_backup
self.query_one("#op_backup_stats_rule", Rule).display = self.has_backup
else:
# Target
plat = "Fluxer" if self.target_platform == "fluxer" else "Stoat"
t_name = v.get("target_community_name")
t_bot = v.get("target_bot_name")
self.query_one("#sp_lbl_t_header", Label).update(plat)
self.query_one("#op_lbl_t_header", Label).update(plat)
if v.get("target_timeout"):
c_disp, tb_disp = "[red]TIMEOUT[/red]", "[red]TIMEOUT[/red]"
@ -208,29 +288,33 @@ class ShuttlePane(Container):
else:
c_disp, tb_disp = "[red]NOT SET UP[/red]", "[red]NOT SET UP[/red]"
self.query_one("#sp_lbl_t_comm", Label).update(f"Community: {c_disp}")
self.query_one("#sp_lbl_t_bot", Label).update(f"Bot: {tb_disp}")
self.query_one("#op_lbl_t_comm", Label).update(f"Community: {c_disp}")
self.query_one("#op_lbl_t_bot", Label).update(f"Bot: {tb_disp}")
# Target Side Status
if v.get("target_token") and v.get("target_community"):
t_status = "[green][VALID][/green]"
elif v.get("target_timeout"):
t_status = "[red][TIMEOUT][/red]"
else:
t_status = "[red][INVALID][/red]"
self.query_one("#sp_lbl_t_status", Label).update(f"Status: {t_status}")
t_err = v.get("target_error")
tp = v.get("target_permissions", {})
# Status
if not self.tokens_valid:
val = "[red][INVALID][/red]"
elif not self.permissions_complete:
val = "[yellow][MISSING PERMISSIONS][/yellow]"
t_missing = []
if t_err is None and v.get("target_token") and v.get("target_community"):
if tp:
t_missing = [k.replace('_', ' ').title() for k, val_p in tp.items() if not val_p]
if v.get("target_token") and v.get("target_community") and not t_missing:
t_status = "[green]VALID[/green]"
elif v.get("target_timeout"):
t_status = "[red]TIMEOUT[/red]"
elif t_err:
t_status = f"[red]{t_err}[/red]"
elif t_missing:
# Show first two for brevity
t_status = f"[yellow]MISSING: {', '.join(t_missing[:2])}{'...' if len(t_missing)>2 else ''}[/yellow]"
else:
val = "[green][VALID][/green]"
self.query_one("#sp_lbl_status", Label).update(f"Status: {val}")
t_status = "[red]INVALID[/red]"
self.query_one("#op_lbl_t_status", Label).update(f"Status: {t_status}")
# Buttons
for bid in ("#sp_clone", "#sp_sync", "#sp_messages", "#sp_danger"):
for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_danger"):
self.query_one(bid, Button).disabled = not self.tokens_valid
# ── validation ────────────────────────────────────────────────────────
@ -241,13 +325,17 @@ class ShuttlePane(Container):
"discord_token": False, "discord_bot_name": None,
"discord_server": False, "discord_server_name": None,
"discord_intents": {}, "discord_permissions": {},
"discord_error": None,
"target_token": False, "target_bot_name": None,
"target_community": False, "target_community_name": None,
"target_permissions": {},
"target_error": None,
"discord_timeout": False, "target_timeout": False,
"backup_info_text": "",
}
self.tokens_valid = False
self.permissions_complete = False
self.has_backup = False
fillers = [
"DISCORD_BOT_TOKEN", "FLUXER_BOT_TOKEN", "STOAT_BOT_TOKEN",
@ -255,16 +343,21 @@ class ShuttlePane(Container):
"000000000000000000", "DISCORD_SERVER_ID", "FLUXER_COMMUNITY_ID",
"STOAT_SERVER_ID", "TARGET_SERVER_ID", "", None,
]
if self.config.tool_mode == "backup_transfer":
d_dummy = self.config.discord_server_id in fillers
else:
# Check dummies
d_dummy = False
if self.view_mode == "backup" or self.config.tool_mode != "backup_transfer":
d_dummy = self.config.discord_bot_token in fillers or self.config.discord_server_id in fillers
else:
# In shuttle mode of backup_transfer, we look at the source backup dir
d_dummy = 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 = {}
if not d_dummy:
tasks["discord"] = asyncio.create_task(self.engine.discord_reader.validate())
if not t_dummy:
if not t_dummy and self.view_mode == "shuttle":
tasks["target"] = asyncio.create_task(self.engine.writer.validate())
all_tasks = list(tasks.values())
@ -282,6 +375,7 @@ class ShuttlePane(Container):
self.validation_results["discord_server_name"] = res.get("server_name")
self.validation_results["discord_intents"] = res.get("intents", {})
self.validation_results["discord_permissions"] = res.get("permissions", {})
self.validation_results["discord_error"] = res.get("error_reason")
elif dt and dt not in done:
self.validation_results["discord_timeout"] = True
dt.cancel()
@ -294,15 +388,24 @@ class ShuttlePane(Container):
self.validation_results["target_community"] = res.get("community", False)
self.validation_results["target_community_name"] = res.get("community_name")
self.validation_results["target_permissions"] = res.get("permissions", {})
self.validation_results["target_error"] = res.get("error_reason")
elif tt and tt not in done:
self.validation_results["target_timeout"] = True
tt.cancel()
discord_ok = self.validation_results.get("discord_token") and self.validation_results.get("discord_server")
if self.view_mode == "backup":
self.tokens_valid = bool(discord_ok)
info = self._get_backup_info()
if info:
self.validation_results["backup_info_text"] = f"Last backup: [cyan]{info}[/cyan]"
self.has_backup = True
else:
target_ok = self.validation_results.get("target_token") and self.validation_results.get("target_community")
self.tokens_valid = bool(discord_ok and target_ok)
if self.tokens_valid:
if self.tokens_valid and self.view_mode == "shuttle":
srv_id = self.config.target_server_id
srv_name = self.validation_results.get("target_community_name", "unknown")
if srv_id and srv_name:
@ -315,6 +418,8 @@ class ShuttlePane(Container):
dp = self.validation_results.get("discord_permissions", {})
if not all([di.get("message_content"), dp.get("view_channel"), dp.get("read_message_history")]):
self.permissions_complete = False
if self.view_mode == "shuttle":
tp = self.validation_results.get("target_permissions", {})
if tp and not all(tp.values()):
self.permissions_complete = False
@ -322,7 +427,7 @@ class ShuttlePane(Container):
pass
finally:
for t in all_tasks:
if not t.done():
if t and not t.done():
t.cancel()
self._update_info_labels()
@ -331,17 +436,31 @@ class ShuttlePane(Container):
def on_button_pressed(self, event: Button.Pressed) -> None:
bid = event.button.id
if not bid or not bid.startswith("sp_"):
if not bid or not bid.startswith("op_"):
return
if bid == "sp_clone":
# Migration Routing
if bid == "op_clone":
self._open_clone_menu()
elif bid == "sp_sync":
elif bid == "op_sync":
self._open_sync_menu()
elif bid == "sp_messages":
elif bid == "op_messages":
self.run_migrate_messages()
elif bid == "sp_danger":
elif bid == "op_danger":
self._open_danger_menu()
# Backup Routing
elif bid == "op_backup_profile":
self.run_backup_profile()
elif bid == "op_backup_msgs":
self.run_backup_messages()
elif bid == "op_backup_sync":
self.run_backup_sync()
elif bid == "op_backup_stats":
from src.ui.backup_stats import BackupStatsScreen
target_dir = Path(self._base_dir()) / f"DISCORD_BACKUP-{self.config.discord_server_id}"
self.app.push_screen(BackupStatsScreen(self.cfg_name, target_dir))
# ── (1) clone server template (combined) ─────────────────────────────
def _open_clone_menu(self):
@ -1480,3 +1599,373 @@ class ShuttlePane(Container):
counts = await danger_delete_all_emojis_and_stickers(self.engine, progress_callback=on_deleted)
modal.write(f"[bold green]Success! {counts.get('emojis', 0)} emojis, {counts.get('stickers', 0)} stickers deleted.[/bold green]")
await log_audit_event(self.engine, "Danger Zone: Assets Wiped", f"Deleted {counts.get('emojis', 0)} emojis and {counts.get('stickers', 0)} stickers.")
# ── backup workers ───────────────────────────────────────────────────
@work(exclusive=True)
async def run_backup_profile(self) -> None:
modal = ProgressScreen(log_level=self.config.log_level)
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.phase_progress()
try:
modal.set_status("Starting readers...")
await self.engine.discord_reader.start()
await self.exporter.setup()
# Gather and print summary
server = getattr(self.engine.discord_reader, 'guild', 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(getattr(server, 'roles', []))}[/green]")
modal.write(f" Emojis: [green]{len(getattr(server, 'emojis', []))}[/green]")
modal.write(f" Channels: [green]{len(getattr(server, 'channels', []))}[/green]")
modal.write("")
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()
modal.write("Exporting structure...")
_, cat_count, chan_count = await self.exporter.export_channels_structure()
modal.write("Exporting roles...")
roles = await self.exporter.export_roles()
modal.write("Exporting assets...")
e_count, s_count = await self.exporter.export_assets()
modal.write(f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]")
modal.write(f"- {len(roles)} roles, {e_count} emojis, {s_count} stickers.")
modal.phase_report("Profile Backup", show_back=False)
except self.engine.discord_reader.Forbidden as e:
modal.write(f"[bold red]Backup failed: {e}[/bold red]")
modal.phase_report("Profile Backup", "error")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
modal.phase_report("Profile Backup", "error")
finally:
await self.engine.close_connections()
self.run_validate()
@work(exclusive=True)
async def run_backup_messages(self) -> None:
modal_prog = ProgressScreen(log_level=self.config.log_level)
self.app.push_screen(modal_prog)
await asyncio.sleep(0.1)
try:
modal_prog.set_status("Fetching channels...")
await self.engine.discord_reader.start()
await self.exporter.setup()
await self.exporter.export_channels_structure()
all_channels = await self.engine.discord_reader.get_channels()
all_categories = await self.engine.discord_reader.get_categories()
cat_map = {c.id: c.name for c in all_categories}
eligible_channels = [
c for c in all_channels
if c.type in [
self.engine.discord_reader.CHANNEL_TYPE_TEXT,
self.engine.discord_reader.CHANNEL_TYPE_NEWS,
self.engine.discord_reader.CHANNEL_TYPE_FORUM
]
]
if not eligible_channels:
modal_prog.write("[yellow]No text/news channels found to backup.[/yellow]")
modal_prog.allow_close()
return
any_found = False
backed_up_ids = set()
for chan in eligible_channels:
if (self.exporter.export_path / "message_backup" / str(chan.id) / "messages.json").exists():
any_found = True
backed_up_ids.add(chan.id)
self.app.pop_screen()
while True:
loop = asyncio.get_running_loop()
future = loop.create_future()
def check_channels(reply: dict | None) -> None:
if not future.done():
future.set_result(reply)
self.app.push_screen(
ChannelSelectScreen(eligible_channels, cat_map, backed_up_ids, any_found),
check_channels,
)
reply = await future
if not reply:
return
selected_ids = reply["channels"]
force_overwrite = reply["force"]
selected_channels = [c for c in eligible_channels if c.id in selected_ids]
# Phase 2: Confirmation
modal_prog = ProgressScreen(log_level=self.config.log_level) # Re-instantiate to avoid Textual re-push UI freeze
self.app.push_screen(modal_prog)
await asyncio.sleep(0.1)
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, 'guild', 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]Backup Channels[/cyan]", f"{len(new_channels)} new, {len(existing_channels)} existing")
# 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":
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
# 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()
modal_prog.show_stats()
# Reset running flag and set cancel callback
self.exporter.is_running = True
modal_prog.cancel_callback = lambda: setattr(self.exporter, "is_running", False)
total_chans = len(selected_channels)
modal_prog.set_status("Backing up messages...")
modal_prog.write(f"[yellow]Starting backup for {total_chans} channels...[/yellow]")
accumulated_msgs = 0
for i, chan in enumerate(selected_channels):
if not self.exporter.is_running:
modal_prog.write("[bold red]Backup cancelled by user.[/bold red]")
break
await asyncio.sleep(0.01) # Yield to UI thread to keep it responsive
backup_exists = (self.exporter.export_path / "message_backup" / str(chan.id) / "messages.json").exists()
is_sync = backup_exists and not force_overwrite
label = "Syncing Backup" if is_sync else "Backing up"
modal_prog.set_item_status(f"[cyan]Processing ({i+1}/{total_chans}): #{chan.name}[/cyan]")
modal_prog.set_progress(i, total_chans)
modal_prog.write(f"[cyan]{label}: {chan.name}[/cyan]")
logger.info(f"{label} for channel: #{chan.name} ({chan.id})")
_msg_log_counter = 0
async def update_msg_count(name, count, author_name=None, message_preview=None):
nonlocal _msg_log_counter
modal_prog.update_stats(messages=str(count))
_msg_log_counter += 1
if author_name and message_preview and _msg_log_counter % 10 == 0:
modal_prog.write(f"[bold]{author_name}:[/bold] {message_preview}")
accumulated_msgs = await self.exporter.export_channel_messages(
chan.id, progress_callback=update_msg_count, force=force_overwrite,
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,
accumulated_count=accumulated_msgs
)
modal_prog.write(f"[green]Completed: {chan.name}[/green]")
if not self.exporter.is_running:
modal_prog.set_item_status("[bold red]Backup Cancelled.[/bold red]")
modal_prog.phase_report("Message Backup", "stopped", show_back=False)
return
modal_prog.set_progress(total_chans, total_chans)
modal_prog.set_item_status("[bold green]Backup completed successfully![/bold green]")
await self.exporter.export_metadata()
modal_prog.write("[bold green]Message backup complete![/bold green]")
logger.info("Message backup operation completed successfully.")
modal_prog.phase_report("Message Backup", show_back=False)
except Exception as e:
logger.error(f"Message backup failed: {e}\n{traceback.format_exc()}")
modal_prog.write(f"[bold red]Message backup failed: {e}[/bold red]")
modal_prog.phase_report("Message Backup", "error", show_back=False)
finally:
await self.engine.close_connections()
self.run_validate()
@work(exclusive=True)
async def run_backup_sync(self) -> None:
modal_prog = ProgressScreen(log_level=self.config.log_level)
self.app.push_screen(modal_prog)
await asyncio.sleep(0.1)
modal_prog.phase_progress()
try:
modal_prog.set_status("Starting sync...")
await self.engine.discord_reader.start()
await self.exporter.setup()
# Gather and print summary
server = getattr(self.engine.discord_reader, 'guild', 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(getattr(server, 'roles', []))}[/green]")
modal_prog.write(f" Emojis: [green]{len(getattr(server, 'emojis', []))}[/green]")
modal_prog.write(f" Channels: [green]{len(getattr(server, 'channels', []))}[/green]")
modal_prog.write("\n[dim]This operation will update the profile and scan existing backed-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()
await self.exporter.export_channels_structure()
await self.exporter.export_assets()
all_channels = await self.engine.discord_reader.get_channels()
eligible_channels = [
c for c in all_channels
if c.type in [
self.engine.discord_reader.CHANNEL_TYPE_TEXT,
self.engine.discord_reader.CHANNEL_TYPE_NEWS,
self.engine.discord_reader.CHANNEL_TYPE_FORUM
]
]
selected_channels = [
c for c in eligible_channels
if (self.exporter.export_path / "message_backup" / str(c.id) / "messages.json").exists()
]
if not selected_channels:
modal_prog.write("[yellow]No existing backups found to sync.[/yellow]")
else:
total_chans = len(selected_channels)
modal_prog.show_stats()
modal_prog.set_status("Syncing messages...")
modal_prog.write(f"[yellow]Syncing {total_chans} channels...[/yellow]")
# Reset running flag and set cancel callback
self.exporter.is_running = True
modal_prog.cancel_callback = lambda: setattr(self.exporter, "is_running", False)
accumulated_msgs = 0
for i, chan in enumerate(selected_channels):
if not self.exporter.is_running:
modal_prog.write("[bold red]Sync cancelled by user.[/bold red]")
break
await asyncio.sleep(0.01) # Yield to UI thread
modal_prog.set_item_status(f"[cyan]Syncing ({i+1}/{total_chans}): #{chan.name}[/cyan]")
modal_prog.set_progress(i, total_chans)
modal_prog.write(f"[cyan]Syncing: {chan.name}[/cyan]")
logger.info(f"Syncing backup for channel: #{chan.name} ({chan.id})")
_msg_log_counter = 0
async def update_msg_count(name, count, author_name=None, message_preview=None):
nonlocal _msg_log_counter
modal_prog.update_stats(messages=str(count))
_msg_log_counter += 1
if author_name and message_preview and _msg_log_counter % 10 == 0:
modal_prog.write(f"[bold]{author_name}:[/bold] {message_preview}")
accumulated_msgs = await self.exporter.export_channel_messages(
chan.id, progress_callback=update_msg_count, force=False,
accumulated_count=accumulated_msgs
)
accumulated_msgs = await self.exporter.export_threads(
chan.id, progress_callback=update_msg_count, force=False,
accumulated_count=accumulated_msgs
)
modal_prog.write(f"[green]Synced: {chan.name}[/green]")
if not self.exporter.is_running:
modal_prog.set_item_status("[bold red]Sync Cancelled.[/bold red]")
modal_prog.phase_report("Backup Sync", "stopped", show_back=False)
return
modal_prog.set_progress(total_chans, total_chans)
modal_prog.set_item_status("[bold green]Sync operation complete![/bold green]")
await self.exporter.export_metadata()
modal_prog.write("[bold green]Sync operation complete![/bold green]")
logger.info("Sync operation completed successfully.")
modal_prog.phase_report("Backup Sync", show_back=False)
except Exception as e:
logger.error(f"Sync failed: {e}\n{traceback.format_exc()}")
modal_prog.write(f"[bold red]Sync failed: {e}[/bold red]")
modal_prog.phase_report("Backup Sync", "error", show_back=False)
finally:
await self.engine.close_connections()
self.run_validate()