option select modals
This commit is contained in:
parent
6c51fcd9ae
commit
c7adc61a4a
8 changed files with 738 additions and 493 deletions
|
|
@ -22,10 +22,9 @@ class MigrationState:
|
|||
# audit log tracking
|
||||
self.audit_log_channel: str | None = None
|
||||
|
||||
# message tracking
|
||||
self.message_map: Dict[str, str] = {}
|
||||
self.last_message_ids: Dict[str, str] = {}
|
||||
self.last_message_timestamps: Dict[str, str] = {}
|
||||
# message tracking per target channel
|
||||
# Format: { target_channel_id: {"message_map": {}, "last_message_id": "", "last_message_timestamp": ""} }
|
||||
self.channel_messages: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
self.load()
|
||||
|
||||
|
|
@ -48,9 +47,25 @@ class MigrationState:
|
|||
if self.messages_file and self.messages_file.exists():
|
||||
with open(self.messages_file, "r", encoding="utf-8") as f:
|
||||
msg_data = json.load(f)
|
||||
self.message_map = msg_data.get("messages", {})
|
||||
self.last_message_ids = msg_data.get("last_message_ids", {})
|
||||
self.last_message_timestamps = msg_data.get("last_message_timestamps", {})
|
||||
|
||||
# Check for new schema (nested under 'channels')
|
||||
if "channels" in msg_data:
|
||||
self.channel_messages = msg_data.get("channels", {})
|
||||
else:
|
||||
# Legacy schema detection & conversion to a default 'unknown_channel' just in case,
|
||||
# though new migrations shouldn't hit this based on previous removals.
|
||||
legacy_map = msg_data.get("messages", {})
|
||||
legacy_ids = msg_data.get("last_message_ids", {})
|
||||
legacy_times = msg_data.get("last_message_timestamps", {})
|
||||
|
||||
if legacy_map or legacy_ids or legacy_times:
|
||||
self.channel_messages = {
|
||||
"legacy_migrated_channel": {
|
||||
"message_map": legacy_map,
|
||||
"last_message_id": list(legacy_ids.values())[-1] if legacy_ids else "",
|
||||
"last_message_timestamp": list(legacy_times.values())[-1] if legacy_times else ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -74,9 +89,7 @@ class MigrationState:
|
|||
if not self.messages_file:
|
||||
return
|
||||
data = {
|
||||
"last_message_ids": self.last_message_ids,
|
||||
"last_message_timestamps": self.last_message_timestamps,
|
||||
"messages": self.message_map
|
||||
"channels": self.channel_messages
|
||||
}
|
||||
with open(self.messages_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
|
@ -170,31 +183,55 @@ class MigrationState:
|
|||
def set_target_sticker_mapping(self, discord_id: str, target_id: str):
|
||||
self.set_sticker_mapping(discord_id, target_id)
|
||||
|
||||
def get_target_message_id(self, discord_id: str) -> str | None:
|
||||
return self.get_fluxer_message_id(discord_id)
|
||||
def get_target_message_id(self, target_channel_id: str, discord_id: str) -> str | None:
|
||||
return self.get_fluxer_message_id(target_channel_id, discord_id)
|
||||
|
||||
def set_target_message_mapping(self, discord_id: str, target_id: str):
|
||||
self.set_message_mapping(discord_id, target_id)
|
||||
def set_target_message_mapping(self, target_channel_id: str, discord_id: str, target_id: str):
|
||||
self.set_message_mapping(target_channel_id, discord_id, target_id)
|
||||
|
||||
# --- Message Management ---
|
||||
|
||||
def set_message_mapping(self, discord_id: str, fluxer_id: str):
|
||||
self.message_map[str(discord_id)] = str(fluxer_id)
|
||||
def _ensure_channel_tracking(self, target_channel_id: str):
|
||||
if str(target_channel_id) not in self.channel_messages:
|
||||
self.channel_messages[str(target_channel_id)] = {
|
||||
"message_map": {},
|
||||
"last_message_id": "",
|
||||
"last_message_timestamp": "",
|
||||
"total_messages": 0,
|
||||
"total_files": 0
|
||||
}
|
||||
|
||||
def increment_stats(self, target_channel_id: str, messages: int = 1, files: int = 0):
|
||||
self._ensure_channel_tracking(target_channel_id)
|
||||
c = self.channel_messages[str(target_channel_id)]
|
||||
c["total_messages"] = c.get("total_messages", 0) + messages
|
||||
c["total_files"] = c.get("total_files", 0) + files
|
||||
self.save_messages()
|
||||
|
||||
def set_message_mapping(self, target_channel_id: str, discord_id: str, fluxer_id: str):
|
||||
self._ensure_channel_tracking(target_channel_id)
|
||||
self.channel_messages[str(target_channel_id)]["message_map"][str(discord_id)] = str(fluxer_id)
|
||||
self.save_messages()
|
||||
|
||||
def get_fluxer_message_id(self, discord_id: str) -> str | None:
|
||||
return self.message_map.get(str(discord_id))
|
||||
def get_fluxer_message_id(self, target_channel_id: str, discord_id: str) -> str | None:
|
||||
if str(target_channel_id) in self.channel_messages:
|
||||
return self.channel_messages[str(target_channel_id)]["message_map"].get(str(discord_id))
|
||||
return None
|
||||
|
||||
def update_last_message_timestamp(self, channel_id: str, timestamp: str):
|
||||
self.last_message_timestamps[str(channel_id)] = timestamp
|
||||
def update_last_message_timestamp(self, target_channel_id: str, timestamp: str):
|
||||
self._ensure_channel_tracking(target_channel_id)
|
||||
self.channel_messages[str(target_channel_id)]["last_message_timestamp"] = str(timestamp)
|
||||
self.save_messages()
|
||||
|
||||
def update_last_message_id(self, channel_id: str, message_id: str):
|
||||
self.last_message_ids[str(channel_id)] = message_id
|
||||
def update_last_message_id(self, target_channel_id: str, message_id: str):
|
||||
self._ensure_channel_tracking(target_channel_id)
|
||||
self.channel_messages[str(target_channel_id)]["last_message_id"] = str(message_id)
|
||||
self.save_messages()
|
||||
|
||||
def get_last_message_id(self, channel_id: str) -> str | None:
|
||||
return self.last_message_ids.get(str(channel_id))
|
||||
def get_last_message_id(self, target_channel_id: str) -> str | None:
|
||||
if str(target_channel_id) in self.channel_messages:
|
||||
return self.channel_messages[str(target_channel_id)].get("last_message_id")
|
||||
return None
|
||||
|
||||
# --- Danger Zone Clearing ---
|
||||
|
||||
|
|
@ -217,9 +254,7 @@ class MigrationState:
|
|||
|
||||
def clear_message_history(self):
|
||||
"""Clears all message mappings and timestamps."""
|
||||
self.message_map.clear()
|
||||
self.last_message_ids.clear()
|
||||
self.last_message_timestamps.clear()
|
||||
self.channel_messages.clear()
|
||||
self.save_messages()
|
||||
|
||||
def set_folder(self, server_id: str, clean_name: str, base_dir: Path | str = ""):
|
||||
|
|
@ -240,5 +275,4 @@ class MigrationState:
|
|||
self.state_file = new_folder / "state-migration.json"
|
||||
self.messages_file = new_folder / "message-tracker.json"
|
||||
|
||||
self.save_state()
|
||||
self.save_messages()
|
||||
self.load()
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class DiscordExporter:
|
|||
# Create safe folder name
|
||||
import re
|
||||
safe_name = re.sub(r'[^a-zA-Z0-9_\-\.]', '_', self.server_name)
|
||||
self.export_path = self.base_dir / f"DISCORD-{self.server_id}"
|
||||
self.export_path = self.base_dir / f"DISCORD_BACKUP-{self.server_id}"
|
||||
self.export_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Consolidate media into one folder
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ def clean_mentions(content: str, guild, user_mentions=None, role_mentions=None,
|
|||
return content
|
||||
|
||||
|
||||
async def analyze_migration(context: MigrationContext, source_channel_id: int, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None) -> Dict[str, int]:
|
||||
async def analyze_migration(context: MigrationContext, source_channel_id: int, after_message_id: int | None = None, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, int]:
|
||||
"""
|
||||
Scans channel history to count messages, threads, and attachments.
|
||||
"""
|
||||
|
|
@ -92,12 +92,12 @@ async def analyze_migration(context: MigrationContext, source_channel_id: int, a
|
|||
stats["attachments"] += len(msg.attachments)
|
||||
|
||||
if progress_callback and stats["messages"] % 10 == 0:
|
||||
await progress_callback(stats["messages"])
|
||||
await progress_callback(stats)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def migrate_messages(context: MigrationContext, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None) -> Dict[str, Any]:
|
||||
async def migrate_messages(context: MigrationContext, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, Any]:
|
||||
"""Migrate messages for a specific channel and returns detailed statistics."""
|
||||
stats = {
|
||||
"messages": 0,
|
||||
|
|
@ -189,7 +189,7 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
|
|||
# Check if this message is a reply
|
||||
reply_to_fluxer_id = None
|
||||
if msg.reference and msg.reference.message_id:
|
||||
reply_to_fluxer_id = context.state.get_fluxer_message_id(str(msg.reference.message_id))
|
||||
reply_to_fluxer_id = context.state.get_fluxer_message_id(target_channel_id, str(msg.reference.message_id))
|
||||
|
||||
fluxer_msg_id = await context.fluxer_writer.send_message(
|
||||
channel_id=target_channel_id,
|
||||
|
|
@ -203,11 +203,12 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
|
|||
)
|
||||
|
||||
if fluxer_msg_id:
|
||||
context.state.set_message_mapping(str(msg.id), fluxer_msg_id)
|
||||
context.state.set_message_mapping(target_channel_id, str(msg.id), fluxer_msg_id)
|
||||
|
||||
context.state.update_last_message_timestamp(str(source_channel_id), str(msg.created_at))
|
||||
context.state.update_last_message_id(str(source_channel_id), str(msg.id))
|
||||
context.state.update_last_message_timestamp(target_channel_id, str(msg.created_at))
|
||||
context.state.update_last_message_id(target_channel_id, str(msg.id))
|
||||
stats["messages"] += 1
|
||||
context.state.increment_stats(target_channel_id, messages=1, files=len(files) if files else 0)
|
||||
|
||||
# Check for associated thread (Normal case: parent message is migrated)
|
||||
if hasattr(msg, 'thread') and msg.thread:
|
||||
|
|
@ -240,7 +241,7 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
|
|||
stats["last_message_url"] = msg.jump_url
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(stats["messages"])
|
||||
await progress_callback(stats)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process message {msg.id}: {e}")
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ def clean_mentions(content: str, guild, user_mentions=None, role_mentions=None,
|
|||
return content
|
||||
|
||||
|
||||
async def analyze_migration(context: MigrationContext, source_channel_id: int, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None) -> Dict[str, int]:
|
||||
async def analyze_migration(context: MigrationContext, source_channel_id: int, after_message_id: int | None = None, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, int]:
|
||||
"""
|
||||
Scans channel history to count messages, threads, and attachments.
|
||||
"""
|
||||
|
|
@ -91,12 +91,12 @@ async def analyze_migration(context: MigrationContext, source_channel_id: int, a
|
|||
stats["attachments"] += len(msg.attachments)
|
||||
|
||||
if progress_callback and stats["messages"] % 10 == 0:
|
||||
await progress_callback(stats["messages"])
|
||||
await progress_callback(stats)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def migrate_messages(context: MigrationContext, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None) -> Dict[str, Any]:
|
||||
async def migrate_messages(context: MigrationContext, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, Any]:
|
||||
"""Migrate messages for a specific channel using Stoat masquerade for author impersonation."""
|
||||
stats = {
|
||||
"messages": 0,
|
||||
|
|
@ -186,7 +186,7 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
|
|||
# Check if this message is a reply
|
||||
reply_to_stoat_id = None
|
||||
if msg.reference and msg.reference.message_id:
|
||||
reply_to_stoat_id = context.state.get_target_message_id(str(msg.reference.message_id))
|
||||
reply_to_stoat_id = context.state.get_target_message_id(target_channel_id, str(msg.reference.message_id))
|
||||
|
||||
stoat_msg_id = await context.stoat_writer.send_message(
|
||||
channel_id=target_channel_id,
|
||||
|
|
@ -200,11 +200,12 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
|
|||
)
|
||||
|
||||
if stoat_msg_id:
|
||||
context.state.set_message_mapping(str(msg.id), stoat_msg_id)
|
||||
context.state.set_message_mapping(target_channel_id, str(msg.id), stoat_msg_id)
|
||||
|
||||
context.state.update_last_message_timestamp(str(source_channel_id), str(msg.created_at))
|
||||
context.state.update_last_message_id(str(source_channel_id), str(msg.id))
|
||||
context.state.update_last_message_timestamp(target_channel_id, str(msg.created_at))
|
||||
context.state.update_last_message_id(target_channel_id, str(msg.id))
|
||||
stats["messages"] += 1
|
||||
context.state.increment_stats(target_channel_id, messages=1, files=len(files) if files else 0)
|
||||
|
||||
# Check for associated thread (Normal case: parent message is migrated)
|
||||
if hasattr(msg, 'thread') and msg.thread:
|
||||
|
|
@ -236,7 +237,7 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
|
|||
stats["last_message_url"] = msg.jump_url
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(stats["messages"])
|
||||
await progress_callback(stats)
|
||||
except Exception as e:
|
||||
# If it's a permission error, stop the entire migration
|
||||
if "MissingPermission" in str(e):
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from textual import work
|
|||
from src.core.configuration import load_config
|
||||
from src.core.base import MigrationContext
|
||||
from src.disco_reaper.exporter import DiscordExporter
|
||||
from src.ui.modals import ProgressScreen, ChannelSelectScreen, ReportModal
|
||||
from src.ui.modals import ProgressScreen, ChannelSelectScreen
|
||||
|
||||
|
||||
class BackupPane(Container):
|
||||
|
|
@ -126,6 +126,7 @@ class BackupPane(Container):
|
|||
modal = ProgressScreen()
|
||||
self.app.call_from_thread(self.app.push_screen, modal)
|
||||
await asyncio.sleep(0.1)
|
||||
self.app.call_from_thread(modal.phase_progress)
|
||||
|
||||
try:
|
||||
self.app.call_from_thread(modal.set_status, "Starting readers...")
|
||||
|
|
@ -143,17 +144,17 @@ class BackupPane(Container):
|
|||
e_count, s_count = await self.exporter.export_assets()
|
||||
|
||||
self.app.call_from_thread(modal.write, f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]")
|
||||
self.app.call_from_thread(modal.write, f"- {cat_count} categories & {chan_count} channels")
|
||||
self.app.call_from_thread(modal.write, f"- {e_count} emojis, {s_count} stickers.")
|
||||
self.app.call_from_thread(modal.phase_report, "Profile Backup")
|
||||
|
||||
except discord.Forbidden as e:
|
||||
self.app.call_from_thread(modal.write, f"[bold red]Backup failed: {e}[/bold red]")
|
||||
self.app.call_from_thread(modal.phase_report, "Profile Backup", "error")
|
||||
except Exception as e:
|
||||
self.app.call_from_thread(modal.write, f"[bold red]Error: {e}[/bold red]")
|
||||
self.app.call_from_thread(modal.phase_report, "Profile Backup", "error")
|
||||
finally:
|
||||
await self.engine.close_connections()
|
||||
self.app.call_from_thread(modal.allow_close)
|
||||
self.app.call_from_thread(self.app.push_screen, ReportModal("Backup Complete", "Server profile and structure successfully exported."))
|
||||
|
||||
@work(exclusive=True, thread=True)
|
||||
async def run_backup_messages(self) -> None:
|
||||
|
|
@ -190,30 +191,49 @@ class BackupPane(Container):
|
|||
|
||||
self.app.call_from_thread(self.app.pop_screen)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.create_future()
|
||||
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)
|
||||
def check_channels(reply: dict | None) -> None:
|
||||
if not future.done():
|
||||
future.set_result(reply)
|
||||
|
||||
self.app.call_from_thread(
|
||||
self.app.push_screen,
|
||||
ChannelSelectScreen(eligible_channels, cat_map, backed_up_ids, any_found),
|
||||
check_channels,
|
||||
)
|
||||
self.app.call_from_thread(
|
||||
self.app.push_screen,
|
||||
ChannelSelectScreen(eligible_channels, cat_map, backed_up_ids, any_found),
|
||||
check_channels,
|
||||
)
|
||||
|
||||
reply = await future
|
||||
if not reply:
|
||||
return
|
||||
reply = await future
|
||||
if not reply:
|
||||
return
|
||||
|
||||
selected_ids = reply["channels"]
|
||||
force_overwrite = reply["force"]
|
||||
selected_ids = reply["channels"]
|
||||
force_overwrite = reply["force"]
|
||||
selected_channels = [c for c in eligible_channels if c.id in selected_ids]
|
||||
|
||||
selected_channels = [c for c in eligible_channels if c.id in selected_ids]
|
||||
# Phase 2: Confirmation
|
||||
self.app.call_from_thread(self.app.push_screen, modal_prog)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
msg = "Sync existing backups" if not force_overwrite else "Overwriting existing backups"
|
||||
self.app.call_from_thread(modal_prog.set_status, f"Awaiting Confirmation to backup [bold]{len(selected_channels)}[/bold] channels...")
|
||||
self.app.call_from_thread(modal_prog.show_info, f"[cyan]{msg}[/cyan]", f"Targets: {', '.join([c.name for c in selected_channels[:3]])}{'...' if len(selected_channels) > 3 else ''}")
|
||||
|
||||
self.app.call_from_thread(self.app.push_screen, modal_prog)
|
||||
await asyncio.sleep(0.1)
|
||||
choice = await modal_prog.phase_wait_confirm()
|
||||
if choice == "btn_back":
|
||||
self.app.call_from_thread(modal_prog.dismiss)
|
||||
continue
|
||||
elif choice == "btn_main_menu":
|
||||
self.app.call_from_thread(modal_prog.dismiss)
|
||||
self.app.call_from_thread(self.app.switch_screen, "config_selection")
|
||||
return
|
||||
|
||||
# Proceed to progress
|
||||
break
|
||||
|
||||
self.app.call_from_thread(modal_prog.phase_progress)
|
||||
|
||||
total_chans = len(selected_channels)
|
||||
self.app.call_from_thread(modal_prog.set_status, "Backing up messages...")
|
||||
|
|
@ -236,19 +256,20 @@ class BackupPane(Container):
|
|||
|
||||
await self.exporter.export_metadata()
|
||||
self.app.call_from_thread(modal_prog.write, "[bold green]Message backup complete![/bold green]")
|
||||
self.app.call_from_thread(modal_prog.phase_report, "Message Backup")
|
||||
|
||||
except Exception as e:
|
||||
self.app.call_from_thread(modal_prog.write, f"[bold red]Message backup failed: {e}[/bold red]")
|
||||
self.app.call_from_thread(modal_prog.phase_report, "Message Backup", "error")
|
||||
finally:
|
||||
await self.engine.close_connections()
|
||||
self.app.call_from_thread(modal_prog.allow_close)
|
||||
self.app.call_from_thread(self.app.push_screen, ReportModal("Backup Complete", "Channel messages and threads successfully backed up."))
|
||||
|
||||
@work(exclusive=True, thread=True)
|
||||
async def run_backup_sync(self) -> None:
|
||||
modal_prog = ProgressScreen()
|
||||
self.app.call_from_thread(self.app.push_screen, modal_prog)
|
||||
await asyncio.sleep(0.1)
|
||||
self.app.call_from_thread(modal_prog.phase_progress)
|
||||
|
||||
try:
|
||||
self.app.call_from_thread(modal_prog.set_status, "Starting sync...")
|
||||
|
|
@ -288,10 +309,10 @@ class BackupPane(Container):
|
|||
|
||||
await self.exporter.export_metadata()
|
||||
self.app.call_from_thread(modal_prog.write, "[bold green]Sync operation complete![/bold green]")
|
||||
self.app.call_from_thread(modal_prog.phase_report, "Backup Sync")
|
||||
|
||||
except Exception as e:
|
||||
self.app.call_from_thread(modal_prog.write, f"[bold red]Sync failed: {e}[/bold red]")
|
||||
self.app.call_from_thread(modal_prog.phase_report, "Backup Sync", "error")
|
||||
finally:
|
||||
await self.engine.close_connections()
|
||||
self.app.call_from_thread(modal_prog.allow_close)
|
||||
self.app.call_from_thread(self.app.push_screen, ReportModal("Sync Complete", "Backup cleanly synced with latest Discord data."))
|
||||
|
|
|
|||
349
src/ui/modals.py
349
src/ui/modals.py
|
|
@ -9,6 +9,22 @@ from textual.screen import ModalScreen, Screen
|
|||
|
||||
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
class UILogHandler(logging.Handler):
|
||||
"""Custom logging handler to send logs to the Textual UI RichLog."""
|
||||
def __init__(self, callback):
|
||||
super().__init__()
|
||||
self.callback = callback
|
||||
self.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(name)s: %(message)s', '%H:%M:%S'))
|
||||
|
||||
def emit(self, record):
|
||||
try:
|
||||
msg = self.format(record)
|
||||
self.callback(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ProgressScreen – unified full-screen progress / log / stats display
|
||||
|
|
@ -44,12 +60,17 @@ class ProgressScreen(Screen[None]):
|
|||
.stat_label { width: 1fr; content-align: center middle; text-style: bold; }
|
||||
|
||||
#prog_log { height: 1fr; margin-bottom: 1; border: solid $primary; }
|
||||
#live_log { height: 10; margin-bottom: 1; border: solid yellow; }
|
||||
#prog_loader { margin-bottom: 1; }
|
||||
#prog_bar_container { height: auto; width: 100%; }
|
||||
#prog_bar_container { height: auto; width: 100%; align: center middle; }
|
||||
#prog_bar { margin-bottom: 1; width: 80%; }
|
||||
|
||||
#prog_actions { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; }
|
||||
#btn_close_progress { width: 1fr; margin: 0 1; }
|
||||
#info_container { height: auto; layout: vertical; border: solid $secondary; padding: 1; margin-bottom: 1; display: none; }
|
||||
.info_label { text-style: bold; content-align: center middle; width: 100%; color: $secondary-lighten-2; }
|
||||
|
||||
#prog_actions { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; layout: vertical; }
|
||||
.action_row { height: auto; layout: horizontal; }
|
||||
.action_row Button { width: 1fr; margin: 0 1; }
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
|
|
@ -66,20 +87,58 @@ class ProgressScreen(Screen[None]):
|
|||
yield Label("Files: 0", id="stat_files", classes="stat_label")
|
||||
|
||||
yield LoadingIndicator(id="prog_loader")
|
||||
with Center(id="prog_bar_container"):
|
||||
pb = ProgressBar(total=None, show_eta=False, id="prog_bar")
|
||||
pb.display = False
|
||||
yield pb
|
||||
|
||||
with Vertical(id="info_container"):
|
||||
yield Label("No previous migration data.", id="info_migration_status", classes="info_label")
|
||||
yield Label("New items detected.", id="info_new_items", classes="info_label")
|
||||
|
||||
# Progress bar moved inside info container
|
||||
with Center(id="prog_bar_container"):
|
||||
pb = ProgressBar(total=None, show_eta=False, id="prog_bar")
|
||||
pb.display = False
|
||||
yield pb
|
||||
|
||||
yield RichLog(id="prog_log", highlight=True, markup=True)
|
||||
yield RichLog(id="live_log", highlight=True, markup=True)
|
||||
|
||||
with Horizontal(id="prog_actions"):
|
||||
yield Button("Close", id="btn_close_progress", disabled=True)
|
||||
with Vertical(id="prog_actions"):
|
||||
with Horizontal(classes="action_row", id="prog_actions_row1"):
|
||||
yield Button("Start from First", id="btn_start_first", disabled=True, variant="primary")
|
||||
yield Button("Continue Migration", id="btn_continue", disabled=True, variant="success")
|
||||
yield Button("Start from ID", id="btn_start_id", disabled=True, variant="warning")
|
||||
with Horizontal(classes="action_row", id="prog_actions_row2"):
|
||||
yield Button("Back", id="btn_back", disabled=False)
|
||||
yield Button("Main Menu", id="btn_main_menu", disabled=False)
|
||||
with Horizontal(classes="action_row", id="prog_actions_cancel"):
|
||||
yield Button("Cancel", id="btn_cancel", variant="error")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
self.confirm_future = None
|
||||
self.cancel_callback = None
|
||||
self.start_time = time.time()
|
||||
self.timer_event = self.set_interval(1.0, self.update_timer)
|
||||
|
||||
# Hide all action rows by default (fetch phase = no buttons)
|
||||
try: self.query_one("#prog_actions_row1", Horizontal).display = False
|
||||
except Exception: pass
|
||||
try: self.query_one("#prog_actions_row2", Horizontal).display = False
|
||||
except Exception: pass
|
||||
try: self.query_one("#prog_actions_cancel", Horizontal).display = False
|
||||
except Exception: pass
|
||||
|
||||
# Intercept Python logs and pipe to the #live_log
|
||||
self.log_handler = UILogHandler(self.write_live)
|
||||
# Attach to root logger
|
||||
logging.getLogger().addHandler(self.log_handler)
|
||||
# Also let's capture discord.py logs specifically if they aren't propagating
|
||||
logging.getLogger("discord").addHandler(self.log_handler)
|
||||
|
||||
def on_unmount(self):
|
||||
# Detach log handler when UI is cleanly removed
|
||||
if hasattr(self, "log_handler"):
|
||||
logging.getLogger().removeHandler(self.log_handler)
|
||||
logging.getLogger("discord").removeHandler(self.log_handler)
|
||||
|
||||
def update_timer(self):
|
||||
elapsed = int(time.time() - self.start_time)
|
||||
|
|
@ -90,10 +149,25 @@ class ProgressScreen(Screen[None]):
|
|||
pass
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
if event.button.id == "btn_close_progress":
|
||||
btn_id = event.button.id
|
||||
if self.confirm_future and not self.confirm_future.done():
|
||||
self.confirm_future.set_result(btn_id)
|
||||
return
|
||||
|
||||
# If Cancel is pressed during operation, invoke callback and dismiss
|
||||
if btn_id == "btn_cancel":
|
||||
if self.cancel_callback:
|
||||
self.cancel_callback()
|
||||
if self.timer_event:
|
||||
self.timer_event.stop()
|
||||
self.dismiss(None)
|
||||
self.dismiss("btn_cancel")
|
||||
return
|
||||
|
||||
# If operation is done (report phase), just dismiss with the action
|
||||
if btn_id in ["btn_back", "btn_main_menu"]:
|
||||
if self.timer_event:
|
||||
self.timer_event.stop()
|
||||
self.dismiss(btn_id)
|
||||
|
||||
def write(self, message: str):
|
||||
try:
|
||||
|
|
@ -101,6 +175,12 @@ class ProgressScreen(Screen[None]):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
def write_live(self, message: str):
|
||||
try:
|
||||
self.query_one("#live_log", RichLog).write(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
write_to_log = write
|
||||
|
||||
def set_status(self, status: str):
|
||||
|
|
@ -132,79 +212,119 @@ class ProgressScreen(Screen[None]):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
def allow_close(self):
|
||||
if self.timer_event:
|
||||
self.timer_event.stop()
|
||||
async def phase_wait_confirm(self, show_continue: bool = False):
|
||||
"""Phase 2: Wait for user confirmation after analysis."""
|
||||
try: self.query_one("#prog_loader", LoadingIndicator).display = False
|
||||
except Exception: pass
|
||||
|
||||
try: self.query_one("#prog_bar", ProgressBar).display = False
|
||||
except Exception: pass
|
||||
|
||||
# Show confirmation buttons
|
||||
try: self.query_one("#prog_actions_row1", Horizontal).display = True
|
||||
except Exception: pass
|
||||
try: self.query_one("#prog_actions_row2", Horizontal).display = True
|
||||
except Exception: pass
|
||||
try: self.query_one("#prog_actions_cancel", Horizontal).display = False
|
||||
except Exception: pass
|
||||
|
||||
try: self.query_one("#btn_start_first", Button).disabled = False
|
||||
except Exception: pass
|
||||
|
||||
try: self.query_one("#btn_start_id", Button).disabled = False
|
||||
except Exception: pass
|
||||
|
||||
try:
|
||||
btn = self.query_one("#btn_close_progress", Button)
|
||||
btn.disabled = False
|
||||
btn.variant = "success"
|
||||
self.query_one("#prog_loader", LoadingIndicator).display = False
|
||||
bar = self.query_one("#prog_bar", ProgressBar)
|
||||
bar.display = True
|
||||
bar.update(total=100, progress=100)
|
||||
if show_continue:
|
||||
self.query_one("#btn_continue", Button).disabled = False
|
||||
self.query_one("#btn_continue", Button).display = True
|
||||
else:
|
||||
self.query_one("#btn_continue", Button).display = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
self.confirm_future = loop.create_future()
|
||||
|
||||
# Wait until the user clicks one of the buttons
|
||||
choice = await self.confirm_future
|
||||
return choice
|
||||
|
||||
def phase_progress(self):
|
||||
"""Phase 3: The actual operation begins. Only Cancel visible."""
|
||||
try: self.query_one("#prog_actions_row1", Horizontal).display = False
|
||||
except Exception: pass
|
||||
|
||||
try: self.query_one("#prog_actions_row2", Horizontal).display = False
|
||||
except Exception: pass
|
||||
|
||||
# Show Cancel button
|
||||
try: self.query_one("#prog_actions_cancel", Horizontal).display = True
|
||||
except Exception: pass
|
||||
|
||||
try: self.query_one("#prog_loader", LoadingIndicator).display = True
|
||||
except Exception: pass
|
||||
|
||||
try: self.query_one("#info_migration_status", Label).display = False
|
||||
except Exception: pass
|
||||
|
||||
try: self.query_one("#info_new_items", Label).display = False
|
||||
except Exception: pass
|
||||
|
||||
def phase_report(self, operation_name: str, status: str = "complete"):
|
||||
"""Phase 4: Operation is done. Show Back + Main Menu.
|
||||
|
||||
status can be: 'complete', 'stopped', 'error'
|
||||
"""
|
||||
try:
|
||||
if self.timer_event:
|
||||
self.timer_event.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Format status title with color
|
||||
status_map = {
|
||||
"complete": ("[bold green]", "Complete"),
|
||||
"stopped": ("[bold yellow]", "Stopped"),
|
||||
"error": ("[bold red]", "Error"),
|
||||
}
|
||||
color, label = status_map.get(status, ("[bold]", status.capitalize()))
|
||||
self.set_status(f"{color}{operation_name} {label}![/{color.split(']')[0].lstrip('[')}]")
|
||||
|
||||
# Hide loader
|
||||
try: self.query_one("#prog_loader", LoadingIndicator).display = False
|
||||
except Exception: pass
|
||||
|
||||
# Hide progress bar (no need to show 100% bar)
|
||||
try: self.query_one("#prog_bar", ProgressBar).display = False
|
||||
except Exception: pass
|
||||
|
||||
# Hide Cancel, show Back + Main Menu
|
||||
try: self.query_one("#prog_actions_cancel", Horizontal).display = False
|
||||
except Exception: pass
|
||||
try: self.query_one("#prog_actions_row1", Horizontal).display = False
|
||||
except Exception: pass
|
||||
try: self.query_one("#prog_actions_row2", Horizontal).display = True
|
||||
except Exception: pass
|
||||
|
||||
try:
|
||||
back_btn = self.query_one("#btn_back", Button)
|
||||
back_btn.disabled = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ReportModal – simple post-operation report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ReportModal(ModalScreen[None]):
|
||||
"""Modal to display a post-operation report."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ReportModal { align: center middle; }
|
||||
#report_dialog {
|
||||
width: 60;
|
||||
height: auto;
|
||||
border: thick $background 80%;
|
||||
background: $surface;
|
||||
padding: 1 2;
|
||||
}
|
||||
#report_title { text-style: bold; margin-bottom: 1; content-align: center middle; width: 100%; color: green; }
|
||||
#report_content { margin-bottom: 2; height: auto; }
|
||||
#report_btn { width: 1fr; margin: 0 1; }
|
||||
"""
|
||||
|
||||
def __init__(self, title: str, report_text: str):
|
||||
super().__init__()
|
||||
self.report_title = title
|
||||
self.report_text = report_text
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="report_dialog"):
|
||||
yield Label(self.report_title, id="report_title")
|
||||
yield Label(self.report_text, id="report_content")
|
||||
yield Button("OK", variant="primary", id="report_btn")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConfirmModal – simple yes / no
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ConfirmModal(ModalScreen[bool]):
|
||||
"""Simple Yes / No confirmation modal."""
|
||||
DEFAULT_CSS = "ConfirmModal { align: center middle; }"
|
||||
|
||||
def __init__(self, message: str, danger: bool = False):
|
||||
super().__init__()
|
||||
self._message = message
|
||||
self._danger = danger
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="confirm_dialog"):
|
||||
yield Label(self._message, id="confirm_msg")
|
||||
with Horizontal(id="confirm_buttons"):
|
||||
yield Button("Confirm", variant="error" if self._danger else "success", id="btn_yes")
|
||||
yield Button("Cancel", variant="primary", id="btn_no")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
self.dismiss(event.button.id == "btn_yes")
|
||||
def show_info(self, migration_status: str, items_status: str):
|
||||
try:
|
||||
info = self.query_one("#info_container", Vertical)
|
||||
info.display = True
|
||||
self.query_one("#info_migration_status", Label).update(migration_status)
|
||||
self.query_one("#info_new_items", Label).update(items_status)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def allow_close(self):
|
||||
"""Legacy fallback: show Back + Main Menu buttons for early exit."""
|
||||
self.phase_report("Operation", "error")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SubMenuModal – generic labelled-button list
|
||||
|
|
@ -235,6 +355,71 @@ class SubMenuModal(ModalScreen[str]):
|
|||
self.dismiss(event.button.id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OptionSelectModal – radio-button selection list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OptionSelectModal(ModalScreen[list[str]]):
|
||||
"""A modal that presents a list of options using RadioButtons (for multi-select) and a Proceed button."""
|
||||
DEFAULT_CSS = """
|
||||
OptionSelectModal { align: center middle; }
|
||||
#opt_dialog {
|
||||
width: 60;
|
||||
height: auto;
|
||||
border: solid green;
|
||||
background: $surface;
|
||||
padding: 1 2;
|
||||
}
|
||||
#opt_title { text-style: bold; margin-bottom: 1; text-align: center; width: 100%; }
|
||||
#opt_scroll { margin-bottom: 1; border: solid $primary; max-height: 12; padding: 1; }
|
||||
#opt_buttons { height: auto; }
|
||||
#opt_buttons Button { width: 1fr; margin: 0 1; }
|
||||
#opt_batch_buttons { height: auto; margin-bottom: 1; }
|
||||
#opt_batch_buttons Button { width: 1fr; margin: 0 1; }
|
||||
"""
|
||||
|
||||
def __init__(self, title: str, options: list[tuple[str, str]]):
|
||||
"""options: list of (option_id, label)"""
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._options = options
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="opt_dialog"):
|
||||
yield Label(self._title, id="opt_title")
|
||||
with Horizontal(id="opt_batch_buttons"):
|
||||
yield Button("Select All", id="btn_opt_all")
|
||||
yield Button("Deselect All", id="btn_opt_none")
|
||||
|
||||
with VerticalScroll(id="opt_scroll"):
|
||||
for opt_id, label in self._options:
|
||||
yield RadioButton(label, id=f"opt_{opt_id}")
|
||||
|
||||
yield Rule()
|
||||
with Horizontal(id="opt_buttons"):
|
||||
yield Button("Proceed", variant="success", id="btn_opt_ok")
|
||||
yield Button("Back", id="btn_opt_back")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
if event.button.id == "btn_opt_back":
|
||||
self.dismiss(None)
|
||||
elif event.button.id == "btn_opt_ok":
|
||||
selected = []
|
||||
for rb in self.query(RadioButton):
|
||||
if rb.value:
|
||||
selected.append(rb.id.split("_", 1)[1])
|
||||
if selected:
|
||||
self.dismiss(selected)
|
||||
else:
|
||||
self.app.notify("Please select at least one option.", severity="warning")
|
||||
elif event.button.id == "btn_opt_all":
|
||||
for rb in self.query(RadioButton):
|
||||
rb.value = True
|
||||
elif event.button.id == "btn_opt_none":
|
||||
for rb in self.query(RadioButton):
|
||||
rb.value = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChannelPickerModal – single-channel selection (shuttle)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -322,11 +507,11 @@ class ChannelPickerScreen(Screen[tuple]):
|
|||
yield Rule()
|
||||
with Horizontal(id="chanpick_buttons"):
|
||||
yield Button("Select", variant="success", id="btn_pick_ok")
|
||||
yield Button("Cancel", id="btn_pick_cancel")
|
||||
yield Button("Back", id="btn_pick_back")
|
||||
yield Footer()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
if event.button.id == "btn_pick_cancel":
|
||||
if event.button.id == "btn_pick_back":
|
||||
self.dismiss(None)
|
||||
elif event.button.id == "btn_pick_ok":
|
||||
src_val = None
|
||||
|
|
@ -442,7 +627,7 @@ class ChannelSelectScreen(Screen[dict]):
|
|||
yield Button("Force Overwrite", variant="error", id="btn_force")
|
||||
else:
|
||||
yield Button("Backup", variant="success", id="btn_backup")
|
||||
yield Button("Cancel", id="btn_cancel_chan")
|
||||
yield Button("Back", id="btn_cancel_chan")
|
||||
yield Footer()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
|
|
|
|||
|
|
@ -1223,7 +1223,7 @@ class MigrationCLI:
|
|||
server_name = self.validation_results.get("discord_server_name", "server")
|
||||
|
||||
# Check for existing migration
|
||||
last_migrated_id = self.engine.state.get_last_message_id(str(source_channel.id))
|
||||
last_migrated_id = self.engine.state.get_last_message_id(str(target_channel.get('id')))
|
||||
next_msg = None
|
||||
if last_migrated_id:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from src.core.configuration import load_config
|
|||
from src.core.base import MigrationContext
|
||||
from src.core.audit import log_audit_event
|
||||
from src.ui.modals import (
|
||||
ProgressScreen, ReportModal, ConfirmModal, SubMenuModal, ChannelPickerScreen,
|
||||
ProgressScreen, SubMenuModal, ChannelPickerScreen, OptionSelectModal,
|
||||
)
|
||||
|
||||
import src.fluxer.roles_permissions as fluxer_roles
|
||||
|
|
@ -106,9 +106,7 @@ class ShuttlePane(Container):
|
|||
yield Label("Status: [yellow]Validating...[/yellow]", id="sp_lbl_status")
|
||||
with Vertical(id="sp_actions"):
|
||||
yield Button("Clone Server Template", id="sp_clone", disabled=True)
|
||||
yield Button("Copy Roles & Permissions", id="sp_roles", disabled=True)
|
||||
yield Button("Copy Emojis & Stickers", id="sp_emojis", disabled=True)
|
||||
yield Button("Sync Server Profile", id="sp_metadata", disabled=True)
|
||||
yield Button("Sync Server Settings", id="sp_sync", disabled=True)
|
||||
yield Button("Migrate Message History", id="sp_messages", disabled=True)
|
||||
yield Rule()
|
||||
yield Button("Danger Zone ⚠", id="sp_danger", variant="error", disabled=True)
|
||||
|
|
@ -170,7 +168,7 @@ class ShuttlePane(Container):
|
|||
self.query_one("#sp_lbl_status", Label).update(f"Status: {val}")
|
||||
|
||||
# Buttons
|
||||
for bid in ("#sp_clone", "#sp_roles", "#sp_emojis", "#sp_metadata", "#sp_messages", "#sp_danger"):
|
||||
for bid in ("#sp_clone", "#sp_sync", "#sp_messages", "#sp_danger"):
|
||||
self.query_one(bid, Button).disabled = not self.tokens_valid
|
||||
|
||||
# ── validation ────────────────────────────────────────────────────────
|
||||
|
|
@ -271,282 +269,223 @@ class ShuttlePane(Container):
|
|||
if not bid or not bid.startswith("sp_"):
|
||||
return
|
||||
if bid == "sp_clone":
|
||||
self.run_clone_template()
|
||||
elif bid == "sp_roles":
|
||||
self._open_roles_menu()
|
||||
elif bid == "sp_emojis":
|
||||
self._open_emoji_menu()
|
||||
elif bid == "sp_metadata":
|
||||
self._open_metadata_menu()
|
||||
self._open_clone_menu()
|
||||
elif bid == "sp_sync":
|
||||
self._open_sync_menu()
|
||||
elif bid == "sp_messages":
|
||||
self.run_migrate_messages()
|
||||
elif bid == "sp_danger":
|
||||
self._open_danger_menu()
|
||||
|
||||
# ── (1) clone server template ─────────────────────────────────────────
|
||||
# ── (1) clone server template (combined) ─────────────────────────────
|
||||
|
||||
def _open_clone_menu(self):
|
||||
options = [
|
||||
("sub_clone_roles", "Clone Roles & Role Permissions"),
|
||||
("sub_clone_channels", "Clone Channels & Categories"),
|
||||
("sub_sync_perms", "Sync Channel & Category Permissions"),
|
||||
]
|
||||
def on_result(choices):
|
||||
if choices:
|
||||
# Order defined: Roles -> Channels -> Permissions
|
||||
ordered = [c for c in ["sub_clone_roles", "sub_clone_channels", "sub_sync_perms"] if c in choices]
|
||||
self.run_batch_clone(ordered)
|
||||
self.app.push_screen(OptionSelectModal("Clone Server Template", options), on_result)
|
||||
|
||||
# ── (2) sync server settings (combined) ────────────────────────────
|
||||
|
||||
def _open_sync_menu(self):
|
||||
options = [
|
||||
("sub_emoji", "Sync Emojis"),
|
||||
("sub_sticker", "Sync Stickers"),
|
||||
("sub_name", "Sync Server Name"),
|
||||
("sub_icon", "Sync Server Icon"),
|
||||
("sub_banner", "Sync Server Banner"),
|
||||
]
|
||||
def on_result(choices):
|
||||
if choices:
|
||||
self.run_batch_sync(choices)
|
||||
self.app.push_screen(OptionSelectModal("Sync Server Settings", options), on_result)
|
||||
|
||||
# ── batch workers ──────────────────────────────────────────────────
|
||||
|
||||
@work(exclusive=True)
|
||||
async def run_clone_template(self) -> None:
|
||||
async def run_batch_clone(self, selections: list[str]) -> None:
|
||||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
modal.set_status(f"Awaiting Confirmation for {len(selections)} Operations...")
|
||||
choice = await modal.phase_wait_confirm()
|
||||
if choice == "btn_back":
|
||||
modal.dismiss()
|
||||
return
|
||||
elif choice == "btn_main_menu":
|
||||
modal.dismiss()
|
||||
self.app.switch_screen("config_selection")
|
||||
return
|
||||
|
||||
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
|
||||
modal.phase_progress()
|
||||
await self.engine.start_connections()
|
||||
self.engine.is_running = True
|
||||
|
||||
for sel in selections:
|
||||
if not self.engine.is_running: break
|
||||
if sel == "sub_clone_roles":
|
||||
await self._logic_clone_roles(modal)
|
||||
elif sel == "sub_clone_channels":
|
||||
await self._logic_clone_channels(modal)
|
||||
elif sel == "sub_sync_perms":
|
||||
await self._logic_sync_permissions(modal)
|
||||
|
||||
modal.phase_report("Clone Template Complete")
|
||||
modal.write("[bold green]All selected cloning operations finished.[/bold green]")
|
||||
except Exception as e:
|
||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||
modal.phase_report("Batch Operation", "error")
|
||||
finally:
|
||||
self.engine.is_running = False
|
||||
await self.engine.close_connections()
|
||||
|
||||
@work(exclusive=True)
|
||||
async def run_batch_sync(self, selections: list[str]) -> None:
|
||||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
modal.set_status("Awaiting Confirmation to Sync Server Settings...")
|
||||
choice = await modal.phase_wait_confirm()
|
||||
if choice == "btn_back":
|
||||
modal.dismiss()
|
||||
return
|
||||
elif choice == "btn_main_menu":
|
||||
modal.dismiss()
|
||||
self.app.switch_screen("config_selection")
|
||||
return
|
||||
|
||||
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
|
||||
modal.phase_progress()
|
||||
await self.engine.start_connections()
|
||||
self.engine.is_running = True
|
||||
|
||||
# Separate asset sync from metadata sync
|
||||
asset_types = []
|
||||
if "sub_emoji" in selections: asset_types.append("Emoji")
|
||||
if "sub_sticker" in selections: asset_types.append("Sticker")
|
||||
if asset_types:
|
||||
await self._logic_copy_assets(modal, asset_types)
|
||||
|
||||
meta_comps = []
|
||||
if "sub_name" in selections: meta_comps.append("name")
|
||||
if "sub_icon" in selections: meta_comps.append("icon")
|
||||
if "sub_banner" in selections: meta_comps.append("banner")
|
||||
if meta_comps:
|
||||
await self._logic_sync_metadata(modal, meta_comps)
|
||||
|
||||
modal.phase_report("Sync Settings Complete")
|
||||
modal.write("[bold green]All selected synchronization operations finished.[/bold green]")
|
||||
except Exception as e:
|
||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||
modal.phase_report("Batch Operation", "error")
|
||||
finally:
|
||||
self.engine.is_running = False
|
||||
await self.engine.close_connections()
|
||||
|
||||
# ── logic blocks (internal) ────────────────────────────────────────
|
||||
|
||||
async def _logic_clone_channels(self, modal: ProgressScreen):
|
||||
if self.target_platform == "fluxer":
|
||||
from src.fluxer.clone_server import sync_channel_state, migrate_channels
|
||||
else:
|
||||
from src.stoat.clone_server import sync_channel_state, migrate_channels
|
||||
|
||||
modal.set_status("Processing Server Structure...")
|
||||
await sync_channel_state(self.engine)
|
||||
categories = await self.engine.discord_reader.get_categories()
|
||||
channels = await self.engine.discord_reader.get_channels()
|
||||
|
||||
async def update_progress(item_name, status, current, total):
|
||||
color = "cyan" if status == "Copying" else "yellow"
|
||||
modal.set_status(f"[{color}]{status}: {item_name}[/{color}]")
|
||||
modal.set_progress(current, total)
|
||||
|
||||
cloned_info = await migrate_channels(self.engine, progress_callback=update_progress, force=False)
|
||||
if cloned_info and cloned_info.get("structure"):
|
||||
lines = ["Successfully cloned channels and categories:"]
|
||||
cats = sorted(cloned_info["structure"].keys(), key=lambda x: (x == "No Category", x))
|
||||
for cat_name in cats:
|
||||
ch_names = cloned_info["structure"][cat_name]
|
||||
if cat_name in cloned_info.get("categories_created", []) or ch_names:
|
||||
lines.append(f"- **{cat_name}**")
|
||||
for n in sorted(ch_names): lines.append(f" - {n}")
|
||||
await log_audit_event(self.engine, "Channels Cloned", "\n".join(lines))
|
||||
|
||||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
try:
|
||||
modal.set_status("Fetching server structure...")
|
||||
await self.engine.start_connections()
|
||||
await sync_channel_state(self.engine)
|
||||
categories = await self.engine.discord_reader.get_categories()
|
||||
channels = await self.engine.discord_reader.get_channels()
|
||||
|
||||
cached = sum(1 for c in categories if self.engine.state.get_fluxer_category_id(str(c.id)))
|
||||
cached += sum(1 for c in channels if self.engine.state.get_fluxer_channel_id(str(c.id)))
|
||||
total = len(categories) + len(channels)
|
||||
|
||||
modal.write(f"[yellow]Found {total} items, {cached} already cloned.[/yellow]")
|
||||
modal.set_status("Cloning channels...")
|
||||
|
||||
async def update_progress(item_name, status, current, total):
|
||||
color = "cyan" if status == "Copying" else "yellow"
|
||||
modal.set_status(f"[{color}]{status}: {item_name}[/{color}]")
|
||||
modal.set_progress(current, total)
|
||||
|
||||
self.engine.is_running = True
|
||||
cloned_info = await migrate_channels(self.engine, progress_callback=update_progress, force=False)
|
||||
|
||||
modal.write("[bold green]Server Template cloned![/bold green]")
|
||||
if cloned_info and cloned_info.get("structure"):
|
||||
lines = ["Successfully cloned channels and categories from Discord:"]
|
||||
cats = sorted(cloned_info["structure"].keys(), key=lambda x: (x == "No Category", x))
|
||||
for cat_name in cats:
|
||||
ch_names = cloned_info["structure"][cat_name]
|
||||
if cat_name in cloned_info.get("categories_created", []) or ch_names:
|
||||
lines.append(f"- **{cat_name}**")
|
||||
for n in sorted(ch_names):
|
||||
lines.append(f" - {n}")
|
||||
await log_audit_event(self.engine, "Server Template Cloned", "\n".join(lines))
|
||||
else:
|
||||
await log_audit_event(self.engine, "Server Template Cloned", "No new items were cloned.")
|
||||
except Exception as e:
|
||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||
finally:
|
||||
self.engine.is_running = False
|
||||
await self.engine.close_connections()
|
||||
modal.set_status("Finished.")
|
||||
modal.allow_close()
|
||||
|
||||
# ── (2) roles & permissions ───────────────────────────────────────────
|
||||
|
||||
def _open_roles_menu(self):
|
||||
options = [
|
||||
("sub_clone_roles", "Clone Roles & Role Permissions", "primary"),
|
||||
("sub_sync_perms", "Sync Channel & Category Permissions", "warning"),
|
||||
]
|
||||
def on_result(choice):
|
||||
if choice == "sub_clone_roles":
|
||||
self.run_clone_roles()
|
||||
elif choice == "sub_sync_perms":
|
||||
self.run_sync_permissions()
|
||||
self.app.push_screen(SubMenuModal("Roles & Permissions", options), on_result)
|
||||
|
||||
@work(exclusive=True)
|
||||
async def run_clone_roles(self) -> None:
|
||||
async def _logic_clone_roles(self, modal: ProgressScreen):
|
||||
roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles
|
||||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
modal.set_status("Processing Roles...")
|
||||
await roles_mod.sync_roles_state(self.engine)
|
||||
|
||||
async def update(name, current, total):
|
||||
modal.set_status(f"[cyan]Copying Role: {name}[/cyan]")
|
||||
modal.set_progress(current, total)
|
||||
|
||||
cloned = await roles_mod.migrate_roles(self.engine, progress_callback=update, force=False)
|
||||
if cloned:
|
||||
await log_audit_event(self.engine, "Roles Cloned", "Successfully cloned roles:\n" + "\n".join(f"- {r}" for r in cloned))
|
||||
|
||||
try:
|
||||
modal.set_status("Checking existing roles...")
|
||||
await self.engine.start_connections()
|
||||
await roles_mod.sync_roles_state(self.engine)
|
||||
roles = await self.engine.discord_reader.get_roles()
|
||||
|
||||
cached = sum(1 for r in roles if self.engine.state.get_target_role_id(str(r.id)))
|
||||
modal.write(f"[yellow]Found {len(roles)} roles, {cached} already cloned.[/yellow]")
|
||||
modal.set_status("Cloning roles...")
|
||||
|
||||
async def update(name, current, total):
|
||||
modal.set_status(f"[cyan]Copying: {name}[/cyan]")
|
||||
modal.set_progress(current, total)
|
||||
|
||||
self.engine.is_running = True
|
||||
cloned = await roles_mod.migrate_roles(self.engine, progress_callback=update, force=False)
|
||||
|
||||
modal.write("[bold green]Role migration complete![/bold green]")
|
||||
if cloned:
|
||||
desc = "Successfully cloned roles:\n" + "\n".join(f"- {r}" for r in cloned)
|
||||
await log_audit_event(self.engine, "Roles Cloned", desc)
|
||||
else:
|
||||
await log_audit_event(self.engine, "Roles Cloned", "No new roles were cloned.")
|
||||
except Exception as e:
|
||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||
finally:
|
||||
self.engine.is_running = False
|
||||
await self.engine.close_connections()
|
||||
modal.set_status("Finished.")
|
||||
modal.allow_close()
|
||||
|
||||
@work(exclusive=True)
|
||||
async def run_sync_permissions(self) -> None:
|
||||
async def _logic_sync_permissions(self, modal: ProgressScreen):
|
||||
roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles
|
||||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
modal.set_status("Syncing Permissions...")
|
||||
|
||||
async def update(name, current, total):
|
||||
modal.set_status(f"[cyan]Syncing Perms: {name}[/cyan]")
|
||||
modal.set_progress(current, total)
|
||||
|
||||
synced = await roles_mod.sync_permissions(self.engine, progress_callback=update)
|
||||
if synced and synced.get("structure"):
|
||||
lines = ["Synchronized permission overrides:"]
|
||||
cats = sorted(synced["structure"].keys(), key=lambda x: (x == "No Category", x))
|
||||
for cat_name in cats:
|
||||
ch_names = synced["structure"][cat_name]
|
||||
if cat_name in synced.get("categories_synced", []) or ch_names:
|
||||
lines.append(f"- **{cat_name}**")
|
||||
for n in sorted(ch_names): lines.append(f" - {n}")
|
||||
await log_audit_event(self.engine, "Permissions Synced", "\n".join(lines))
|
||||
|
||||
try:
|
||||
modal.set_status("Syncing permissions...")
|
||||
await self.engine.start_connections()
|
||||
|
||||
async def update(name, current, total):
|
||||
modal.set_status(f"[cyan]Syncing: {name}[/cyan]")
|
||||
modal.set_progress(current, total)
|
||||
|
||||
self.engine.is_running = True
|
||||
synced = await roles_mod.sync_permissions(self.engine, progress_callback=update)
|
||||
|
||||
modal.write("[bold green]Permission sync complete![/bold green]")
|
||||
if synced and synced.get("structure"):
|
||||
lines = ["Synchronized permission overrides:"]
|
||||
cats = sorted(synced["structure"].keys(), key=lambda x: (x == "No Category", x))
|
||||
for cat_name in cats:
|
||||
ch_names = synced["structure"][cat_name]
|
||||
if cat_name in synced.get("categories_synced", []) or ch_names:
|
||||
lines.append(f"- **{cat_name}**")
|
||||
for n in sorted(ch_names):
|
||||
lines.append(f" - {n}")
|
||||
await log_audit_event(self.engine, "Permissions Synced", "\n".join(lines))
|
||||
else:
|
||||
await log_audit_event(self.engine, "Permissions Synced", "No permissions synchronized.")
|
||||
except Exception as e:
|
||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||
finally:
|
||||
self.engine.is_running = False
|
||||
await self.engine.close_connections()
|
||||
modal.set_status("Finished.")
|
||||
modal.allow_close()
|
||||
|
||||
# ── (3) emojis & stickers ─────────────────────────────────────────────
|
||||
|
||||
def _open_emoji_menu(self):
|
||||
options = [
|
||||
("sub_emoji", "Sync Emojis only", "primary"),
|
||||
("sub_sticker", "Sync Stickers only", "primary"),
|
||||
("sub_both", "Sync Emojis & Stickers", "success"),
|
||||
]
|
||||
def on_result(choice):
|
||||
types = []
|
||||
if choice == "sub_emoji":
|
||||
types = ["Emoji"]
|
||||
elif choice == "sub_sticker":
|
||||
types = ["Sticker"]
|
||||
elif choice == "sub_both":
|
||||
types = ["Emoji", "Sticker"]
|
||||
if types:
|
||||
self.run_copy_emojis(types)
|
||||
self.app.push_screen(SubMenuModal("Emojis & Stickers", options), on_result)
|
||||
|
||||
@work(exclusive=True)
|
||||
async def run_copy_emojis(self, types_to_include: list[str]) -> None:
|
||||
async def _logic_copy_assets(self, modal: ProgressScreen, types_to_include: list[str]):
|
||||
asset_mod = stoat_emoji_stickers if self.target_platform == "stoat" else fluxer_emoji_stickers
|
||||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
modal.set_status("Processing Assets...")
|
||||
await asset_mod.sync_assets_state(self.engine)
|
||||
|
||||
async def update(name, item_type, current, total):
|
||||
modal.set_status(f"[cyan]Copying {item_type}: {name}[/cyan]")
|
||||
modal.set_progress(current, total)
|
||||
|
||||
cloned = await asset_mod.migrate_emojis(self.engine, progress_callback=update, types_to_include=types_to_include, force=False)
|
||||
if cloned and (cloned.get("Emoji") or cloned.get("Sticker")):
|
||||
lines = []
|
||||
if cloned.get("Emoji"):
|
||||
lines.append("Emojis cloned:"); lines.extend([f"- {n}" for n in cloned["Emoji"]])
|
||||
if cloned.get("Sticker"):
|
||||
lines.append("Stickers cloned:"); lines.extend([f"- {n}" for n in cloned["Sticker"]])
|
||||
await log_audit_event(self.engine, "Assets Cloned", "\n".join(lines))
|
||||
|
||||
try:
|
||||
modal.set_status("Checking existing assets...")
|
||||
await self.engine.start_connections()
|
||||
await asset_mod.sync_assets_state(self.engine)
|
||||
|
||||
modal.set_status("Copying assets...")
|
||||
|
||||
async def update(name, item_type, current, total):
|
||||
modal.set_status(f"[cyan]Copying {item_type}: {name}[/cyan]")
|
||||
modal.set_progress(current, total)
|
||||
|
||||
self.engine.is_running = True
|
||||
cloned = await asset_mod.migrate_emojis(
|
||||
self.engine,
|
||||
progress_callback=update,
|
||||
types_to_include=types_to_include,
|
||||
force=False,
|
||||
)
|
||||
|
||||
modal.write("[bold green]Asset migration complete![/bold green]")
|
||||
if cloned and (cloned.get("Emoji") or cloned.get("Sticker")):
|
||||
lines = []
|
||||
if cloned.get("Emoji"):
|
||||
lines.append("Emojis cloned:")
|
||||
for n in cloned["Emoji"]:
|
||||
lines.append(f"- {n}")
|
||||
if cloned.get("Sticker"):
|
||||
lines.append("Stickers cloned:")
|
||||
for n in cloned["Sticker"]:
|
||||
lines.append(f"- {n}")
|
||||
await log_audit_event(self.engine, "Emojis & Stickers Cloned", "\n".join(lines))
|
||||
else:
|
||||
await log_audit_event(self.engine, "Emojis & Stickers Cloned", "No new assets cloned.")
|
||||
except Exception as e:
|
||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||
finally:
|
||||
self.engine.is_running = False
|
||||
await self.engine.close_connections()
|
||||
modal.set_status("Finished.")
|
||||
modal.allow_close()
|
||||
|
||||
# ── (4) server metadata sync ──────────────────────────────────────────
|
||||
|
||||
def _open_metadata_menu(self):
|
||||
options = [
|
||||
("sub_name", "Sync Name only", "primary"),
|
||||
("sub_icon", "Sync Icon only", "primary"),
|
||||
("sub_banner", "Sync Banner only", "primary"),
|
||||
("sub_all_meta", "Sync Everything", "success"),
|
||||
]
|
||||
def on_result(choice):
|
||||
comps = []
|
||||
if choice == "sub_name":
|
||||
comps = ["name"]
|
||||
elif choice == "sub_icon":
|
||||
comps = ["icon"]
|
||||
elif choice == "sub_banner":
|
||||
comps = ["banner"]
|
||||
elif choice == "sub_all_meta":
|
||||
comps = ["name", "icon", "banner"]
|
||||
if comps:
|
||||
self.run_sync_metadata(comps)
|
||||
self.app.push_screen(SubMenuModal("Sync Server Profile", options), on_result)
|
||||
|
||||
@work(exclusive=True)
|
||||
async def run_sync_metadata(self, components: list[str]) -> None:
|
||||
async def _logic_sync_metadata(self, modal: ProgressScreen, components: list[str]):
|
||||
meta_mod = fluxer_metadata if self.target_platform == "fluxer" else stoat_metadata
|
||||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
try:
|
||||
modal.set_status("Syncing server metadata...")
|
||||
await self.engine.start_connections()
|
||||
|
||||
async def progress_cb(item, status):
|
||||
color = "green" if status == "DONE" else "red" if status == "ERROR" else "yellow"
|
||||
modal.write(f"{item} [[bold {color}]{status}[/bold {color}]]")
|
||||
|
||||
cloned = await meta_mod.sync_server_metadata(self.engine, progress_cb, components=components)
|
||||
|
||||
modal.write("[bold green]Server profile sync finished![/bold green]")
|
||||
|
||||
lines = ["Synchronized Community profile:"]
|
||||
if "name" in cloned:
|
||||
lines.append(f"- **Name**: {cloned['name']}")
|
||||
if "icon" in cloned:
|
||||
lines.append("- **Icon**")
|
||||
if "banner" in cloned:
|
||||
lines.append("- **Banner**")
|
||||
modal.set_status("Syncing Server Profile...")
|
||||
|
||||
async def progress_cb(item, status):
|
||||
color = "green" if status == "DONE" else "red" if status == "ERROR" else "yellow"
|
||||
modal.write(f"{item} [[bold {color}]{status}[/bold {color}]]")
|
||||
|
||||
cloned = await meta_mod.sync_server_metadata(self.engine, progress_cb, components=components)
|
||||
if cloned:
|
||||
lines = ["Synchronized profile traits:"]
|
||||
if "name" in cloned: lines.append(f"- **Name**: {cloned['name']}")
|
||||
if "icon" in cloned: lines.append("- **Icon**")
|
||||
if "banner" in cloned: lines.append("- **Banner**")
|
||||
# Prepare files for audit log
|
||||
files = []
|
||||
if "icon" in cloned:
|
||||
ext = "gif" if cloned["icon"].startswith(b"GIF") else "png"
|
||||
|
|
@ -554,16 +493,7 @@ class ShuttlePane(Container):
|
|||
if "banner" in cloned:
|
||||
ext = "gif" if cloned["banner"].startswith(b"GIF") else "png"
|
||||
files.append({"filename": f"banner.{ext}", "data": cloned["banner"]})
|
||||
if cloned:
|
||||
await log_audit_event(self.engine, "Server Profile Synced", "\n".join(lines), files=files)
|
||||
else:
|
||||
await log_audit_event(self.engine, "Server Profile Synced", "Nothing synchronized.")
|
||||
except Exception as e:
|
||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||
finally:
|
||||
await self.engine.close_connections()
|
||||
modal.set_status("Finished.")
|
||||
modal.allow_close()
|
||||
await log_audit_event(self.engine, "Profile Synced", "\n".join(lines), files=files)
|
||||
|
||||
# ── (5) message migration ─────────────────────────────────────────────
|
||||
|
||||
|
|
@ -609,62 +539,114 @@ class ShuttlePane(Container):
|
|||
|
||||
target_cat_names = {str(c.get("id")): c.get("name") for c in full_f if c.get("type") == 4}
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
pick_future = loop.create_future()
|
||||
while True:
|
||||
loop = asyncio.get_running_loop()
|
||||
pick_future = loop.create_future()
|
||||
|
||||
def on_pick(result):
|
||||
if not pick_future.done():
|
||||
pick_future.set_result(result)
|
||||
def on_pick(result):
|
||||
if not pick_future.done():
|
||||
pick_future.set_result(result)
|
||||
|
||||
self.app.push_screen(ChannelPickerScreen(d_channels, d_cat_map, f_channels, target_cat_names, platform_name), on_pick)
|
||||
res = await pick_future
|
||||
self.app.push_screen(ChannelPickerScreen(d_channels, d_cat_map, f_channels, target_cat_names, platform_name), on_pick)
|
||||
res = await pick_future
|
||||
|
||||
if res is None:
|
||||
await self.engine.close_connections()
|
||||
return
|
||||
|
||||
src_id, tgt_id = res
|
||||
source_channel = next(c for c in d_channels if c.id == src_id)
|
||||
target_channel = next(c for c in f_channels if c.get("id") == tgt_id)
|
||||
if res is None:
|
||||
await self.engine.close_connections()
|
||||
return
|
||||
|
||||
src_id, tgt_id = res
|
||||
source_channel = next(c for c in d_channels if c.id == src_id)
|
||||
target_channel = next(c for c in f_channels if c.get("id") == tgt_id)
|
||||
|
||||
# Determine after_id
|
||||
after_id = None
|
||||
last_migrated = self.engine.state.get_last_message_id(str(source_channel.id))
|
||||
if last_migrated:
|
||||
after_id = int(last_migrated)
|
||||
# Determine after_id status
|
||||
last_migrated = self.engine.state.get_last_message_id(str(target_channel.get('id')))
|
||||
has_previous = bool(last_migrated)
|
||||
|
||||
# Analyze
|
||||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
modal.set_status("Analyzing channel...")
|
||||
modal.show_stats()
|
||||
|
||||
# Analyze
|
||||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
modal.set_status("Analyzing channel...")
|
||||
modal.show_stats()
|
||||
self.engine.is_running = True
|
||||
stats_analysis = {"messages": 0, "threads": 0, "attachments": 0}
|
||||
|
||||
self.engine.is_running = True
|
||||
stats = {"messages": 0, "threads": 0, "attachments": 0}
|
||||
async def update_scan(current_stats):
|
||||
modal.set_status(f"[cyan]Scanned {current_stats['messages']} items...")
|
||||
|
||||
async def update_scan(count):
|
||||
modal.set_status(f"[cyan]Scanned {count} items...")
|
||||
stats_analysis = await migrate_mod.analyze_migration(
|
||||
self.engine,
|
||||
source_channel_id=source_channel.id,
|
||||
after_message_id=int(last_migrated) if last_migrated else None,
|
||||
progress_callback=update_scan,
|
||||
)
|
||||
self.engine.is_running = False
|
||||
|
||||
stats = await migrate_mod.analyze_migration(
|
||||
self.engine,
|
||||
source_channel_id=source_channel.id,
|
||||
after_message_id=after_id,
|
||||
progress_callback=update_scan,
|
||||
)
|
||||
self.engine.is_running = False
|
||||
# Set initial total stats for the confirmation block
|
||||
modal.update_stats(
|
||||
messages=stats_analysis['messages'],
|
||||
threads=stats_analysis['threads'],
|
||||
files=stats_analysis['attachments']
|
||||
)
|
||||
|
||||
modal.write(f"[cyan]Messages: {stats['messages']}, Threads: {stats['threads']}, Attachments: {stats['attachments']}[/cyan]")
|
||||
modal.write(f"Migrating Discord [cyan]#{source_channel.name}[/cyan] → {platform_name} [green]#{target_channel.get('name')}[/green]")
|
||||
# Setup the info container
|
||||
m_status = "[bold yellow]Previous Migration Detected[/bold yellow]" if has_previous else "[bold cyan]No previous migration data.[/bold cyan]"
|
||||
i_status = f"[bold]{stats_analysis['messages']}[/bold] New Messages, [bold]{stats_analysis['threads']}[/bold] Threads."
|
||||
modal.show_info(m_status, i_status)
|
||||
|
||||
modal.set_status(f"Awaiting Confirmation to migrate Discord [cyan]#{source_channel.name}[/cyan] → {platform_name} [green]#{target_channel.get('name')}[/green]")
|
||||
|
||||
# Phase 2: Confirmation
|
||||
choice = await modal.phase_wait_confirm(show_continue=has_previous)
|
||||
if choice == "btn_back":
|
||||
modal.dismiss()
|
||||
continue # Return to channel picker
|
||||
elif choice == "btn_main_menu":
|
||||
modal.dismiss()
|
||||
self.app.switch_screen("config_selection")
|
||||
self.engine.is_running = False
|
||||
await self.engine.close_connections()
|
||||
return
|
||||
|
||||
after_id = None
|
||||
if choice == "btn_continue" and last_migrated:
|
||||
after_id = int(last_migrated)
|
||||
elif choice == "btn_start_id":
|
||||
# Fallback to full for now since we don't have an ID input dialog yet
|
||||
modal.write("[yellow]Custom Message ID start not fully implemented, starting from beginning.[/yellow]")
|
||||
after_id = None
|
||||
|
||||
# If we are here, we are proceeding with migration
|
||||
break
|
||||
|
||||
# Phase 3: Progress
|
||||
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
|
||||
modal.phase_progress()
|
||||
modal.set_status("Migrating messages...")
|
||||
|
||||
total_messages = stats["messages"]
|
||||
total_messages = stats_analysis["messages"]
|
||||
total_threads = stats_analysis["threads"]
|
||||
total_attachments = stats_analysis["attachments"]
|
||||
|
||||
self.engine.is_running = True
|
||||
|
||||
async def update_msg(count):
|
||||
modal.set_status(f"[cyan]Migrated {count}/{total_messages} messages...")
|
||||
modal.set_progress(count, total_messages)
|
||||
modal.update_stats(messages=count)
|
||||
async def update_msg(current_stats):
|
||||
c_msgs = current_stats["messages"]
|
||||
c_threads = current_stats["threads"]
|
||||
c_files = current_stats["attachments"]
|
||||
|
||||
modal.set_status(f"[cyan]Migrated {c_msgs}/{total_messages} messages...")
|
||||
modal.set_progress(c_msgs, total_messages)
|
||||
|
||||
modal.update_stats(
|
||||
messages=f"{c_msgs}/{total_messages}",
|
||||
threads=f"{c_threads}/{total_threads}",
|
||||
files=f"{c_files}/{total_attachments}"
|
||||
)
|
||||
|
||||
# optionally show a scrolling trace if the backend provided it
|
||||
modal.write_live(f"Migrated message #{c_msgs}")
|
||||
|
||||
result = await migrate_mod.migrate_messages(
|
||||
self.engine,
|
||||
|
|
@ -676,15 +658,12 @@ class ShuttlePane(Container):
|
|||
|
||||
if self.engine.is_running:
|
||||
modal.write(f"[bold green]Success! {result['messages']} messages migrated.[/bold green]")
|
||||
event_title = "Message History Migrated"
|
||||
event_title = "Message Migration"
|
||||
modal.phase_report(event_title)
|
||||
else:
|
||||
modal.write(f"[bold yellow]Interrupted! {result['messages']} messages migrated.[/bold yellow]")
|
||||
event_title = "Message History Migration Interrupted"
|
||||
|
||||
self.app.push_screen(ReportModal(
|
||||
event_title,
|
||||
f"Successfully processed {result['messages']} messages, {result['attachments']} attachments, and {result['threads']} threads."
|
||||
))
|
||||
event_title = "Message Migration"
|
||||
modal.phase_report(event_title, "stopped")
|
||||
|
||||
lines = [f"Migrated Discord #{source_channel.name} → {platform_name} #{target_channel.get('name')}:"]
|
||||
lines.append(f"{result['messages']} messages, {result['attachments']} attachments, {result['threads']} threads")
|
||||
|
|
@ -696,10 +675,10 @@ class ShuttlePane(Container):
|
|||
modal.write("[bold red]Bot is missing the 'Masquerade' permission.[/bold red]")
|
||||
else:
|
||||
modal.write(f"[bold red]Error: {err}[/bold red]")
|
||||
modal.phase_report("Message Migration", "error")
|
||||
finally:
|
||||
self.engine.is_running = False
|
||||
await self.engine.close_connections()
|
||||
modal.allow_close()
|
||||
|
||||
# ── (6) danger zone ───────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -722,10 +701,22 @@ class ShuttlePane(Container):
|
|||
self.app.push_screen(SubMenuModal("⚠ DANGER ZONE ⚠", options), on_result)
|
||||
|
||||
def _confirm_danger(self, message: str, callback):
|
||||
def on_confirm(confirmed: bool):
|
||||
if confirmed:
|
||||
async def do_confirm():
|
||||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
modal.set_status(f"[bold red]DANGER:[/bold red] {message}")
|
||||
modal.write("[bold red]WARNING: THIS WILL DELETE DATA PERMANENTLY! MUST PROCEED TO CONTINUE.[/bold red]")
|
||||
|
||||
choice = await modal.phase_wait_confirm()
|
||||
modal.dismiss()
|
||||
if choice == "btn_start_first":
|
||||
callback()
|
||||
self.app.push_screen(ConfirmModal(message, danger=True), on_confirm)
|
||||
elif choice == "btn_main_menu":
|
||||
self.app.switch_screen("config_selection")
|
||||
|
||||
asyncio.create_task(do_confirm())
|
||||
|
||||
@work(exclusive=True)
|
||||
async def run_dz_delete_channels(self) -> None:
|
||||
|
|
@ -737,6 +728,8 @@ class ShuttlePane(Container):
|
|||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
|
||||
modal.phase_progress()
|
||||
|
||||
try:
|
||||
modal.set_status("[red]Deleting channels...")
|
||||
|
|
@ -747,14 +740,15 @@ class ShuttlePane(Container):
|
|||
modal.set_progress(current, total)
|
||||
|
||||
count = await danger_delete_all_channels(self.engine, progress_callback=on_deleted)
|
||||
modal.write(f"[bold green]{count} channels/categories deleted.[/bold green]")
|
||||
|
||||
modal.phase_report("Danger Zone: Channels Wiped")
|
||||
modal.write(f"[bold green]Success! {count} channels/categories deleted.[/bold green]")
|
||||
await log_audit_event(self.engine, "Danger Zone: Channels Wiped", f"Deleted {count} channels and categories.")
|
||||
except Exception as e:
|
||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||
modal.phase_report("Delete Channels", "error")
|
||||
finally:
|
||||
await self.engine.close_target_only()
|
||||
modal.set_status("Finished.")
|
||||
modal.allow_close()
|
||||
|
||||
@work(exclusive=True)
|
||||
async def run_dz_reset_perms(self) -> None:
|
||||
|
|
@ -766,6 +760,8 @@ class ShuttlePane(Container):
|
|||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
|
||||
modal.phase_progress()
|
||||
|
||||
try:
|
||||
modal.set_status("[red]Resetting permissions...")
|
||||
|
|
@ -776,14 +772,15 @@ class ShuttlePane(Container):
|
|||
modal.set_progress(current, total)
|
||||
|
||||
count = await danger_reset_channel_permissions(self.engine, progress_callback=on_reset)
|
||||
modal.write(f"[bold green]Permissions reset on {count} items.[/bold green]")
|
||||
|
||||
modal.phase_report("Danger Zone: Permissions Wiped")
|
||||
modal.write(f"[bold green]Success! Permissions reset on {count} items.[/bold green]")
|
||||
await log_audit_event(self.engine, "Danger Zone: Permissions Wiped", f"Reset permissions on {count} items.")
|
||||
except Exception as e:
|
||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||
modal.phase_report("Wipe Permissions", "error")
|
||||
finally:
|
||||
await self.engine.close_target_only()
|
||||
modal.set_status("Finished.")
|
||||
modal.allow_close()
|
||||
|
||||
@work(exclusive=True)
|
||||
async def run_dz_delete_roles(self) -> None:
|
||||
|
|
@ -795,6 +792,8 @@ class ShuttlePane(Container):
|
|||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
|
||||
modal.phase_progress()
|
||||
|
||||
try:
|
||||
modal.set_status("[red]Deleting roles...")
|
||||
|
|
@ -805,14 +804,15 @@ class ShuttlePane(Container):
|
|||
modal.set_progress(current, total)
|
||||
|
||||
count = await danger_delete_all_roles(self.engine, progress_callback=on_deleted)
|
||||
modal.write(f"[bold green]{count} roles deleted.[/bold green]")
|
||||
|
||||
modal.phase_report("Danger Zone: Roles Wiped")
|
||||
modal.write(f"[bold green]Success! {count} roles deleted.[/bold green]")
|
||||
await log_audit_event(self.engine, "Danger Zone: Roles Wiped", f"Deleted {count} roles.")
|
||||
except Exception as e:
|
||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||
modal.phase_report("Wipe Roles", "error")
|
||||
finally:
|
||||
await self.engine.close_target_only()
|
||||
modal.set_status("Finished.")
|
||||
modal.allow_close()
|
||||
|
||||
@work(exclusive=True)
|
||||
async def run_dz_delete_assets(self) -> None:
|
||||
|
|
@ -824,6 +824,8 @@ class ShuttlePane(Container):
|
|||
modal = ProgressScreen()
|
||||
self.app.push_screen(modal)
|
||||
await asyncio.sleep(0.1)
|
||||
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
|
||||
modal.phase_progress()
|
||||
|
||||
try:
|
||||
modal.set_status("[red]Deleting assets...")
|
||||
|
|
@ -834,11 +836,12 @@ class ShuttlePane(Container):
|
|||
modal.set_progress(current, total)
|
||||
|
||||
counts = await danger_delete_all_emojis_and_stickers(self.engine, progress_callback=on_deleted)
|
||||
modal.write(f"[bold green]{counts.get('emojis', 0)} emojis, {counts.get('stickers', 0)} stickers deleted.[/bold green]")
|
||||
|
||||
modal.phase_report("Danger Zone: Assets Wiped")
|
||||
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.")
|
||||
except Exception as e:
|
||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||
modal.phase_report("Wipe Assets", "error")
|
||||
finally:
|
||||
await self.engine.close_target_only()
|
||||
modal.set_status("Finished.")
|
||||
modal.allow_close()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue