disco-reaper/src/ui/backup_ops.py
2026-03-08 03:42:39 +05:30

520 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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")
def on_mount(self) -> None:
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()
if info:
backup_text = f"Last backup: [cyan]{info}[/cyan]"
self._update_ui(s_text, b_text, backup_text, valid)
except Exception as e:
self._update_ui(f"[red]Error: {e}[/red]", "", "", False)
def _get_backup_info(self) -> str | None:
profile_file = Path(f"ReaperFiles-{self.cfg_name}") / "server_profile" / "profile.json"
if profile_file.exists():
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):
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
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()
# ── 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()
@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})")
async def update_msg_count(name, count, author_name=None, message_preview=None):
modal_prog.update_stats(messages=str(count))
if author_name and message_preview:
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()
@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})")
async def update_msg_count(name, count, author_name=None, message_preview=None):
modal_prog.update_stats(messages=str(count))
if author_name and message_preview:
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()