520 lines
25 KiB
Python
520 lines
25 KiB
Python
"""
|
||
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()
|