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
|
# audit log tracking
|
||||||
self.audit_log_channel: str | None = None
|
self.audit_log_channel: str | None = None
|
||||||
|
|
||||||
# message tracking
|
# message tracking per target channel
|
||||||
self.message_map: Dict[str, str] = {}
|
# Format: { target_channel_id: {"message_map": {}, "last_message_id": "", "last_message_timestamp": ""} }
|
||||||
self.last_message_ids: Dict[str, str] = {}
|
self.channel_messages: Dict[str, Dict[str, Any]] = {}
|
||||||
self.last_message_timestamps: Dict[str, str] = {}
|
|
||||||
|
|
||||||
self.load()
|
self.load()
|
||||||
|
|
||||||
|
|
@ -48,9 +47,25 @@ class MigrationState:
|
||||||
if self.messages_file and self.messages_file.exists():
|
if self.messages_file and self.messages_file.exists():
|
||||||
with open(self.messages_file, "r", encoding="utf-8") as f:
|
with open(self.messages_file, "r", encoding="utf-8") as f:
|
||||||
msg_data = json.load(f)
|
msg_data = json.load(f)
|
||||||
self.message_map = msg_data.get("messages", {})
|
|
||||||
self.last_message_ids = msg_data.get("last_message_ids", {})
|
# Check for new schema (nested under 'channels')
|
||||||
self.last_message_timestamps = msg_data.get("last_message_timestamps", {})
|
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:
|
if not self.messages_file:
|
||||||
return
|
return
|
||||||
data = {
|
data = {
|
||||||
"last_message_ids": self.last_message_ids,
|
"channels": self.channel_messages
|
||||||
"last_message_timestamps": self.last_message_timestamps,
|
|
||||||
"messages": self.message_map
|
|
||||||
}
|
}
|
||||||
with open(self.messages_file, "w", encoding="utf-8") as f:
|
with open(self.messages_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=4)
|
json.dump(data, f, indent=4)
|
||||||
|
|
@ -170,31 +183,55 @@ class MigrationState:
|
||||||
def set_target_sticker_mapping(self, discord_id: str, target_id: str):
|
def set_target_sticker_mapping(self, discord_id: str, target_id: str):
|
||||||
self.set_sticker_mapping(discord_id, target_id)
|
self.set_sticker_mapping(discord_id, target_id)
|
||||||
|
|
||||||
def get_target_message_id(self, discord_id: str) -> str | None:
|
def get_target_message_id(self, target_channel_id: str, discord_id: str) -> str | None:
|
||||||
return self.get_fluxer_message_id(discord_id)
|
return self.get_fluxer_message_id(target_channel_id, discord_id)
|
||||||
|
|
||||||
def set_target_message_mapping(self, discord_id: str, target_id: str):
|
def set_target_message_mapping(self, target_channel_id: str, discord_id: str, target_id: str):
|
||||||
self.set_message_mapping(discord_id, target_id)
|
self.set_message_mapping(target_channel_id, discord_id, target_id)
|
||||||
|
|
||||||
# --- Message Management ---
|
# --- Message Management ---
|
||||||
|
|
||||||
def set_message_mapping(self, discord_id: str, fluxer_id: str):
|
def _ensure_channel_tracking(self, target_channel_id: str):
|
||||||
self.message_map[str(discord_id)] = str(fluxer_id)
|
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()
|
self.save_messages()
|
||||||
|
|
||||||
def get_fluxer_message_id(self, discord_id: str) -> str | None:
|
def set_message_mapping(self, target_channel_id: str, discord_id: str, fluxer_id: str):
|
||||||
return self.message_map.get(str(discord_id))
|
self._ensure_channel_tracking(target_channel_id)
|
||||||
|
self.channel_messages[str(target_channel_id)]["message_map"][str(discord_id)] = str(fluxer_id)
|
||||||
def update_last_message_timestamp(self, channel_id: str, timestamp: str):
|
|
||||||
self.last_message_timestamps[str(channel_id)] = timestamp
|
|
||||||
self.save_messages()
|
self.save_messages()
|
||||||
|
|
||||||
def update_last_message_id(self, channel_id: str, message_id: str):
|
def get_fluxer_message_id(self, target_channel_id: str, discord_id: str) -> str | None:
|
||||||
self.last_message_ids[str(channel_id)] = message_id
|
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, 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()
|
self.save_messages()
|
||||||
|
|
||||||
def get_last_message_id(self, channel_id: str) -> str | None:
|
def update_last_message_id(self, target_channel_id: str, message_id: str):
|
||||||
return self.last_message_ids.get(str(channel_id))
|
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, 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 ---
|
# --- Danger Zone Clearing ---
|
||||||
|
|
||||||
|
|
@ -217,9 +254,7 @@ class MigrationState:
|
||||||
|
|
||||||
def clear_message_history(self):
|
def clear_message_history(self):
|
||||||
"""Clears all message mappings and timestamps."""
|
"""Clears all message mappings and timestamps."""
|
||||||
self.message_map.clear()
|
self.channel_messages.clear()
|
||||||
self.last_message_ids.clear()
|
|
||||||
self.last_message_timestamps.clear()
|
|
||||||
self.save_messages()
|
self.save_messages()
|
||||||
|
|
||||||
def set_folder(self, server_id: str, clean_name: str, base_dir: Path | str = ""):
|
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.state_file = new_folder / "state-migration.json"
|
||||||
self.messages_file = new_folder / "message-tracker.json"
|
self.messages_file = new_folder / "message-tracker.json"
|
||||||
|
|
||||||
self.save_state()
|
self.load()
|
||||||
self.save_messages()
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ class DiscordExporter:
|
||||||
# Create safe folder name
|
# Create safe folder name
|
||||||
import re
|
import re
|
||||||
safe_name = re.sub(r'[^a-zA-Z0-9_\-\.]', '_', self.server_name)
|
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)
|
self.export_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Consolidate media into one folder
|
# Consolidate media into one folder
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ def clean_mentions(content: str, guild, user_mentions=None, role_mentions=None,
|
||||||
return content
|
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.
|
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)
|
stats["attachments"] += len(msg.attachments)
|
||||||
|
|
||||||
if progress_callback and stats["messages"] % 10 == 0:
|
if progress_callback and stats["messages"] % 10 == 0:
|
||||||
await progress_callback(stats["messages"])
|
await progress_callback(stats)
|
||||||
|
|
||||||
return 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."""
|
"""Migrate messages for a specific channel and returns detailed statistics."""
|
||||||
stats = {
|
stats = {
|
||||||
"messages": 0,
|
"messages": 0,
|
||||||
|
|
@ -189,7 +189,7 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
|
||||||
# Check if this message is a reply
|
# Check if this message is a reply
|
||||||
reply_to_fluxer_id = None
|
reply_to_fluxer_id = None
|
||||||
if msg.reference and msg.reference.message_id:
|
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(
|
fluxer_msg_id = await context.fluxer_writer.send_message(
|
||||||
channel_id=target_channel_id,
|
channel_id=target_channel_id,
|
||||||
|
|
@ -203,11 +203,12 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
|
||||||
)
|
)
|
||||||
|
|
||||||
if fluxer_msg_id:
|
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_timestamp(target_channel_id, str(msg.created_at))
|
||||||
context.state.update_last_message_id(str(source_channel_id), str(msg.id))
|
context.state.update_last_message_id(target_channel_id, str(msg.id))
|
||||||
stats["messages"] += 1
|
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)
|
# Check for associated thread (Normal case: parent message is migrated)
|
||||||
if hasattr(msg, 'thread') and msg.thread:
|
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
|
stats["last_message_url"] = msg.jump_url
|
||||||
|
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
await progress_callback(stats["messages"])
|
await progress_callback(stats)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to process message {msg.id}: {e}")
|
logger.error(f"Failed to process message {msg.id}: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ def clean_mentions(content: str, guild, user_mentions=None, role_mentions=None,
|
||||||
return content
|
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.
|
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)
|
stats["attachments"] += len(msg.attachments)
|
||||||
|
|
||||||
if progress_callback and stats["messages"] % 10 == 0:
|
if progress_callback and stats["messages"] % 10 == 0:
|
||||||
await progress_callback(stats["messages"])
|
await progress_callback(stats)
|
||||||
|
|
||||||
return 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."""
|
"""Migrate messages for a specific channel using Stoat masquerade for author impersonation."""
|
||||||
stats = {
|
stats = {
|
||||||
"messages": 0,
|
"messages": 0,
|
||||||
|
|
@ -186,7 +186,7 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
|
||||||
# Check if this message is a reply
|
# Check if this message is a reply
|
||||||
reply_to_stoat_id = None
|
reply_to_stoat_id = None
|
||||||
if msg.reference and msg.reference.message_id:
|
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(
|
stoat_msg_id = await context.stoat_writer.send_message(
|
||||||
channel_id=target_channel_id,
|
channel_id=target_channel_id,
|
||||||
|
|
@ -200,11 +200,12 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
|
||||||
)
|
)
|
||||||
|
|
||||||
if stoat_msg_id:
|
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_timestamp(target_channel_id, str(msg.created_at))
|
||||||
context.state.update_last_message_id(str(source_channel_id), str(msg.id))
|
context.state.update_last_message_id(target_channel_id, str(msg.id))
|
||||||
stats["messages"] += 1
|
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)
|
# Check for associated thread (Normal case: parent message is migrated)
|
||||||
if hasattr(msg, 'thread') and msg.thread:
|
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
|
stats["last_message_url"] = msg.jump_url
|
||||||
|
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
await progress_callback(stats["messages"])
|
await progress_callback(stats)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If it's a permission error, stop the entire migration
|
# If it's a permission error, stop the entire migration
|
||||||
if "MissingPermission" in str(e):
|
if "MissingPermission" in str(e):
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from textual import work
|
||||||
from src.core.configuration import load_config
|
from src.core.configuration import load_config
|
||||||
from src.core.base import MigrationContext
|
from src.core.base import MigrationContext
|
||||||
from src.disco_reaper.exporter import DiscordExporter
|
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):
|
class BackupPane(Container):
|
||||||
|
|
@ -126,6 +126,7 @@ class BackupPane(Container):
|
||||||
modal = ProgressScreen()
|
modal = ProgressScreen()
|
||||||
self.app.call_from_thread(self.app.push_screen, modal)
|
self.app.call_from_thread(self.app.push_screen, modal)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
self.app.call_from_thread(modal.phase_progress)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.app.call_from_thread(modal.set_status, "Starting readers...")
|
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()
|
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"[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.write, f"- {e_count} emojis, {s_count} stickers.")
|
||||||
|
self.app.call_from_thread(modal.phase_report, "Profile Backup")
|
||||||
|
|
||||||
except discord.Forbidden as e:
|
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.write, f"[bold red]Backup failed: {e}[/bold red]")
|
||||||
|
self.app.call_from_thread(modal.phase_report, "Profile Backup", "error")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.app.call_from_thread(modal.write, f"[bold red]Error: {e}[/bold red]")
|
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:
|
finally:
|
||||||
await self.engine.close_connections()
|
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)
|
@work(exclusive=True, thread=True)
|
||||||
async def run_backup_messages(self) -> None:
|
async def run_backup_messages(self) -> None:
|
||||||
|
|
@ -190,30 +191,49 @@ class BackupPane(Container):
|
||||||
|
|
||||||
self.app.call_from_thread(self.app.pop_screen)
|
self.app.call_from_thread(self.app.pop_screen)
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
while True:
|
||||||
future = loop.create_future()
|
loop = asyncio.get_running_loop()
|
||||||
|
future = loop.create_future()
|
||||||
|
|
||||||
def check_channels(reply: dict | None) -> None:
|
def check_channels(reply: dict | None) -> None:
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_result(reply)
|
future.set_result(reply)
|
||||||
|
|
||||||
self.app.call_from_thread(
|
self.app.call_from_thread(
|
||||||
self.app.push_screen,
|
self.app.push_screen,
|
||||||
ChannelSelectScreen(eligible_channels, cat_map, backed_up_ids, any_found),
|
ChannelSelectScreen(eligible_channels, cat_map, backed_up_ids, any_found),
|
||||||
check_channels,
|
check_channels,
|
||||||
)
|
)
|
||||||
|
|
||||||
reply = await future
|
reply = await future
|
||||||
if not reply:
|
if not reply:
|
||||||
return
|
return
|
||||||
|
|
||||||
selected_ids = reply["channels"]
|
selected_ids = reply["channels"]
|
||||||
force_overwrite = reply["force"]
|
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)
|
||||||
|
|
||||||
self.app.call_from_thread(self.app.push_screen, modal_prog)
|
msg = "Sync existing backups" if not force_overwrite else "Overwriting existing backups"
|
||||||
await asyncio.sleep(0.1)
|
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 ''}")
|
||||||
|
|
||||||
|
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)
|
total_chans = len(selected_channels)
|
||||||
self.app.call_from_thread(modal_prog.set_status, "Backing up messages...")
|
self.app.call_from_thread(modal_prog.set_status, "Backing up messages...")
|
||||||
|
|
@ -236,19 +256,20 @@ class BackupPane(Container):
|
||||||
|
|
||||||
await self.exporter.export_metadata()
|
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.write, "[bold green]Message backup complete![/bold green]")
|
||||||
|
self.app.call_from_thread(modal_prog.phase_report, "Message Backup")
|
||||||
|
|
||||||
except Exception as e:
|
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.write, f"[bold red]Message backup failed: {e}[/bold red]")
|
||||||
|
self.app.call_from_thread(modal_prog.phase_report, "Message Backup", "error")
|
||||||
finally:
|
finally:
|
||||||
await self.engine.close_connections()
|
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)
|
@work(exclusive=True, thread=True)
|
||||||
async def run_backup_sync(self) -> None:
|
async def run_backup_sync(self) -> None:
|
||||||
modal_prog = ProgressScreen()
|
modal_prog = ProgressScreen()
|
||||||
self.app.call_from_thread(self.app.push_screen, modal_prog)
|
self.app.call_from_thread(self.app.push_screen, modal_prog)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
self.app.call_from_thread(modal_prog.phase_progress)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.app.call_from_thread(modal_prog.set_status, "Starting sync...")
|
self.app.call_from_thread(modal_prog.set_status, "Starting sync...")
|
||||||
|
|
@ -288,10 +309,10 @@ class BackupPane(Container):
|
||||||
|
|
||||||
await self.exporter.export_metadata()
|
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.write, "[bold green]Sync operation complete![/bold green]")
|
||||||
|
self.app.call_from_thread(modal_prog.phase_report, "Backup Sync")
|
||||||
|
|
||||||
except Exception as e:
|
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.write, f"[bold red]Sync failed: {e}[/bold red]")
|
||||||
|
self.app.call_from_thread(modal_prog.phase_report, "Backup Sync", "error")
|
||||||
finally:
|
finally:
|
||||||
await self.engine.close_connections()
|
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."))
|
|
||||||
|
|
|
||||||
327
src/ui/modals.py
327
src/ui/modals.py
|
|
@ -9,6 +9,22 @@ from textual.screen import ModalScreen, Screen
|
||||||
|
|
||||||
|
|
||||||
import time
|
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
|
# 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; }
|
.stat_label { width: 1fr; content-align: center middle; text-style: bold; }
|
||||||
|
|
||||||
#prog_log { height: 1fr; margin-bottom: 1; border: solid $primary; }
|
#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_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_bar { margin-bottom: 1; width: 80%; }
|
||||||
|
|
||||||
#prog_actions { height: auto; margin-top: 1; dock: bottom; margin-bottom: 0; }
|
#info_container { height: auto; layout: vertical; border: solid $secondary; padding: 1; margin-bottom: 1; display: none; }
|
||||||
#btn_close_progress { width: 1fr; margin: 0 1; }
|
.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:
|
def compose(self) -> ComposeResult:
|
||||||
|
|
@ -66,21 +87,59 @@ class ProgressScreen(Screen[None]):
|
||||||
yield Label("Files: 0", id="stat_files", classes="stat_label")
|
yield Label("Files: 0", id="stat_files", classes="stat_label")
|
||||||
|
|
||||||
yield LoadingIndicator(id="prog_loader")
|
yield LoadingIndicator(id="prog_loader")
|
||||||
with Center(id="prog_bar_container"):
|
|
||||||
pb = ProgressBar(total=None, show_eta=False, id="prog_bar")
|
with Vertical(id="info_container"):
|
||||||
pb.display = False
|
yield Label("No previous migration data.", id="info_migration_status", classes="info_label")
|
||||||
yield pb
|
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="prog_log", highlight=True, markup=True)
|
||||||
|
yield RichLog(id="live_log", highlight=True, markup=True)
|
||||||
|
|
||||||
with Horizontal(id="prog_actions"):
|
with Vertical(id="prog_actions"):
|
||||||
yield Button("Close", id="btn_close_progress", disabled=True)
|
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()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
self.confirm_future = None
|
||||||
|
self.cancel_callback = None
|
||||||
self.start_time = time.time()
|
self.start_time = time.time()
|
||||||
self.timer_event = self.set_interval(1.0, self.update_timer)
|
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):
|
def update_timer(self):
|
||||||
elapsed = int(time.time() - self.start_time)
|
elapsed = int(time.time() - self.start_time)
|
||||||
mins, secs = divmod(elapsed, 60)
|
mins, secs = divmod(elapsed, 60)
|
||||||
|
|
@ -90,10 +149,25 @@ class ProgressScreen(Screen[None]):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed):
|
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:
|
if self.timer_event:
|
||||||
self.timer_event.stop()
|
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):
|
def write(self, message: str):
|
||||||
try:
|
try:
|
||||||
|
|
@ -101,6 +175,12 @@ class ProgressScreen(Screen[None]):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def write_live(self, message: str):
|
||||||
|
try:
|
||||||
|
self.query_one("#live_log", RichLog).write(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
write_to_log = write
|
write_to_log = write
|
||||||
|
|
||||||
def set_status(self, status: str):
|
def set_status(self, status: str):
|
||||||
|
|
@ -132,79 +212,119 @@ class ProgressScreen(Screen[None]):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def allow_close(self):
|
async def phase_wait_confirm(self, show_continue: bool = False):
|
||||||
if self.timer_event:
|
"""Phase 2: Wait for user confirmation after analysis."""
|
||||||
self.timer_event.stop()
|
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:
|
try:
|
||||||
btn = self.query_one("#btn_close_progress", Button)
|
if show_continue:
|
||||||
btn.disabled = False
|
self.query_one("#btn_continue", Button).disabled = False
|
||||||
btn.variant = "success"
|
self.query_one("#btn_continue", Button).display = True
|
||||||
self.query_one("#prog_loader", LoadingIndicator).display = False
|
else:
|
||||||
bar = self.query_one("#prog_bar", ProgressBar)
|
self.query_one("#btn_continue", Button).display = False
|
||||||
bar.display = True
|
|
||||||
bar.update(total=100, progress=100)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
loop = asyncio.get_running_loop()
|
||||||
# ReportModal – simple post-operation report
|
self.confirm_future = loop.create_future()
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ReportModal(ModalScreen[None]):
|
# Wait until the user clicks one of the buttons
|
||||||
"""Modal to display a post-operation report."""
|
choice = await self.confirm_future
|
||||||
|
return choice
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
def phase_progress(self):
|
||||||
ReportModal { align: center middle; }
|
"""Phase 3: The actual operation begins. Only Cancel visible."""
|
||||||
#report_dialog {
|
try: self.query_one("#prog_actions_row1", Horizontal).display = False
|
||||||
width: 60;
|
except Exception: pass
|
||||||
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):
|
try: self.query_one("#prog_actions_row2", Horizontal).display = False
|
||||||
super().__init__()
|
except Exception: pass
|
||||||
self.report_title = title
|
|
||||||
self.report_text = report_text
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
# Show Cancel button
|
||||||
with Vertical(id="report_dialog"):
|
try: self.query_one("#prog_actions_cancel", Horizontal).display = True
|
||||||
yield Label(self.report_title, id="report_title")
|
except Exception: pass
|
||||||
yield Label(self.report_text, id="report_content")
|
|
||||||
yield Button("OK", variant="primary", id="report_btn")
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed):
|
try: self.query_one("#prog_loader", LoadingIndicator).display = True
|
||||||
self.dismiss(None)
|
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
|
||||||
# ConfirmModal – simple yes / no
|
except Exception: pass
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ConfirmModal(ModalScreen[bool]):
|
def phase_report(self, operation_name: str, status: str = "complete"):
|
||||||
"""Simple Yes / No confirmation modal."""
|
"""Phase 4: Operation is done. Show Back + Main Menu.
|
||||||
DEFAULT_CSS = "ConfirmModal { align: center middle; }"
|
|
||||||
|
|
||||||
def __init__(self, message: str, danger: bool = False):
|
status can be: 'complete', 'stopped', 'error'
|
||||||
super().__init__()
|
"""
|
||||||
self._message = message
|
try:
|
||||||
self._danger = danger
|
if self.timer_event:
|
||||||
|
self.timer_event.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
# Format status title with color
|
||||||
with Vertical(id="confirm_dialog"):
|
status_map = {
|
||||||
yield Label(self._message, id="confirm_msg")
|
"complete": ("[bold green]", "Complete"),
|
||||||
with Horizontal(id="confirm_buttons"):
|
"stopped": ("[bold yellow]", "Stopped"),
|
||||||
yield Button("Confirm", variant="error" if self._danger else "success", id="btn_yes")
|
"error": ("[bold red]", "Error"),
|
||||||
yield Button("Cancel", variant="primary", id="btn_no")
|
}
|
||||||
|
color, label = status_map.get(status, ("[bold]", status.capitalize()))
|
||||||
|
self.set_status(f"{color}{operation_name} {label}![/{color.split(']')[0].lstrip('[')}]")
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed):
|
# Hide loader
|
||||||
self.dismiss(event.button.id == "btn_yes")
|
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
|
||||||
|
|
||||||
|
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
|
# SubMenuModal – generic labelled-button list
|
||||||
|
|
@ -235,6 +355,71 @@ class SubMenuModal(ModalScreen[str]):
|
||||||
self.dismiss(event.button.id)
|
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)
|
# ChannelPickerModal – single-channel selection (shuttle)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -322,11 +507,11 @@ class ChannelPickerScreen(Screen[tuple]):
|
||||||
yield Rule()
|
yield Rule()
|
||||||
with Horizontal(id="chanpick_buttons"):
|
with Horizontal(id="chanpick_buttons"):
|
||||||
yield Button("Select", variant="success", id="btn_pick_ok")
|
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()
|
yield Footer()
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed):
|
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)
|
self.dismiss(None)
|
||||||
elif event.button.id == "btn_pick_ok":
|
elif event.button.id == "btn_pick_ok":
|
||||||
src_val = None
|
src_val = None
|
||||||
|
|
@ -442,7 +627,7 @@ class ChannelSelectScreen(Screen[dict]):
|
||||||
yield Button("Force Overwrite", variant="error", id="btn_force")
|
yield Button("Force Overwrite", variant="error", id="btn_force")
|
||||||
else:
|
else:
|
||||||
yield Button("Backup", variant="success", id="btn_backup")
|
yield Button("Backup", variant="success", id="btn_backup")
|
||||||
yield Button("Cancel", id="btn_cancel_chan")
|
yield Button("Back", id="btn_cancel_chan")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
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")
|
server_name = self.validation_results.get("discord_server_name", "server")
|
||||||
|
|
||||||
# Check for existing migration
|
# 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
|
next_msg = None
|
||||||
if last_migrated_id:
|
if last_migrated_id:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from src.core.configuration import load_config
|
||||||
from src.core.base import MigrationContext
|
from src.core.base import MigrationContext
|
||||||
from src.core.audit import log_audit_event
|
from src.core.audit import log_audit_event
|
||||||
from src.ui.modals import (
|
from src.ui.modals import (
|
||||||
ProgressScreen, ReportModal, ConfirmModal, SubMenuModal, ChannelPickerScreen,
|
ProgressScreen, SubMenuModal, ChannelPickerScreen, OptionSelectModal,
|
||||||
)
|
)
|
||||||
|
|
||||||
import src.fluxer.roles_permissions as fluxer_roles
|
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")
|
yield Label("Status: [yellow]Validating...[/yellow]", id="sp_lbl_status")
|
||||||
with Vertical(id="sp_actions"):
|
with Vertical(id="sp_actions"):
|
||||||
yield Button("Clone Server Template", id="sp_clone", disabled=True)
|
yield Button("Clone Server Template", id="sp_clone", disabled=True)
|
||||||
yield Button("Copy Roles & Permissions", id="sp_roles", disabled=True)
|
yield Button("Sync Server Settings", id="sp_sync", disabled=True)
|
||||||
yield Button("Copy Emojis & Stickers", id="sp_emojis", disabled=True)
|
|
||||||
yield Button("Sync Server Profile", id="sp_metadata", disabled=True)
|
|
||||||
yield Button("Migrate Message History", id="sp_messages", disabled=True)
|
yield Button("Migrate Message History", id="sp_messages", disabled=True)
|
||||||
yield Rule()
|
yield Rule()
|
||||||
yield Button("Danger Zone ⚠", id="sp_danger", variant="error", disabled=True)
|
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}")
|
self.query_one("#sp_lbl_status", Label).update(f"Status: {val}")
|
||||||
|
|
||||||
# Buttons
|
# 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
|
self.query_one(bid, Button).disabled = not self.tokens_valid
|
||||||
|
|
||||||
# ── validation ────────────────────────────────────────────────────────
|
# ── validation ────────────────────────────────────────────────────────
|
||||||
|
|
@ -271,282 +269,223 @@ class ShuttlePane(Container):
|
||||||
if not bid or not bid.startswith("sp_"):
|
if not bid or not bid.startswith("sp_"):
|
||||||
return
|
return
|
||||||
if bid == "sp_clone":
|
if bid == "sp_clone":
|
||||||
self.run_clone_template()
|
self._open_clone_menu()
|
||||||
elif bid == "sp_roles":
|
elif bid == "sp_sync":
|
||||||
self._open_roles_menu()
|
self._open_sync_menu()
|
||||||
elif bid == "sp_emojis":
|
|
||||||
self._open_emoji_menu()
|
|
||||||
elif bid == "sp_metadata":
|
|
||||||
self._open_metadata_menu()
|
|
||||||
elif bid == "sp_messages":
|
elif bid == "sp_messages":
|
||||||
self.run_migrate_messages()
|
self.run_migrate_messages()
|
||||||
elif bid == "sp_danger":
|
elif bid == "sp_danger":
|
||||||
self._open_danger_menu()
|
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)
|
@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":
|
if self.target_platform == "fluxer":
|
||||||
from src.fluxer.clone_server import sync_channel_state, migrate_channels
|
from src.fluxer.clone_server import sync_channel_state, migrate_channels
|
||||||
else:
|
else:
|
||||||
from src.stoat.clone_server import sync_channel_state, migrate_channels
|
from src.stoat.clone_server import sync_channel_state, migrate_channels
|
||||||
|
|
||||||
modal = ProgressScreen()
|
modal.set_status("Processing Server Structure...")
|
||||||
self.app.push_screen(modal)
|
await sync_channel_state(self.engine)
|
||||||
await asyncio.sleep(0.1)
|
categories = await self.engine.discord_reader.get_categories()
|
||||||
|
channels = await self.engine.discord_reader.get_channels()
|
||||||
|
|
||||||
try:
|
async def update_progress(item_name, status, current, total):
|
||||||
modal.set_status("Fetching server structure...")
|
color = "cyan" if status == "Copying" else "yellow"
|
||||||
await self.engine.start_connections()
|
modal.set_status(f"[{color}]{status}: {item_name}[/{color}]")
|
||||||
await sync_channel_state(self.engine)
|
modal.set_progress(current, total)
|
||||||
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)))
|
cloned_info = await migrate_channels(self.engine, progress_callback=update_progress, force=False)
|
||||||
cached += sum(1 for c in channels if self.engine.state.get_fluxer_channel_id(str(c.id)))
|
if cloned_info and cloned_info.get("structure"):
|
||||||
total = len(categories) + len(channels)
|
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.write(f"[yellow]Found {total} items, {cached} already cloned.[/yellow]")
|
async def _logic_clone_roles(self, modal: ProgressScreen):
|
||||||
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:
|
|
||||||
roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles
|
roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles
|
||||||
modal = ProgressScreen()
|
modal.set_status("Processing Roles...")
|
||||||
self.app.push_screen(modal)
|
await roles_mod.sync_roles_state(self.engine)
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
try:
|
async def update(name, current, total):
|
||||||
modal.set_status("Checking existing roles...")
|
modal.set_status(f"[cyan]Copying Role: {name}[/cyan]")
|
||||||
await self.engine.start_connections()
|
modal.set_progress(current, total)
|
||||||
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)))
|
cloned = await roles_mod.migrate_roles(self.engine, progress_callback=update, force=False)
|
||||||
modal.write(f"[yellow]Found {len(roles)} roles, {cached} already cloned.[/yellow]")
|
if cloned:
|
||||||
modal.set_status("Cloning roles...")
|
await log_audit_event(self.engine, "Roles Cloned", "Successfully cloned roles:\n" + "\n".join(f"- {r}" for r in cloned))
|
||||||
|
|
||||||
async def update(name, current, total):
|
async def _logic_sync_permissions(self, modal: ProgressScreen):
|
||||||
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:
|
|
||||||
roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles
|
roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles
|
||||||
modal = ProgressScreen()
|
modal.set_status("Syncing Permissions...")
|
||||||
self.app.push_screen(modal)
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
try:
|
async def update(name, current, total):
|
||||||
modal.set_status("Syncing permissions...")
|
modal.set_status(f"[cyan]Syncing Perms: {name}[/cyan]")
|
||||||
await self.engine.start_connections()
|
modal.set_progress(current, total)
|
||||||
|
|
||||||
async def update(name, current, total):
|
synced = await roles_mod.sync_permissions(self.engine, progress_callback=update)
|
||||||
modal.set_status(f"[cyan]Syncing: {name}[/cyan]")
|
if synced and synced.get("structure"):
|
||||||
modal.set_progress(current, total)
|
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))
|
||||||
|
|
||||||
self.engine.is_running = True
|
async def _logic_copy_assets(self, modal: ProgressScreen, types_to_include: list[str]):
|
||||||
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:
|
|
||||||
asset_mod = stoat_emoji_stickers if self.target_platform == "stoat" else fluxer_emoji_stickers
|
asset_mod = stoat_emoji_stickers if self.target_platform == "stoat" else fluxer_emoji_stickers
|
||||||
modal = ProgressScreen()
|
modal.set_status("Processing Assets...")
|
||||||
self.app.push_screen(modal)
|
await asset_mod.sync_assets_state(self.engine)
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
try:
|
async def update(name, item_type, current, total):
|
||||||
modal.set_status("Checking existing assets...")
|
modal.set_status(f"[cyan]Copying {item_type}: {name}[/cyan]")
|
||||||
await self.engine.start_connections()
|
modal.set_progress(current, total)
|
||||||
await asset_mod.sync_assets_state(self.engine)
|
|
||||||
|
|
||||||
modal.set_status("Copying assets...")
|
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))
|
||||||
|
|
||||||
async def update(name, item_type, current, total):
|
async def _logic_sync_metadata(self, modal: ProgressScreen, components: list[str]):
|
||||||
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:
|
|
||||||
meta_mod = fluxer_metadata if self.target_platform == "fluxer" else stoat_metadata
|
meta_mod = fluxer_metadata if self.target_platform == "fluxer" else stoat_metadata
|
||||||
modal = ProgressScreen()
|
modal.set_status("Syncing Server Profile...")
|
||||||
self.app.push_screen(modal)
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
try:
|
async def progress_cb(item, status):
|
||||||
modal.set_status("Syncing server metadata...")
|
color = "green" if status == "DONE" else "red" if status == "ERROR" else "yellow"
|
||||||
await self.engine.start_connections()
|
modal.write(f"{item} [[bold {color}]{status}[/bold {color}]]")
|
||||||
|
|
||||||
async def progress_cb(item, status):
|
cloned = await meta_mod.sync_server_metadata(self.engine, progress_cb, components=components)
|
||||||
color = "green" if status == "DONE" else "red" if status == "ERROR" else "yellow"
|
if cloned:
|
||||||
modal.write(f"{item} [[bold {color}]{status}[/bold {color}]]")
|
lines = ["Synchronized profile traits:"]
|
||||||
|
if "name" in cloned: lines.append(f"- **Name**: {cloned['name']}")
|
||||||
cloned = await meta_mod.sync_server_metadata(self.engine, progress_cb, components=components)
|
if "icon" in cloned: lines.append("- **Icon**")
|
||||||
|
if "banner" in cloned: lines.append("- **Banner**")
|
||||||
modal.write("[bold green]Server profile sync finished![/bold green]")
|
# Prepare files for audit log
|
||||||
|
|
||||||
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**")
|
|
||||||
files = []
|
files = []
|
||||||
if "icon" in cloned:
|
if "icon" in cloned:
|
||||||
ext = "gif" if cloned["icon"].startswith(b"GIF") else "png"
|
ext = "gif" if cloned["icon"].startswith(b"GIF") else "png"
|
||||||
|
|
@ -554,16 +493,7 @@ class ShuttlePane(Container):
|
||||||
if "banner" in cloned:
|
if "banner" in cloned:
|
||||||
ext = "gif" if cloned["banner"].startswith(b"GIF") else "png"
|
ext = "gif" if cloned["banner"].startswith(b"GIF") else "png"
|
||||||
files.append({"filename": f"banner.{ext}", "data": cloned["banner"]})
|
files.append({"filename": f"banner.{ext}", "data": cloned["banner"]})
|
||||||
if cloned:
|
await log_audit_event(self.engine, "Profile Synced", "\n".join(lines), files=files)
|
||||||
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()
|
|
||||||
|
|
||||||
# ── (5) message migration ─────────────────────────────────────────────
|
# ── (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}
|
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()
|
while True:
|
||||||
pick_future = loop.create_future()
|
loop = asyncio.get_running_loop()
|
||||||
|
pick_future = loop.create_future()
|
||||||
|
|
||||||
def on_pick(result):
|
def on_pick(result):
|
||||||
if not pick_future.done():
|
if not pick_future.done():
|
||||||
pick_future.set_result(result)
|
pick_future.set_result(result)
|
||||||
|
|
||||||
self.app.push_screen(ChannelPickerScreen(d_channels, d_cat_map, f_channels, target_cat_names, platform_name), on_pick)
|
self.app.push_screen(ChannelPickerScreen(d_channels, d_cat_map, f_channels, target_cat_names, platform_name), on_pick)
|
||||||
res = await pick_future
|
res = await pick_future
|
||||||
|
|
||||||
if res is None:
|
if res is None:
|
||||||
await self.engine.close_connections()
|
await self.engine.close_connections()
|
||||||
return
|
return
|
||||||
|
|
||||||
src_id, tgt_id = res
|
src_id, tgt_id = res
|
||||||
source_channel = next(c for c in d_channels if c.id == src_id)
|
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)
|
target_channel = next(c for c in f_channels if c.get("id") == tgt_id)
|
||||||
|
|
||||||
# Determine after_id
|
# Determine after_id status
|
||||||
after_id = None
|
last_migrated = self.engine.state.get_last_message_id(str(target_channel.get('id')))
|
||||||
last_migrated = self.engine.state.get_last_message_id(str(source_channel.id))
|
has_previous = bool(last_migrated)
|
||||||
if last_migrated:
|
|
||||||
after_id = int(last_migrated)
|
|
||||||
|
|
||||||
# Analyze
|
# Analyze
|
||||||
modal = ProgressScreen()
|
modal = ProgressScreen()
|
||||||
self.app.push_screen(modal)
|
self.app.push_screen(modal)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
modal.set_status("Analyzing channel...")
|
modal.set_status("Analyzing channel...")
|
||||||
modal.show_stats()
|
modal.show_stats()
|
||||||
|
|
||||||
self.engine.is_running = True
|
self.engine.is_running = True
|
||||||
stats = {"messages": 0, "threads": 0, "attachments": 0}
|
stats_analysis = {"messages": 0, "threads": 0, "attachments": 0}
|
||||||
|
|
||||||
async def update_scan(count):
|
async def update_scan(current_stats):
|
||||||
modal.set_status(f"[cyan]Scanned {count} items...")
|
modal.set_status(f"[cyan]Scanned {current_stats['messages']} items...")
|
||||||
|
|
||||||
stats = await migrate_mod.analyze_migration(
|
stats_analysis = await migrate_mod.analyze_migration(
|
||||||
self.engine,
|
self.engine,
|
||||||
source_channel_id=source_channel.id,
|
source_channel_id=source_channel.id,
|
||||||
after_message_id=after_id,
|
after_message_id=int(last_migrated) if last_migrated else None,
|
||||||
progress_callback=update_scan,
|
progress_callback=update_scan,
|
||||||
)
|
)
|
||||||
self.engine.is_running = False
|
self.engine.is_running = False
|
||||||
|
|
||||||
modal.write(f"[cyan]Messages: {stats['messages']}, Threads: {stats['threads']}, Attachments: {stats['attachments']}[/cyan]")
|
# Set initial total stats for the confirmation block
|
||||||
modal.write(f"Migrating Discord [cyan]#{source_channel.name}[/cyan] → {platform_name} [green]#{target_channel.get('name')}[/green]")
|
modal.update_stats(
|
||||||
|
messages=stats_analysis['messages'],
|
||||||
|
threads=stats_analysis['threads'],
|
||||||
|
files=stats_analysis['attachments']
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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...")
|
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
|
self.engine.is_running = True
|
||||||
|
|
||||||
async def update_msg(count):
|
async def update_msg(current_stats):
|
||||||
modal.set_status(f"[cyan]Migrated {count}/{total_messages} messages...")
|
c_msgs = current_stats["messages"]
|
||||||
modal.set_progress(count, total_messages)
|
c_threads = current_stats["threads"]
|
||||||
modal.update_stats(messages=count)
|
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(
|
result = await migrate_mod.migrate_messages(
|
||||||
self.engine,
|
self.engine,
|
||||||
|
|
@ -676,15 +658,12 @@ class ShuttlePane(Container):
|
||||||
|
|
||||||
if self.engine.is_running:
|
if self.engine.is_running:
|
||||||
modal.write(f"[bold green]Success! {result['messages']} messages migrated.[/bold green]")
|
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:
|
else:
|
||||||
modal.write(f"[bold yellow]Interrupted! {result['messages']} messages migrated.[/bold yellow]")
|
modal.write(f"[bold yellow]Interrupted! {result['messages']} messages migrated.[/bold yellow]")
|
||||||
event_title = "Message History Migration Interrupted"
|
event_title = "Message Migration"
|
||||||
|
modal.phase_report(event_title, "stopped")
|
||||||
self.app.push_screen(ReportModal(
|
|
||||||
event_title,
|
|
||||||
f"Successfully processed {result['messages']} messages, {result['attachments']} attachments, and {result['threads']} threads."
|
|
||||||
))
|
|
||||||
|
|
||||||
lines = [f"Migrated Discord #{source_channel.name} → {platform_name} #{target_channel.get('name')}:"]
|
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")
|
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]")
|
modal.write("[bold red]Bot is missing the 'Masquerade' permission.[/bold red]")
|
||||||
else:
|
else:
|
||||||
modal.write(f"[bold red]Error: {err}[/bold red]")
|
modal.write(f"[bold red]Error: {err}[/bold red]")
|
||||||
|
modal.phase_report("Message Migration", "error")
|
||||||
finally:
|
finally:
|
||||||
self.engine.is_running = False
|
self.engine.is_running = False
|
||||||
await self.engine.close_connections()
|
await self.engine.close_connections()
|
||||||
modal.allow_close()
|
|
||||||
|
|
||||||
# ── (6) danger zone ───────────────────────────────────────────────────
|
# ── (6) danger zone ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -722,10 +701,22 @@ class ShuttlePane(Container):
|
||||||
self.app.push_screen(SubMenuModal("⚠ DANGER ZONE ⚠", options), on_result)
|
self.app.push_screen(SubMenuModal("⚠ DANGER ZONE ⚠", options), on_result)
|
||||||
|
|
||||||
def _confirm_danger(self, message: str, callback):
|
def _confirm_danger(self, message: str, callback):
|
||||||
def on_confirm(confirmed: bool):
|
async def do_confirm():
|
||||||
if confirmed:
|
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()
|
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)
|
@work(exclusive=True)
|
||||||
async def run_dz_delete_channels(self) -> None:
|
async def run_dz_delete_channels(self) -> None:
|
||||||
|
|
@ -737,6 +728,8 @@ class ShuttlePane(Container):
|
||||||
modal = ProgressScreen()
|
modal = ProgressScreen()
|
||||||
self.app.push_screen(modal)
|
self.app.push_screen(modal)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
|
||||||
|
modal.phase_progress()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
modal.set_status("[red]Deleting channels...")
|
modal.set_status("[red]Deleting channels...")
|
||||||
|
|
@ -747,14 +740,15 @@ class ShuttlePane(Container):
|
||||||
modal.set_progress(current, total)
|
modal.set_progress(current, total)
|
||||||
|
|
||||||
count = await danger_delete_all_channels(self.engine, progress_callback=on_deleted)
|
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.")
|
await log_audit_event(self.engine, "Danger Zone: Channels Wiped", f"Deleted {count} channels and categories.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||||
|
modal.phase_report("Delete Channels", "error")
|
||||||
finally:
|
finally:
|
||||||
await self.engine.close_target_only()
|
await self.engine.close_target_only()
|
||||||
modal.set_status("Finished.")
|
|
||||||
modal.allow_close()
|
|
||||||
|
|
||||||
@work(exclusive=True)
|
@work(exclusive=True)
|
||||||
async def run_dz_reset_perms(self) -> None:
|
async def run_dz_reset_perms(self) -> None:
|
||||||
|
|
@ -766,6 +760,8 @@ class ShuttlePane(Container):
|
||||||
modal = ProgressScreen()
|
modal = ProgressScreen()
|
||||||
self.app.push_screen(modal)
|
self.app.push_screen(modal)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
|
||||||
|
modal.phase_progress()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
modal.set_status("[red]Resetting permissions...")
|
modal.set_status("[red]Resetting permissions...")
|
||||||
|
|
@ -776,14 +772,15 @@ class ShuttlePane(Container):
|
||||||
modal.set_progress(current, total)
|
modal.set_progress(current, total)
|
||||||
|
|
||||||
count = await danger_reset_channel_permissions(self.engine, progress_callback=on_reset)
|
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.")
|
await log_audit_event(self.engine, "Danger Zone: Permissions Wiped", f"Reset permissions on {count} items.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||||
|
modal.phase_report("Wipe Permissions", "error")
|
||||||
finally:
|
finally:
|
||||||
await self.engine.close_target_only()
|
await self.engine.close_target_only()
|
||||||
modal.set_status("Finished.")
|
|
||||||
modal.allow_close()
|
|
||||||
|
|
||||||
@work(exclusive=True)
|
@work(exclusive=True)
|
||||||
async def run_dz_delete_roles(self) -> None:
|
async def run_dz_delete_roles(self) -> None:
|
||||||
|
|
@ -795,6 +792,8 @@ class ShuttlePane(Container):
|
||||||
modal = ProgressScreen()
|
modal = ProgressScreen()
|
||||||
self.app.push_screen(modal)
|
self.app.push_screen(modal)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
|
||||||
|
modal.phase_progress()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
modal.set_status("[red]Deleting roles...")
|
modal.set_status("[red]Deleting roles...")
|
||||||
|
|
@ -805,14 +804,15 @@ class ShuttlePane(Container):
|
||||||
modal.set_progress(current, total)
|
modal.set_progress(current, total)
|
||||||
|
|
||||||
count = await danger_delete_all_roles(self.engine, progress_callback=on_deleted)
|
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.")
|
await log_audit_event(self.engine, "Danger Zone: Roles Wiped", f"Deleted {count} roles.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||||
|
modal.phase_report("Wipe Roles", "error")
|
||||||
finally:
|
finally:
|
||||||
await self.engine.close_target_only()
|
await self.engine.close_target_only()
|
||||||
modal.set_status("Finished.")
|
|
||||||
modal.allow_close()
|
|
||||||
|
|
||||||
@work(exclusive=True)
|
@work(exclusive=True)
|
||||||
async def run_dz_delete_assets(self) -> None:
|
async def run_dz_delete_assets(self) -> None:
|
||||||
|
|
@ -824,6 +824,8 @@ class ShuttlePane(Container):
|
||||||
modal = ProgressScreen()
|
modal = ProgressScreen()
|
||||||
self.app.push_screen(modal)
|
self.app.push_screen(modal)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
|
||||||
|
modal.phase_progress()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
modal.set_status("[red]Deleting assets...")
|
modal.set_status("[red]Deleting assets...")
|
||||||
|
|
@ -834,11 +836,12 @@ class ShuttlePane(Container):
|
||||||
modal.set_progress(current, total)
|
modal.set_progress(current, total)
|
||||||
|
|
||||||
counts = await danger_delete_all_emojis_and_stickers(self.engine, progress_callback=on_deleted)
|
counts = await danger_delete_all_emojis_and_stickers(self.engine, progress_callback=on_deleted)
|
||||||
modal.write(f"[bold green]{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.")
|
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:
|
except Exception as e:
|
||||||
modal.write(f"[bold red]Error: {e}[/bold red]")
|
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||||
|
modal.phase_report("Wipe Assets", "error")
|
||||||
finally:
|
finally:
|
||||||
await self.engine.close_target_only()
|
await self.engine.close_target_only()
|
||||||
modal.set_status("Finished.")
|
|
||||||
modal.allow_close()
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue