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,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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,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":
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue