dedupicate UI components for shuttle and backup panes
This commit is contained in:
parent
0c97d14a2b
commit
975369c784
7 changed files with 730 additions and 761 deletions
|
|
@ -97,36 +97,50 @@ class DiscordReader:
|
||||||
"server": False,
|
"server": False,
|
||||||
"bot_name": None,
|
"bot_name": None,
|
||||||
"server_name": None,
|
"server_name": None,
|
||||||
|
"error_reason": None,
|
||||||
"intents": {"message_content": False},
|
"intents": {"message_content": False},
|
||||||
"permissions": {"view_channel": False, "read_message_history": False}
|
"permissions": {"view_channel": False, "read_message_history": False}
|
||||||
}
|
}
|
||||||
temp_client = self._create_client()
|
temp_client = self._create_client()
|
||||||
try:
|
try:
|
||||||
await temp_client.login(self.token)
|
try:
|
||||||
results["token"] = True
|
await temp_client.login(self.token)
|
||||||
if temp_client.user:
|
results["token"] = True
|
||||||
results["bot_name"] = temp_client.user.display_name
|
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
|
||||||
|
|
||||||
guild = await temp_client.fetch_guild(self.server_id)
|
try:
|
||||||
if guild is not None:
|
guild = await temp_client.fetch_guild(self.server_id)
|
||||||
results["server"] = True
|
if guild is not None:
|
||||||
results["server_name"] = guild.name
|
results["server"] = True
|
||||||
|
results["server_name"] = guild.name
|
||||||
# Check intents
|
|
||||||
results["intents"]["message_content"] = temp_client.intents.message_content
|
# Check intents
|
||||||
|
results["intents"]["message_content"] = temp_client.intents.message_content
|
||||||
# Check permissions
|
|
||||||
# We need to fetch the member to check permissions
|
# Check permissions
|
||||||
try:
|
try:
|
||||||
member = await guild.fetch_member(temp_client.user.id)
|
member = await guild.fetch_member(temp_client.user.id)
|
||||||
perms = member.guild_permissions
|
perms = member.guild_permissions
|
||||||
results["permissions"]["view_channel"] = perms.view_channel
|
results["permissions"]["view_channel"] = perms.view_channel
|
||||||
results["permissions"]["read_message_history"] = perms.read_message_history
|
results["permissions"]["read_message_history"] = perms.read_message_history
|
||||||
except Exception:
|
except Exception as e:
|
||||||
# Fallback if member fetch fails, though it shouldn't for the bot itself
|
logger.debug(f"Member fetch failed: {e}")
|
||||||
pass
|
# Fallback if member fetch fails, though it shouldn't for the bot itself
|
||||||
except Exception:
|
except discord.Forbidden:
|
||||||
pass
|
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:
|
finally:
|
||||||
if not temp_client.is_closed():
|
if not temp_client.is_closed():
|
||||||
await temp_client.close()
|
await temp_client.close()
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ class FluxerWriter:
|
||||||
is_community_valid = False
|
is_community_valid = False
|
||||||
bot_name = None
|
bot_name = None
|
||||||
community_name = None
|
community_name = None
|
||||||
|
error_reason = None
|
||||||
permissions = {
|
permissions = {
|
||||||
"manage_channels": False,
|
"manage_channels": False,
|
||||||
"manage_messages": False,
|
"manage_messages": False,
|
||||||
|
|
@ -116,77 +117,84 @@ class FluxerWriter:
|
||||||
try:
|
try:
|
||||||
# Check token by fetching me
|
# Check token by fetching me
|
||||||
me_id = None
|
me_id = None
|
||||||
if self.bot and self.bot.user:
|
try:
|
||||||
is_token_valid = True
|
if self.bot and self.bot.user:
|
||||||
bot_name = self.bot.user.username
|
|
||||||
me_id = self.bot.user.id
|
|
||||||
else:
|
|
||||||
me = await self.client.get_current_user()
|
|
||||||
if me:
|
|
||||||
is_token_valid = True
|
is_token_valid = True
|
||||||
bot_name = me.get("username")
|
bot_name = self.bot.user.username
|
||||||
me_id = int(me["id"])
|
me_id = self.bot.user.id
|
||||||
|
else:
|
||||||
|
me = await self.client.get_current_user()
|
||||||
|
if me:
|
||||||
|
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
|
# Check community
|
||||||
guild_data = await self.client.get_guild(self.community_id)
|
try:
|
||||||
if guild_data:
|
guild_data = await self.client.get_guild(self.community_id)
|
||||||
is_community_valid = True
|
if guild_data:
|
||||||
community_name = guild_data.get("name")
|
is_community_valid = True
|
||||||
|
community_name = guild_data.get("name")
|
||||||
|
|
||||||
if me_id:
|
if me_id:
|
||||||
try:
|
|
||||||
# Fetch member to get roles
|
|
||||||
member_data = await self.client.get_guild_member(self.community_id, me_id)
|
|
||||||
member_role_ids = [int(r) for r in member_data.get("roles", [])]
|
|
||||||
|
|
||||||
# Fetch all roles to get their permissions
|
|
||||||
all_roles_data = await self.client.get_guild_roles(self.community_id)
|
|
||||||
|
|
||||||
# Calculate total permissions
|
|
||||||
# In Discord/Fluxer, permissions are additive
|
|
||||||
total_perms = 0
|
|
||||||
fluxer_guild_id = 0
|
|
||||||
try:
|
try:
|
||||||
fluxer_guild_id = int(self.community_id)
|
# Fetch member to get roles
|
||||||
except (ValueError, TypeError):
|
member_data = await self.client.get_guild_member(self.community_id, me_id)
|
||||||
pass
|
member_role_ids = [int(r) for r in member_data.get("roles", [])]
|
||||||
|
|
||||||
|
# Fetch all roles to get their permissions
|
||||||
|
all_roles_data = await self.client.get_guild_roles(self.community_id)
|
||||||
|
|
||||||
|
# Calculate total permissions
|
||||||
|
# In Discord/Fluxer, permissions are additive
|
||||||
|
total_perms = 0
|
||||||
|
fluxer_guild_id = 0
|
||||||
|
try:
|
||||||
|
fluxer_guild_id = int(self.community_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
for r_data in all_roles_data:
|
for r_data in all_roles_data:
|
||||||
r_id = int(r_data["id"])
|
r_id = int(r_data["id"])
|
||||||
# Add @everyone permissions (role ID same as guild ID)
|
# Add @everyone permissions (role ID same as guild ID)
|
||||||
if r_id == fluxer_guild_id or r_id in member_role_ids:
|
if r_id == fluxer_guild_id or r_id in member_role_ids:
|
||||||
total_perms |= int(r_data.get("permissions", 0))
|
total_perms |= int(r_data.get("permissions", 0))
|
||||||
|
|
||||||
# Debugging
|
# Bitmask Mapping (Discord standard)
|
||||||
# print(f"DEBUG: me_id={me_id}, roles={member_role_ids}, total_perms={total_perms}")
|
is_admin = bool(total_perms & (1 << 3))
|
||||||
|
|
||||||
# Bitmask Mapping (Discord standard)
|
permissions["manage_channels"] = is_admin or bool(total_perms & (1 << 4))
|
||||||
# Administrator: 1 << 3
|
permissions["manage_messages"] = is_admin or bool(total_perms & (1 << 13))
|
||||||
# Manage Channels: 1 << 4
|
permissions["manage_roles"] = is_admin or bool(total_perms & (1 << 28))
|
||||||
# Manage Messages: 1 << 13
|
permissions["manage_webhooks"] = is_admin or bool(total_perms & (1 << 29))
|
||||||
# Manage Roles: 1 << 28
|
permissions["manage_emojis_stickers"] = is_admin or bool(total_perms & (1 << 30))
|
||||||
# Manage Webhooks: 1 << 29
|
|
||||||
# Manage Emojis/Stickers: 1 << 30
|
except Exception as e:
|
||||||
is_admin = bool(total_perms & (1 << 3))
|
logger.error(f"Failed to calculate Fluxer permissions: {e}")
|
||||||
|
error_reason = f"Permission error: {str(e)}"
|
||||||
permissions["manage_channels"] = is_admin or bool(total_perms & (1 << 4))
|
else:
|
||||||
permissions["manage_messages"] = is_admin or bool(total_perms & (1 << 13))
|
error_reason = "Community not found"
|
||||||
permissions["manage_roles"] = is_admin or bool(total_perms & (1 << 28))
|
except Exception as e:
|
||||||
permissions["manage_webhooks"] = is_admin or bool(total_perms & (1 << 29))
|
error_reason = f"Community Error: {str(e)}"
|
||||||
permissions["manage_emojis_stickers"] = is_admin or bool(total_perms & (1 << 30))
|
except Exception as e:
|
||||||
|
error_reason = str(e)
|
||||||
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
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"token": is_token_valid,
|
"token": is_token_valid,
|
||||||
"community": is_community_valid,
|
"community": is_community_valid,
|
||||||
"bot_name": bot_name,
|
"bot_name": bot_name,
|
||||||
"community_name": community_name,
|
"community_name": community_name,
|
||||||
|
"error_reason": error_reason,
|
||||||
"permissions": permissions
|
"permissions": permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ class StoatWriter:
|
||||||
"community": False,
|
"community": False,
|
||||||
"bot_name": "N/A",
|
"bot_name": "N/A",
|
||||||
"community_name": "N/A",
|
"community_name": "N/A",
|
||||||
|
"error_reason": None,
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"manage_channels": False,
|
"manage_channels": False,
|
||||||
"manage_server": False,
|
"manage_server": False,
|
||||||
|
|
@ -143,7 +144,10 @@ class StoatWriter:
|
||||||
results["token"] = True
|
results["token"] = True
|
||||||
results["bot_name"] = current_user.display_name or current_user.name
|
results["bot_name"] = current_user.display_name or current_user.name
|
||||||
except stoat.Unauthorized:
|
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
|
return results
|
||||||
|
|
||||||
# Validate server access
|
# Validate server access
|
||||||
|
|
@ -172,21 +176,20 @@ class StoatWriter:
|
||||||
"mention_roles": perms.mention_roles
|
"mention_roles": perms.mention_roles
|
||||||
}
|
}
|
||||||
except stoat.NotFound:
|
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:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching Stoat member permissions: {e}")
|
results["error_reason"] = f"Permission error: {str(e)}"
|
||||||
|
|
||||||
except stoat.NotFound:
|
except stoat.NotFound:
|
||||||
logger.error(f"Stoat server {self.community_id} not found.")
|
results["error_reason"] = "Server not found"
|
||||||
except stoat.Forbidden:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error validating Stoat server: {e}")
|
results["error_reason"] = f"Server error: {str(e)}"
|
||||||
|
|
||||||
except Exception as 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
|
self._validation_cache = results
|
||||||
return results
|
return results
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -370,29 +370,40 @@ class ConfigScreen(Screen):
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch {platform} servers: {e}")
|
logger.error(f"Failed to fetch {platform} servers: {e}")
|
||||||
self.query_one("#btn_fetch_target_servers", Button).variant = "warning"
|
try:
|
||||||
self.query_one("#inp_target_server", Select).prompt = "Invalid token"
|
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:
|
if not initial:
|
||||||
self.notify(f"Failed to fetch {platform} servers: {e}", severity="error")
|
self.notify(f"Failed to fetch {platform} servers: {e}", severity="error")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not servers:
|
if not servers:
|
||||||
self.query_one("#btn_fetch_target_servers", Button).variant = "warning"
|
try:
|
||||||
self.query_one("#inp_target_server", Select).prompt = "No servers found"
|
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:
|
if not initial:
|
||||||
self.notify(f"No {platform} servers found or invalid token.", severity="warning")
|
self.notify(f"No {platform} servers found or invalid token.", severity="warning")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.query_one("#btn_fetch_target_servers", Button).variant = "success"
|
try:
|
||||||
options = [(label, sid) for label, sid in servers]
|
self.query_one("#btn_fetch_target_servers", Button).variant = "success"
|
||||||
select_widget = self.query_one("#inp_target_server", Select)
|
options = [(label, sid) for label, sid in servers]
|
||||||
select_widget.prompt = "Select a server"
|
select_widget = self.query_one("#inp_target_server", Select)
|
||||||
select_widget.set_options(options)
|
select_widget.prompt = "Select a server"
|
||||||
|
select_widget.set_options(options)
|
||||||
|
|
||||||
|
# Restore saved value
|
||||||
|
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}")
|
||||||
|
|
||||||
# Restore saved value
|
|
||||||
saved_id = self.config.target_server_id
|
|
||||||
if saved_id and any(sid == saved_id for _, sid in servers):
|
|
||||||
select_widget.value = saved_id
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
if event.button.id == "btn_fetch_guilds":
|
if event.button.id == "btn_fetch_guilds":
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,7 @@ from textual.widgets import Header, Footer, Button, ContentSwitcher, Rule
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
|
|
||||||
from src.core.configuration import load_config
|
from src.core.configuration import load_config
|
||||||
from src.ui.backup_ops import BackupPane
|
from src.ui.shuttle_ops import OperationPane
|
||||||
from src.ui.shuttle_ops import ShuttlePane
|
|
||||||
|
|
||||||
|
|
||||||
class ModeScreen(Screen):
|
class ModeScreen(Screen):
|
||||||
|
|
@ -122,13 +121,13 @@ class ModeScreen(Screen):
|
||||||
with Container(id="main_outer"):
|
with Container(id="main_outer"):
|
||||||
with Container(id="main_container"):
|
with Container(id="main_container"):
|
||||||
if mode == "backup_only":
|
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":
|
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
|
else: # backup_transfer
|
||||||
with ContentSwitcher(initial="pane_backup", id="switcher"):
|
with ContentSwitcher(initial="pane_backup", id="switcher"):
|
||||||
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")
|
||||||
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")
|
||||||
|
|
||||||
with Vertical(id="mode_footer"):
|
with Vertical(id="mode_footer"):
|
||||||
yield Rule(id="footer_rule")
|
yield Rule(id="footer_rule")
|
||||||
|
|
@ -155,9 +154,7 @@ class ModeScreen(Screen):
|
||||||
self.app.push_screen(ModeScreen(self.cfg_name, self.config_path))
|
self.app.push_screen(ModeScreen(self.cfg_name, self.config_path))
|
||||||
else:
|
else:
|
||||||
self.config = new_cfg
|
self.config = new_cfg
|
||||||
for pane in self.query(BackupPane):
|
for pane in self.query(OperationPane):
|
||||||
pane.reload_config()
|
|
||||||
for pane in self.query(ShuttlePane):
|
|
||||||
pane.reload_config()
|
pane.reload_config()
|
||||||
self.app.push_screen(ConfigScreen(self.cfg_name, self.config_path), reload_screen)
|
self.app.push_screen(ConfigScreen(self.cfg_name, self.config_path), reload_screen)
|
||||||
elif bid == "btn_switch":
|
elif bid == "btn_switch":
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""
|
"""
|
||||||
ShuttlePane – self-contained shuttle (migration) operations widget.
|
OperationPane – unified operations widget for both Backup and Migration.
|
||||||
Embedded inside ModeScreen's "Migrate" tab.
|
Handles Discord backups and multi-platform migrations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -20,8 +20,10 @@ from textual import work
|
||||||
from src.core.configuration import load_config
|
from src.core.configuration import load_config
|
||||||
from src.core.base import MigrationContext
|
from src.core.base import MigrationContext
|
||||||
from src.core.audit import log_audit_event
|
from src.core.audit import log_audit_event
|
||||||
|
from src.core.exporter import DiscordExporter
|
||||||
from src.ui.modals import (
|
from src.ui.modals import (
|
||||||
ProgressScreen, SubMenuModal, ChannelPickerScreen, OptionSelectModal, MessageIDInputModal,
|
ProgressScreen, SubMenuModal, ChannelPickerScreen, OptionSelectModal, MessageIDInputModal,
|
||||||
|
ChannelSelectScreen
|
||||||
)
|
)
|
||||||
|
|
||||||
import src.fluxer.roles_permissions as fluxer_roles
|
import src.fluxer.roles_permissions as fluxer_roles
|
||||||
|
|
@ -78,69 +80,87 @@ class RateLimitHandler(logging.Handler):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ShuttlePane(Container):
|
class OperationPane(Container):
|
||||||
"""Shuttle (migration) operations pane — clone, roles, emojis, metadata, messages, danger zone."""
|
"""Unified operations pane — Backup, Clone, Sync, Migrate."""
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
ShuttlePane { height: auto; width: 100%; }
|
OperationPane { height: auto; width: 100%; }
|
||||||
ShuttlePane #sp_info {
|
OperationPane #op_info {
|
||||||
height: auto; border: tall yellow; padding: 1; margin-bottom: 1; layout: vertical;
|
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 { 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; }
|
||||||
.pane_status { text-style: bold; margin-top: 1; }
|
.pane_status { text-style: bold; margin-top: 1; }
|
||||||
#sp_info_split Rule { height: 100%; margin: 0 2; color: $accent; }
|
#op_info_split Rule { height: 100%; margin: 0 2; color: $accent; }
|
||||||
#sp_lbl_status { display: none; }
|
#op_lbl_backup { display: none; }
|
||||||
|
|
||||||
ShuttlePane #sp_actions { height: auto; }
|
OperationPane #op_actions { height: auto; }
|
||||||
ShuttlePane #sp_actions Button { width: 100%; margin-bottom: 1; }
|
OperationPane #op_actions Button { width: 100%; margin-bottom: 1; }
|
||||||
#footer_rule { margin: 0; }
|
#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)
|
super().__init__(*args, **kwargs)
|
||||||
self.cfg_name = cfg_name
|
self.cfg_name = cfg_name
|
||||||
self.config_path = cfg_path
|
self.config_path = cfg_path
|
||||||
|
self.view_mode = view_mode # "backup" or "shuttle" (migrate)
|
||||||
self.config = load_config(cfg_path)
|
self.config = load_config(cfg_path)
|
||||||
self.target_platform = self.config.target_platform or "fluxer"
|
self.target_platform = self.config.target_platform or "fluxer"
|
||||||
self.engine: MigrationContext | None = None
|
self.engine: MigrationContext | None = None
|
||||||
|
self.exporter: DiscordExporter | None = None
|
||||||
self.validation_results: dict = {}
|
self.validation_results: dict = {}
|
||||||
self.tokens_valid = False
|
self.tokens_valid = False
|
||||||
self.permissions_complete = False
|
self.permissions_complete = False
|
||||||
|
self.has_backup = False
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with VerticalScroll():
|
with VerticalScroll():
|
||||||
with Vertical(id="sp_info"):
|
with Vertical(id="op_info"):
|
||||||
with Horizontal(id="sp_info_split"):
|
with Horizontal(id="op_info_split"):
|
||||||
with Vertical(classes="info_pane"):
|
with Vertical(classes="info_pane"):
|
||||||
yield Label("Discord", classes="pane_header")
|
yield Label("Discord", classes="pane_header")
|
||||||
yield Label("Server: [yellow]Loading...[/yellow]", id="sp_lbl_d_server")
|
yield Label("Server: [yellow]Loading...[/yellow]", id="op_lbl_d_server")
|
||||||
yield Label("Bot: [yellow]Loading...[/yellow]", id="sp_lbl_d_bot")
|
if self.view_mode == "backup":
|
||||||
yield Label("Status: [yellow]Validating...[/yellow]", id="sp_lbl_d_status", classes="pane_status")
|
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"):
|
with Vertical(classes="info_pane", id="op_target_pane"):
|
||||||
yield Label("Target", id="sp_lbl_t_header", classes="pane_header")
|
yield Label("Target", id="op_lbl_t_header", classes="pane_header")
|
||||||
yield Label("Community: [yellow]Loading...[/yellow]", id="sp_lbl_t_comm")
|
yield Label("Community: [yellow]Loading...[/yellow]", id="op_lbl_t_comm")
|
||||||
yield Label("Bot: [yellow]Loading...[/yellow]", id="sp_lbl_t_bot")
|
yield Label("Bot: [yellow]Loading...[/yellow]", id="op_lbl_t_bot")
|
||||||
yield Label("Status: [yellow]Validating...[/yellow]", id="sp_lbl_t_status", classes="pane_status")
|
yield Label("Status: [yellow]Validating...[/yellow]", id="op_lbl_t_status", classes="pane_status")
|
||||||
|
|
||||||
yield Label("", id="sp_lbl_status")
|
yield Label("", id="op_lbl_backup")
|
||||||
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")
|
with Vertical(id="op_actions"):
|
||||||
yield Button("Sync Server Settings", id="sp_sync", disabled=True, tooltip="Sync emojis, stickers, server name, and icon to the target community")
|
if self.view_mode == "backup":
|
||||||
yield Button("Migrate Message History", id="sp_messages", disabled=True, variant="primary", tooltip="Migrate message history from Discord to the target platform")
|
yield Button("Backup Server Profile", id="op_backup_profile", disabled=True, tooltip="Backup Discord server roles, emojis, and channel structure")
|
||||||
yield Rule(id="footer_rule")
|
yield Button("Backup Channel Messages", id="op_backup_msgs", disabled=True, variant="primary", tooltip="Select and backup message history from text channels")
|
||||||
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("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="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:
|
def on_mount(self) -> None:
|
||||||
self._rebuild_engine()
|
self._rebuild_engine()
|
||||||
# run_validate is handled by the writer's internal caching now to prevent log flooding
|
|
||||||
self.run_validate()
|
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:
|
def reload_config(self) -> None:
|
||||||
self.config = load_config(self.config_path)
|
self.config = load_config(self.config_path)
|
||||||
self.target_platform = self.config.target_platform or "fluxer"
|
self.target_platform = self.config.target_platform or "fluxer"
|
||||||
|
|
@ -153,6 +173,32 @@ class ShuttlePane(Container):
|
||||||
def _rebuild_engine(self):
|
def _rebuild_engine(self):
|
||||||
source = "backup" if self.config.tool_mode == "backup_transfer" else "live"
|
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())
|
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 ────────────────────────────────────────────────────────────
|
# ── labels ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -169,69 +215,107 @@ class ShuttlePane(Container):
|
||||||
s_disp = f'[green]"{d_name}"[/green]'
|
s_disp = f'[green]"{d_name}"[/green]'
|
||||||
b_disp = f'[green]{d_bot}[/green]'
|
b_disp = f'[green]{d_bot}[/green]'
|
||||||
elif v.get("discord_token") is False:
|
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]"
|
s_disp, b_disp = "[red]NOT FOUND[/red]", "[red]NOT FOUND[/red]"
|
||||||
else:
|
else:
|
||||||
s_disp, b_disp = "[red]INVALID TOKEN[/red]", "[red]INVALID TOKEN[/red]"
|
s_disp, b_disp = "[red]INVALID TOKEN[/red]", "[red]INVALID TOKEN[/red]"
|
||||||
else:
|
else:
|
||||||
s_disp, b_disp = "[red]NOT SET UP[/red]", "[red]NOT SET UP[/red]"
|
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}")
|
self.query_one("#op_lbl_d_server", Label).update(f"Server: {s_disp}")
|
||||||
if self.config.tool_mode == "backup_transfer":
|
if self.view_mode == "backup":
|
||||||
self.query_one("#sp_lbl_d_bot", Label).update(f"Source: {b_disp}")
|
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:
|
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
|
# Discord Side Status
|
||||||
if v.get("discord_token") and v.get("discord_server"):
|
d_err = v.get("discord_error")
|
||||||
d_status = "[green][VALID][/green]"
|
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"):
|
elif v.get("discord_timeout"):
|
||||||
d_status = "[red][TIMEOUT][/red]"
|
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:
|
else:
|
||||||
d_status = "[red][INVALID][/red]"
|
d_status = "[red]INVALID[/red]"
|
||||||
self.query_one("#sp_lbl_d_status", Label).update(f"Status: {d_status}")
|
self.query_one("#op_lbl_d_status", Label).update(f"Status: {d_status}")
|
||||||
|
|
||||||
# Target
|
# Target / Backup Info
|
||||||
plat = "Fluxer" if self.target_platform == "fluxer" else "Stoat"
|
if self.view_mode == "backup":
|
||||||
t_name = v.get("target_community_name")
|
backup_text = v.get("backup_info_text", "")
|
||||||
t_bot = v.get("target_bot_name")
|
self.query_one("#op_lbl_backup", Label).update(backup_text)
|
||||||
|
self.query_one("#op_lbl_backup", Label).display = bool(backup_text)
|
||||||
self.query_one("#sp_lbl_t_header", Label).update(plat)
|
|
||||||
|
|
||||||
if v.get("target_timeout"):
|
|
||||||
c_disp, tb_disp = "[red]TIMEOUT[/red]", "[red]TIMEOUT[/red]"
|
|
||||||
elif v.get("target_token") and v.get("target_community"):
|
|
||||||
c_disp = f'[green]"{t_name}"[/green]'
|
|
||||||
tb_disp = f'[green]{t_bot}[/green]'
|
|
||||||
elif v.get("target_token") is False:
|
|
||||||
c_disp, tb_disp = "[red]INVALID TOKEN[/red]", "[red]INVALID TOKEN[/red]"
|
|
||||||
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}")
|
# Hide target side in backup mode completely
|
||||||
self.query_one("#sp_lbl_t_bot", Label).update(f"Bot: {tb_disp}")
|
self.query_one("#op_vrule").display = False
|
||||||
|
self.query_one("#op_target_pane").display = False
|
||||||
# Target Side Status
|
|
||||||
if v.get("target_token") and v.get("target_community"):
|
enabled = (v.get("discord_token") and v.get("discord_server") and not d_missing)
|
||||||
t_status = "[green][VALID][/green]"
|
for bid in ("#op_backup_profile", "#op_backup_msgs", "#op_backup_sync"):
|
||||||
elif v.get("target_timeout"):
|
self.query_one(bid, Button).disabled = not enabled
|
||||||
t_status = "[red][TIMEOUT][/red]"
|
|
||||||
|
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:
|
else:
|
||||||
t_status = "[red][INVALID][/red]"
|
# Target
|
||||||
self.query_one("#sp_lbl_t_status", Label).update(f"Status: {t_status}")
|
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("#op_lbl_t_header", Label).update(plat)
|
||||||
|
|
||||||
|
if v.get("target_timeout"):
|
||||||
|
c_disp, tb_disp = "[red]TIMEOUT[/red]", "[red]TIMEOUT[/red]"
|
||||||
|
elif v.get("target_token") and v.get("target_community"):
|
||||||
|
c_disp = f'[green]"{t_name}"[/green]'
|
||||||
|
tb_disp = f'[green]{t_bot}[/green]'
|
||||||
|
elif v.get("target_token") is False:
|
||||||
|
c_disp, tb_disp = "[red]INVALID TOKEN[/red]", "[red]INVALID TOKEN[/red]"
|
||||||
|
else:
|
||||||
|
c_disp, tb_disp = "[red]NOT SET UP[/red]", "[red]NOT SET UP[/red]"
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
# Status
|
# Target Side Status
|
||||||
if not self.tokens_valid:
|
t_err = v.get("target_error")
|
||||||
val = "[red][INVALID][/red]"
|
tp = v.get("target_permissions", {})
|
||||||
elif not self.permissions_complete:
|
|
||||||
val = "[yellow][MISSING PERMISSIONS][/yellow]"
|
t_missing = []
|
||||||
else:
|
if t_err is None and v.get("target_token") and v.get("target_community"):
|
||||||
val = "[green][VALID][/green]"
|
if tp:
|
||||||
self.query_one("#sp_lbl_status", Label).update(f"Status: {val}")
|
t_missing = [k.replace('_', ' ').title() for k, val_p in tp.items() if not val_p]
|
||||||
|
|
||||||
# Buttons
|
if v.get("target_token") and v.get("target_community") and not t_missing:
|
||||||
for bid in ("#sp_clone", "#sp_sync", "#sp_messages", "#sp_danger"):
|
t_status = "[green]VALID[/green]"
|
||||||
self.query_one(bid, Button).disabled = not self.tokens_valid
|
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:
|
||||||
|
t_status = "[red]INVALID[/red]"
|
||||||
|
self.query_one("#op_lbl_t_status", Label).update(f"Status: {t_status}")
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_danger"):
|
||||||
|
self.query_one(bid, Button).disabled = not self.tokens_valid
|
||||||
|
|
||||||
# ── validation ────────────────────────────────────────────────────────
|
# ── validation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -241,13 +325,17 @@ class ShuttlePane(Container):
|
||||||
"discord_token": False, "discord_bot_name": None,
|
"discord_token": False, "discord_bot_name": None,
|
||||||
"discord_server": False, "discord_server_name": None,
|
"discord_server": False, "discord_server_name": None,
|
||||||
"discord_intents": {}, "discord_permissions": {},
|
"discord_intents": {}, "discord_permissions": {},
|
||||||
|
"discord_error": None,
|
||||||
"target_token": False, "target_bot_name": None,
|
"target_token": False, "target_bot_name": None,
|
||||||
"target_community": False, "target_community_name": None,
|
"target_community": False, "target_community_name": None,
|
||||||
"target_permissions": {},
|
"target_permissions": {},
|
||||||
|
"target_error": None,
|
||||||
"discord_timeout": False, "target_timeout": False,
|
"discord_timeout": False, "target_timeout": False,
|
||||||
|
"backup_info_text": "",
|
||||||
}
|
}
|
||||||
self.tokens_valid = False
|
self.tokens_valid = False
|
||||||
self.permissions_complete = False
|
self.permissions_complete = False
|
||||||
|
self.has_backup = False
|
||||||
|
|
||||||
fillers = [
|
fillers = [
|
||||||
"DISCORD_BOT_TOKEN", "FLUXER_BOT_TOKEN", "STOAT_BOT_TOKEN",
|
"DISCORD_BOT_TOKEN", "FLUXER_BOT_TOKEN", "STOAT_BOT_TOKEN",
|
||||||
|
|
@ -255,16 +343,21 @@ class ShuttlePane(Container):
|
||||||
"000000000000000000", "DISCORD_SERVER_ID", "FLUXER_COMMUNITY_ID",
|
"000000000000000000", "DISCORD_SERVER_ID", "FLUXER_COMMUNITY_ID",
|
||||||
"STOAT_SERVER_ID", "TARGET_SERVER_ID", "", None,
|
"STOAT_SERVER_ID", "TARGET_SERVER_ID", "", None,
|
||||||
]
|
]
|
||||||
if self.config.tool_mode == "backup_transfer":
|
|
||||||
d_dummy = self.config.discord_server_id in fillers
|
# Check dummies
|
||||||
else:
|
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
|
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
|
t_dummy = (self.config.target_bot_token or "") in fillers or (self.config.target_server_id or "") in fillers
|
||||||
|
|
||||||
tasks = {}
|
tasks = {}
|
||||||
if not d_dummy:
|
if not d_dummy:
|
||||||
tasks["discord"] = asyncio.create_task(self.engine.discord_reader.validate())
|
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())
|
tasks["target"] = asyncio.create_task(self.engine.writer.validate())
|
||||||
|
|
||||||
all_tasks = list(tasks.values())
|
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_server_name"] = res.get("server_name")
|
||||||
self.validation_results["discord_intents"] = res.get("intents", {})
|
self.validation_results["discord_intents"] = res.get("intents", {})
|
||||||
self.validation_results["discord_permissions"] = res.get("permissions", {})
|
self.validation_results["discord_permissions"] = res.get("permissions", {})
|
||||||
|
self.validation_results["discord_error"] = res.get("error_reason")
|
||||||
elif dt and dt not in done:
|
elif dt and dt not in done:
|
||||||
self.validation_results["discord_timeout"] = True
|
self.validation_results["discord_timeout"] = True
|
||||||
dt.cancel()
|
dt.cancel()
|
||||||
|
|
@ -294,15 +388,24 @@ class ShuttlePane(Container):
|
||||||
self.validation_results["target_community"] = res.get("community", False)
|
self.validation_results["target_community"] = res.get("community", False)
|
||||||
self.validation_results["target_community_name"] = res.get("community_name")
|
self.validation_results["target_community_name"] = res.get("community_name")
|
||||||
self.validation_results["target_permissions"] = res.get("permissions", {})
|
self.validation_results["target_permissions"] = res.get("permissions", {})
|
||||||
|
self.validation_results["target_error"] = res.get("error_reason")
|
||||||
elif tt and tt not in done:
|
elif tt and tt not in done:
|
||||||
self.validation_results["target_timeout"] = True
|
self.validation_results["target_timeout"] = True
|
||||||
tt.cancel()
|
tt.cancel()
|
||||||
|
|
||||||
discord_ok = self.validation_results.get("discord_token") and self.validation_results.get("discord_server")
|
discord_ok = self.validation_results.get("discord_token") and self.validation_results.get("discord_server")
|
||||||
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.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_id = self.config.target_server_id
|
||||||
srv_name = self.validation_results.get("target_community_name", "unknown")
|
srv_name = self.validation_results.get("target_community_name", "unknown")
|
||||||
if srv_id and srv_name:
|
if srv_id and srv_name:
|
||||||
|
|
@ -315,14 +418,16 @@ class ShuttlePane(Container):
|
||||||
dp = self.validation_results.get("discord_permissions", {})
|
dp = self.validation_results.get("discord_permissions", {})
|
||||||
if not all([di.get("message_content"), dp.get("view_channel"), dp.get("read_message_history")]):
|
if not all([di.get("message_content"), dp.get("view_channel"), dp.get("read_message_history")]):
|
||||||
self.permissions_complete = False
|
self.permissions_complete = False
|
||||||
tp = self.validation_results.get("target_permissions", {})
|
|
||||||
if tp and not all(tp.values()):
|
if self.view_mode == "shuttle":
|
||||||
self.permissions_complete = False
|
tp = self.validation_results.get("target_permissions", {})
|
||||||
|
if tp and not all(tp.values()):
|
||||||
|
self.permissions_complete = False
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
for t in all_tasks:
|
for t in all_tasks:
|
||||||
if not t.done():
|
if t and not t.done():
|
||||||
t.cancel()
|
t.cancel()
|
||||||
|
|
||||||
self._update_info_labels()
|
self._update_info_labels()
|
||||||
|
|
@ -331,16 +436,30 @@ class ShuttlePane(Container):
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
bid = event.button.id
|
bid = event.button.id
|
||||||
if not bid or not bid.startswith("sp_"):
|
if not bid or not bid.startswith("op_"):
|
||||||
return
|
return
|
||||||
if bid == "sp_clone":
|
|
||||||
|
# Migration Routing
|
||||||
|
if bid == "op_clone":
|
||||||
self._open_clone_menu()
|
self._open_clone_menu()
|
||||||
elif bid == "sp_sync":
|
elif bid == "op_sync":
|
||||||
self._open_sync_menu()
|
self._open_sync_menu()
|
||||||
elif bid == "sp_messages":
|
elif bid == "op_messages":
|
||||||
self.run_migrate_messages()
|
self.run_migrate_messages()
|
||||||
elif bid == "sp_danger":
|
elif bid == "op_danger":
|
||||||
self._open_danger_menu()
|
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) ─────────────────────────────
|
# ── (1) clone server template (combined) ─────────────────────────────
|
||||||
|
|
||||||
|
|
@ -1480,3 +1599,373 @@ class ShuttlePane(Container):
|
||||||
counts = await danger_delete_all_emojis_and_stickers(self.engine, progress_callback=on_deleted)
|
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]")
|
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.")
|
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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue