fix back button behavior & improve logging info

This commit is contained in:
rambros 2026-03-03 16:44:36 +05:30
parent c7adc61a4a
commit eb029ac656
14 changed files with 283 additions and 242 deletions

View file

@ -31,11 +31,15 @@ class MigrationContext:
messages_file: str | Path = "" messages_file: str | Path = ""
if server_id != "unconfigured": if server_id != "unconfigured":
logger.info(f"Probing for existing migration folder for Server ID: {server_id}")
for d in Path(".").iterdir(): for d in Path(".").iterdir():
if d.is_dir() and d.name.endswith(f"-{server_id}"): if d.is_dir() and d.name.endswith(f"-{server_id}"):
logger.info(f"Targeting existing folder: {d.name}")
state_file = d / "state-migration.json" state_file = d / "state-migration.json"
messages_file = d / "message-tracker.json" messages_file = d / "message-tracker.json"
break break
else:
logger.info(f"No existing folder found for {server_id}. A new one will be created upon first state change.")
self.state = MigrationState( self.state = MigrationState(
state_file=state_file, state_file=state_file,

View file

@ -179,6 +179,7 @@ class DiscordReader:
channel = await self.get_channel(channel_id) channel = await self.get_channel(channel_id)
if isinstance(channel, discord.TextChannel) or isinstance(channel, discord.Thread): if isinstance(channel, discord.TextChannel) or isinstance(channel, discord.Thread):
after = discord.Object(id=after_id) if after_id else None after = discord.Object(id=after_id) if after_id else None
logger.info(f"Fetching message history for {channel.name} ({channel.id}) oldest_first=True after={after_id}")
# To avoid exploding RAM, we yield items one by one # To avoid exploding RAM, we yield items one by one
async for message in channel.history(limit=limit, oldest_first=True, after=after): async for message in channel.history(limit=limit, oldest_first=True, after=after):
yield message yield message

View file

@ -45,27 +45,33 @@ class MigrationState:
# 2. Load separate messages file # 2. Load separate messages file
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: logger.info(f"Loading messages from {self.messages_file.name}")
msg_data = json.load(f) try:
with open(self.messages_file, "r", encoding="utf-8") as f:
# Check for new schema (nested under 'channels') msg_data = json.load(f)
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: # Check for new schema (nested under 'channels')
self.channel_messages = { if "channels" in msg_data:
"legacy_migrated_channel": { self.channel_messages = msg_data.get("channels", {})
"message_map": legacy_map, logger.debug(f"Loaded {len(self.channel_messages)} tracked channels.")
"last_message_id": list(legacy_ids.values())[-1] if legacy_ids else "", else:
"last_message_timestamp": list(legacy_times.values())[-1] if legacy_times else "" logger.warning("Legacy schema or empty tracker detected in messages file.")
# 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 ""
}
} }
} except Exception as e:
logger.error(f"Failed to load messages file: {e}")
@ -73,6 +79,8 @@ class MigrationState:
"""Saves only the core server configuration (channels, roles, emojis).""" """Saves only the core server configuration (channels, roles, emojis)."""
if not self.state_file: if not self.state_file:
return return
logger.debug(f"Saving state to {self.state_file.name}")
data = { data = {
"channels": self.channel_map, "channels": self.channel_map,
"categories": self.category_map, "categories": self.category_map,
@ -81,18 +89,26 @@ class MigrationState:
"stickers": self.sticker_map, "stickers": self.sticker_map,
"audit_log_channel": self.audit_log_channel "audit_log_channel": self.audit_log_channel
} }
with open(self.state_file, "w", encoding="utf-8") as f: try:
json.dump(data, f, indent=4) with open(self.state_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
except Exception as e:
logger.error(f"Failed to save state file: {e}")
def save_messages(self): def save_messages(self):
"""Saves only the message tracking data.""" """Saves only the message tracking data."""
if not self.messages_file: if not self.messages_file:
return return
logger.debug(f"Saving messages to {self.messages_file.name}")
data = { data = {
"channels": self.channel_messages "channels": self.channel_messages
} }
with open(self.messages_file, "w", encoding="utf-8") as f: try:
json.dump(data, f, indent=4) with open(self.messages_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
except Exception as e:
logger.error(f"Failed to save messages file: {e}")
# --- Type Specific Getters/Setters --- # --- Type Specific Getters/Setters ---
@ -260,11 +276,13 @@ class MigrationState:
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 = ""):
base = Path(base_dir) if base_dir else Path(".") base = Path(base_dir) if base_dir else Path(".")
new_folder = base / f"{clean_name}-{server_id}" new_folder = base / f"{clean_name}-{server_id}"
logger.info(f"Setting active migration folder: {new_folder}")
# If we have an existing folder that is different, rename it # If we have an existing folder that is different, rename it
if self.state_file and self.state_file.parent.exists() and self.state_file.parent != new_folder: if self.state_file and self.state_file.parent.exists() and self.state_file.parent != new_folder:
# Check if it's actually in a server-specific folder (not roots) # Check if it's actually in a server-specific folder (not roots)
if self.state_file.parent.name.endswith(f"-{server_id}"): if self.state_file.parent.name.endswith(f"-{server_id}"):
logger.info(f"Renaming active folder from {self.state_file.parent.name} to {new_folder.name}")
try: try:
self.state_file.parent.rename(new_folder) self.state_file.parent.rename(new_folder)
except Exception as e: except Exception as e:
@ -275,4 +293,5 @@ 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"
logger.debug("Re-loading data from new folder location.")
self.load() self.load()

View file

@ -10,6 +10,7 @@ async def sync_assets_state(context: MigrationContext):
""" """
Scans Fluxer for emojis and stickers matching Discord names and updates state file mappings. Scans Fluxer for emojis and stickers matching Discord names and updates state file mappings.
""" """
logger.info("Synchronizing asset mappings (emojis/stickers) with Fluxer...")
discord_emojis = await context.discord_reader.get_emojis() discord_emojis = await context.discord_reader.get_emojis()
discord_stickers = await context.discord_reader.get_stickers() discord_stickers = await context.discord_reader.get_stickers()
@ -80,14 +81,16 @@ async def migrate_emojis(context: MigrationContext, progress_callback: Callable[
total = len(objs) total = len(objs)
cloned_assets: dict[str, dict[str, str]] = {"Emoji": {}, "Sticker": {}} cloned_assets: dict[str, dict[str, str]] = {"Emoji": {}, "Sticker": {}}
if total == 0: logger.info(f"Migrating {total} assets to Fluxer (Types: {', '.join(types_to_include)})...")
return cloned_assets
for idx, (obj, obj_type) in enumerate(objs): for idx, (obj, obj_type) in enumerate(objs):
if not context.is_running: break if not context.is_running:
logger.warning("Asset migration interrupted.")
break
try: try:
if obj_type == "Emoji": if obj_type == "Emoji":
logger.debug(f"Migrating emoji: {obj.name}")
img_data = await context.discord_reader.download_emoji(obj) img_data = await context.discord_reader.download_emoji(obj)
fluxer_id = await context.fluxer_writer.create_emoji( fluxer_id = await context.fluxer_writer.create_emoji(
name=obj.name, name=obj.name,
@ -97,6 +100,7 @@ async def migrate_emojis(context: MigrationContext, progress_callback: Callable[
context.state.set_emoji_mapping(str(obj.id), fluxer_id) context.state.set_emoji_mapping(str(obj.id), fluxer_id)
cloned_assets["Emoji"][obj.name] = fluxer_id cloned_assets["Emoji"][obj.name] = fluxer_id
else: else:
logger.debug(f"Migrating sticker: {obj.name}")
img_data = await context.discord_reader.download_sticker(obj) img_data = await context.discord_reader.download_sticker(obj)
fluxer_id = await context.fluxer_writer.create_sticker( fluxer_id = await context.fluxer_writer.create_sticker(
name=obj.name, name=obj.name,

View file

@ -99,16 +99,16 @@ async def analyze_migration(context: MigrationContext, source_channel_id: int, a
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]: 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, "threads": 0, "attachments": 0}
"messages": 0,
"attachments": 0, logger.info(f"Starting message migration: Discord #{source_channel_id} -> Fluxer #{target_channel_id}")
"threads": 0, if after_message_id:
"first_message_url": None, logger.info(f"Resuming migration from after message ID: {after_message_id}")
"last_message_url": None
}
try: try:
async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id): async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id):
if not context.is_running: if not context.is_running:
logger.warning("Migration interrupted by user (is_running=False)")
break break
@ -190,6 +190,10 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
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(target_channel_id, str(msg.reference.message_id)) reply_to_fluxer_id = context.state.get_fluxer_message_id(target_channel_id, str(msg.reference.message_id))
if reply_to_fluxer_id:
logger.debug(f"Detected reply to Discord ID {msg.reference.message_id} -> Fluxer ID {reply_to_fluxer_id}")
else:
logger.debug(f"Reply target Discord ID {msg.reference.message_id} not found in current session map.")
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,
@ -210,6 +214,10 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
stats["messages"] += 1 stats["messages"] += 1
context.state.increment_stats(target_channel_id, messages=1, files=len(files) if files else 0) context.state.increment_stats(target_channel_id, messages=1, files=len(files) if files else 0)
# Periodic log
if stats["messages"] % 50 == 0:
logger.info(f"Progress: Migrated {stats['messages']}/{total_to_process} messages in this channel.")
# 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:
thread = msg.thread thread = msg.thread

View file

@ -10,6 +10,7 @@ async def sync_roles_state(context: MigrationContext):
""" """
Scans Fluxer for roles matching Discord names and updates state file mappings. Scans Fluxer for roles matching Discord names and updates state file mappings.
""" """
logger.info("Synchronizing role mappings with Fluxer...")
discord_roles = await context.discord_reader.get_roles() discord_roles = await context.discord_reader.get_roles()
fluxer_roles = await context.fluxer_writer.client.get_guild_roles(context.config.fluxer_community_id) fluxer_roles = await context.fluxer_writer.client.get_guild_roles(context.config.fluxer_community_id)
@ -39,6 +40,7 @@ async def sync_roles_state(context: MigrationContext):
async def sync_permissions(context: MigrationContext, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None) -> dict: async def sync_permissions(context: MigrationContext, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None) -> dict:
"""Syncs category and channel role overrides/permissions.""" """Syncs category and channel role overrides/permissions."""
logger.info("Starting permissions synchronization...")
categories = await context.discord_reader.get_categories() categories = await context.discord_reader.get_categories()
channels = await context.discord_reader.get_channels() channels = await context.discord_reader.get_channels()
@ -56,7 +58,10 @@ async def sync_permissions(context: MigrationContext, progress_callback: Callabl
current_idx = 0 current_idx = 0
if total == 0: if total == 0:
logger.info("No mapped categories or channels found for permission sync.")
return synced_info return synced_info
logger.info(f"Syncing permissions for {len(categories)} categories and {len(channels)} channels.")
async def _sync_overwrites(discord_item, fluxer_id): async def _sync_overwrites(discord_item, fluxer_id):
"""Helper to sync role overwrites for a given channel or category.""" """Helper to sync role overwrites for a given channel or category."""
@ -120,6 +125,7 @@ async def sync_permissions(context: MigrationContext, progress_callback: Callabl
current_idx += 1 current_idx += 1
if progress_callback: await progress_callback(channel.name, current_idx, total) if progress_callback: await progress_callback(channel.name, current_idx, total)
logger.info(f"Permissions sync complete: {len(synced_info['categories_synced'])} categories, {len(synced_info['channels_synced'])} channels.")
return synced_info return synced_info
@ -133,12 +139,14 @@ async def migrate_roles(context: MigrationContext, progress_callback: Callable[[
total = len(roles) total = len(roles)
cloned_role_names = [] cloned_role_names = []
if total == 0: logger.info(f"Migrating {total} roles to Fluxer...")
return cloned_role_names
for idx, role in enumerate(roles): for idx, role in enumerate(roles):
if not context.is_running: break if not context.is_running:
logger.warning("Role migration interrupted.")
break
logger.debug(f"Creating role: {role.name}")
fluxer_id = await context.fluxer_writer.create_role( fluxer_id = await context.fluxer_writer.create_role(
name=role.name, name=role.name,
color=role.color.value, color=role.color.value,

View file

@ -14,10 +14,12 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal
if "name" in components: if "name" in components:
try: try:
name = metadata.get("name") name = metadata.get("name")
logger.info(f"Syncing server name: {name}")
await context.writer.update_guild_metadata(name=name) await context.writer.update_guild_metadata(name=name)
cloned_data["name"] = name cloned_data["name"] = name
await progress_callback("Server Name", "DONE") await progress_callback("Server Name", "DONE")
except Exception: except Exception as e:
logger.error(f"Failed to sync server name: {e}")
await progress_callback("Server Name", "ERROR") await progress_callback("Server Name", "ERROR")
# 2. Sync Icon # 2. Sync Icon
@ -28,12 +30,15 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal
icon_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.icon) icon_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.icon)
if icon_bytes: if icon_bytes:
logger.info(f"Uploading server icon ({len(icon_bytes)} bytes)...")
await context.writer.update_guild_metadata(icon=icon_bytes) await context.writer.update_guild_metadata(icon=icon_bytes)
cloned_data["icon"] = icon_bytes cloned_data["icon"] = icon_bytes
await progress_callback("Server Icon", "DONE") await progress_callback("Server Icon", "DONE")
else: else:
logger.info("No server icon found to sync.")
await progress_callback("Server Icon", "SKIP") await progress_callback("Server Icon", "SKIP")
except Exception: except Exception as e:
logger.error(f"Failed to sync server icon: {e}")
await progress_callback("Server Icon", "ERROR") await progress_callback("Server Icon", "ERROR")
# 3. Sync Banner # 3. Sync Banner
@ -44,12 +49,15 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal
banner_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.banner) banner_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.banner)
if banner_bytes: if banner_bytes:
logger.info(f"Uploading server banner ({len(banner_bytes)} bytes)...")
await context.writer.update_guild_metadata(banner=banner_bytes) await context.writer.update_guild_metadata(banner=banner_bytes)
cloned_data["banner"] = banner_bytes cloned_data["banner"] = banner_bytes
await progress_callback("Server Banner", "DONE") await progress_callback("Server Banner", "DONE")
else: else:
logger.info("No server banner found to sync.")
await progress_callback("Server Banner", "SKIP") await progress_callback("Server Banner", "SKIP")
except Exception: except Exception as e:
logger.error(f"Failed to sync server banner: {e}")
await progress_callback("Server Banner", "ERROR") await progress_callback("Server Banner", "ERROR")
return cloned_data return cloned_data

View file

@ -10,6 +10,7 @@ async def sync_assets_state(context: MigrationContext):
""" """
Scans Stoat for emojis matching Discord names and updates state file mappings. Scans Stoat for emojis matching Discord names and updates state file mappings.
""" """
logger.info("Synchronizing asset mappings (emojis) with Stoat...")
discord_emojis = await context.discord_reader.get_emojis() discord_emojis = await context.discord_reader.get_emojis()
# Stickers not supported on Stoat based on current library investigation # Stickers not supported on Stoat based on current library investigation
@ -74,11 +75,16 @@ async def migrate_emojis(context: MigrationContext, progress_callback: Callable[
if total == 0: if total == 0:
return cloned_assets return cloned_assets
logger.info(f"Migrating {total} assets to Stoat (Types: {', '.join(types_to_include)})...")
for idx, (obj, obj_type) in enumerate(objs): for idx, (obj, obj_type) in enumerate(objs):
if not context.is_running: break if not context.is_running:
logger.warning("Stoat asset migration interrupted.")
break
try: try:
if obj_type == "Emoji": if obj_type == "Emoji":
logger.debug(f"Migrating emoji to Stoat: {obj.name}")
img_data = await context.discord_reader.download_emoji(obj) img_data = await context.discord_reader.download_emoji(obj)
stoat_id = await context.writer.create_emoji( stoat_id = await context.writer.create_emoji(
name=obj.name, name=obj.name,

View file

@ -98,16 +98,16 @@ async def analyze_migration(context: MigrationContext, source_channel_id: int, a
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]: 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, "threads": 0, "attachments": 0}
"messages": 0,
"attachments": 0, logger.info(f"Starting message migration: Discord #{source_channel_id} -> Stoat #{target_channel_id}")
"threads": 0, if after_message_id:
"first_message_url": None, logger.info(f"Resuming migration from after message ID: {after_message_id}")
"last_message_url": None
}
try: try:
async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id): async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id):
if not context.is_running: if not context.is_running:
logger.warning("Migration interrupted by user (is_running=False)")
break break
@ -187,6 +187,10 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
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(target_channel_id, str(msg.reference.message_id)) reply_to_stoat_id = context.state.get_target_message_id(target_channel_id, str(msg.reference.message_id))
if reply_to_stoat_id:
logger.debug(f"Detected reply to Discord ID {msg.reference.message_id} -> Stoat ID {reply_to_stoat_id}")
else:
logger.debug(f"Reply target Discord ID {msg.reference.message_id} not found in current session map.")
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,
@ -206,6 +210,10 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
context.state.update_last_message_id(target_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) context.state.increment_stats(target_channel_id, messages=1, files=len(files) if files else 0)
# Periodic log
if stats["messages"] % 50 == 0:
logger.info(f"Progress: Migrated {stats['messages']} messages in this channel.")
# 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:

View file

@ -11,6 +11,7 @@ async def sync_roles_state(context: MigrationContext):
""" """
Scans Stoat for roles matching Discord names and updates state file mappings. Scans Stoat for roles matching Discord names and updates state file mappings.
""" """
logger.info("Synchronizing role mappings with Stoat...")
discord_roles = await context.discord_reader.get_roles() discord_roles = await context.discord_reader.get_roles()
server = await context.stoat_writer._get_server() server = await context.stoat_writer._get_server()
stoat_roles = list(server.roles.values()) stoat_roles = list(server.roles.values())
@ -41,6 +42,7 @@ async def sync_roles_state(context: MigrationContext):
async def sync_permissions(context: MigrationContext, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None) -> dict: async def sync_permissions(context: MigrationContext, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None) -> dict:
"""Syncs category and channel role overrides/permissions.""" """Syncs category and channel role overrides/permissions."""
logger.info("Starting permissions synchronization for Stoat...")
discord_categories = await context.discord_reader.get_categories() discord_categories = await context.discord_reader.get_categories()
discord_channels = await context.discord_reader.get_channels() discord_channels = await context.discord_reader.get_channels()
@ -58,7 +60,10 @@ async def sync_permissions(context: MigrationContext, progress_callback: Callabl
current_idx = 0 current_idx = 0
if total == 0: if total == 0:
logger.info("No mapped categories or channels found for Stoat permission sync.")
return synced_info return synced_info
logger.info(f"Syncing permissions for {len(mapped_categories)} categories and {len(mapped_channels)} channels to Stoat.")
cat_map = {cat.id: cat for cat in discord_categories} cat_map = {cat.id: cat for cat in discord_categories}
cat_names = {cat.id: cat.name for cat in discord_categories} cat_names = {cat.id: cat.name for cat in discord_categories}
@ -160,6 +165,7 @@ async def sync_permissions(context: MigrationContext, progress_callback: Callabl
await asyncio.sleep(context.config.migration.rate_limit_delay_seconds) await asyncio.sleep(context.config.migration.rate_limit_delay_seconds)
logger.info(f"Stoat permissions sync complete: {len(synced_info['categories_synced'])} categories, {len(synced_info['channels_synced'])} channels.")
return synced_info return synced_info
@ -176,20 +182,17 @@ async def migrate_roles(context: MigrationContext, progress_callback: Callable[[
except Exception as e: except Exception as e:
logger.error(f"Failed to sync default permissions: {e}") logger.error(f"Failed to sync default permissions: {e}")
roles = await context.discord_reader.get_roles()
if not force:
roles = [r for r in roles if not context.state.get_target_role_id(str(r.id))]
total = len(roles) total = len(roles)
cloned_role_names = [] cloned_role_names = []
if total == 0: logger.info(f"Migrating {total} roles to Stoat...")
return cloned_role_names
for idx, role in enumerate(roles): for idx, role in enumerate(roles):
if not context.is_running: break if not context.is_running:
logger.warning("Stoat role migration interrupted.")
break
logger.debug(f"Creating role in Stoat: {role.name}")
stoat_id = await context.stoat_writer.create_role( stoat_id = await context.stoat_writer.create_role(
name=role.name, name=role.name,
color=role.color.value, color=role.color.value,

View file

@ -14,10 +14,12 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal
if "name" in components: if "name" in components:
try: try:
name = metadata.get("name") name = metadata.get("name")
logger.info(f"Syncing server name to Stoat: {name}")
await context.writer.update_guild_metadata(name=name) await context.writer.update_guild_metadata(name=name)
cloned_data["name"] = name cloned_data["name"] = name
await progress_callback("Server Name", "DONE") await progress_callback("Server Name", "DONE")
except Exception: except Exception as e:
logger.error(f"Failed to sync server name to Stoat: {e}")
await progress_callback("Server Name", "ERROR") await progress_callback("Server Name", "ERROR")
# 2. Sync Icon # 2. Sync Icon
@ -28,12 +30,15 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal
icon_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.icon) icon_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.icon)
if icon_bytes: if icon_bytes:
logger.info(f"Uploading server icon to Stoat ({len(icon_bytes)} bytes)...")
await context.writer.update_guild_metadata(icon=icon_bytes) await context.writer.update_guild_metadata(icon=icon_bytes)
cloned_data["icon"] = icon_bytes cloned_data["icon"] = icon_bytes
await progress_callback("Server Icon", "DONE") await progress_callback("Server Icon", "DONE")
else: else:
logger.debug("No server icon found to sync to Stoat.")
await progress_callback("Server Icon", "SKIP") await progress_callback("Server Icon", "SKIP")
except Exception: except Exception as e:
logger.error(f"Failed to sync server icon to Stoat: {e}")
await progress_callback("Server Icon", "ERROR") await progress_callback("Server Icon", "ERROR")
# 3. Sync Banner # 3. Sync Banner
@ -44,12 +49,15 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal
banner_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.banner) banner_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.banner)
if banner_bytes: if banner_bytes:
logger.info(f"Uploading server banner to Stoat ({len(banner_bytes)} bytes)...")
await context.writer.update_guild_metadata(banner=banner_bytes) await context.writer.update_guild_metadata(banner=banner_bytes)
cloned_data["banner"] = banner_bytes cloned_data["banner"] = banner_bytes
await progress_callback("Server Banner", "DONE") await progress_callback("Server Banner", "DONE")
else: else:
logger.debug("No server banner found to sync to Stoat.")
await progress_callback("Server Banner", "SKIP") await progress_callback("Server Banner", "SKIP")
except Exception: except Exception as e:
logger.error(f"Failed to sync server banner to Stoat: {e}")
await progress_callback("Server Banner", "ERROR") await progress_callback("Server Banner", "ERROR")
return cloned_data return cloned_data

View file

@ -63,12 +63,12 @@ class BackupPane(Container):
# ── validation ──────────────────────────────────────────────────────── # ── validation ────────────────────────────────────────────────────────
@work(exclusive=True, thread=True) @work(exclusive=True)
async def _validate(self) -> None: async def _validate(self) -> None:
fillers = ["DISCORD_BOT_TOKEN", "000000000000000000", "DISCORD_SERVER_ID", "", None] fillers = ["DISCORD_BOT_TOKEN", "000000000000000000", "DISCORD_SERVER_ID", "", None]
d_token = self.config.discord_bot_token d_token = self.config.discord_bot_token
if d_token in fillers or self.config.discord_server_id in fillers: if d_token in fillers or self.config.discord_server_id in fillers:
self.app.call_from_thread(self._update_ui, "[red]NOT CONFIGURED[/red]", "", "", False) self._update_ui("[red]NOT CONFIGURED[/red]", "", "", False)
return return
try: try:
res = await self.engine.discord_reader.validate() res = await self.engine.discord_reader.validate()
@ -83,9 +83,9 @@ class BackupPane(Container):
if info: if info:
backup_text = f"Last backup: [cyan]{info}[/cyan]" backup_text = f"Last backup: [cyan]{info}[/cyan]"
self.app.call_from_thread(self._update_ui, s_text, b_text, backup_text, valid) self._update_ui(s_text, b_text, backup_text, valid)
except Exception as e: except Exception as e:
self.app.call_from_thread(self._update_ui, f"[red]Error: {e}[/red]", "", "", False) self._update_ui(f"[red]Error: {e}[/red]", "", "", False)
def _get_backup_info(self) -> str | None: def _get_backup_info(self) -> str | None:
profile_file = Path(f"Reaper-{self.cfg_name}") / "server_profile.json" profile_file = Path(f"Reaper-{self.cfg_name}") / "server_profile.json"
@ -121,49 +121,49 @@ class BackupPane(Container):
# ── workers ─────────────────────────────────────────────────────────── # ── workers ───────────────────────────────────────────────────────────
@work(exclusive=True, thread=True) @work(exclusive=True)
async def run_backup_profile(self) -> None: async def run_backup_profile(self) -> None:
modal = ProgressScreen() modal = ProgressScreen()
self.app.call_from_thread(self.app.push_screen, modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
self.app.call_from_thread(modal.phase_progress) modal.phase_progress()
try: try:
self.app.call_from_thread(modal.set_status, "Starting readers...") modal.set_status("Starting readers...")
await self.engine.discord_reader.start() await self.engine.discord_reader.start()
await self.exporter.setup() await self.exporter.setup()
self.app.call_from_thread(modal.write, "[yellow]Backing up server profile & skeleton...[/yellow]") modal.write("[yellow]Backing up server profile & skeleton...[/yellow]")
await self.exporter.export_metadata() await self.exporter.export_metadata()
await self.exporter.download_server_assets() await self.exporter.download_server_assets()
self.app.call_from_thread(modal.write, "Exporting structure...") modal.write("Exporting structure...")
_, cat_count, chan_count = await self.exporter.export_channels_structure() _, cat_count, chan_count = await self.exporter.export_channels_structure()
self.app.call_from_thread(modal.write, "Exporting assets...") modal.write("Exporting assets...")
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]") modal.write(f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]")
self.app.call_from_thread(modal.write, f"- {e_count} emojis, {s_count} stickers.") modal.write(f"- {e_count} emojis, {s_count} stickers.")
self.app.call_from_thread(modal.phase_report, "Profile Backup") 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]") modal.write(f"[bold red]Backup failed: {e}[/bold red]")
self.app.call_from_thread(modal.phase_report, "Profile Backup", "error") 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]") modal.write(f"[bold red]Error: {e}[/bold red]")
self.app.call_from_thread(modal.phase_report, "Profile Backup", "error") modal.phase_report("Profile Backup", "error")
finally: finally:
await self.engine.close_connections() await self.engine.close_connections()
@work(exclusive=True, thread=True) @work(exclusive=True)
async def run_backup_messages(self) -> None: async def run_backup_messages(self) -> None:
modal_prog = ProgressScreen() modal_prog = ProgressScreen()
self.app.call_from_thread(self.app.push_screen, modal_prog) self.app.push_screen(modal_prog)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
try: try:
self.app.call_from_thread(modal_prog.set_status, "Fetching channels...") modal_prog.set_status("Fetching channels...")
await self.engine.discord_reader.start() await self.engine.discord_reader.start()
await self.exporter.setup() await self.exporter.setup()
@ -178,8 +178,8 @@ class BackupPane(Container):
] ]
if not eligible_channels: if not eligible_channels:
self.app.call_from_thread(modal_prog.write, "[yellow]No text/news channels found to backup.[/yellow]") modal_prog.write("[yellow]No text/news channels found to backup.[/yellow]")
self.app.call_from_thread(modal_prog.allow_close) modal_prog.allow_close()
return return
any_found = False any_found = False
@ -189,7 +189,7 @@ class BackupPane(Container):
any_found = True any_found = True
backed_up_ids.add(chan.id) backed_up_ids.add(chan.id)
self.app.call_from_thread(self.app.pop_screen) self.app.pop_screen()
while True: while True:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@ -199,8 +199,7 @@ class BackupPane(Container):
if not future.done(): if not future.done():
future.set_result(reply) future.set_result(reply)
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,
) )
@ -214,69 +213,69 @@ class BackupPane(Container):
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 # Phase 2: Confirmation
self.app.call_from_thread(self.app.push_screen, modal_prog) self.app.push_screen(modal_prog)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
msg = "Sync existing backups" if not force_overwrite else "Overwriting existing backups" 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...") 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 ''}") 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() choice = await modal_prog.phase_wait_confirm()
if choice == "btn_back": if choice == "btn_back":
self.app.call_from_thread(modal_prog.dismiss) modal_prog.dismiss()
continue continue
elif choice == "btn_main_menu": elif choice == "btn_main_menu":
self.app.call_from_thread(modal_prog.dismiss) modal_prog.dismiss()
self.app.call_from_thread(self.app.switch_screen, "config_selection") self.app.switch_screen("config_selection")
return return
# Proceed to progress # Proceed to progress
break break
self.app.call_from_thread(modal_prog.phase_progress) 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...") modal_prog.set_status("Backing up messages...")
self.app.call_from_thread(modal_prog.write, f"[yellow]Starting backup for {total_chans} channels...[/yellow]") modal_prog.write(f"[yellow]Starting backup for {total_chans} channels...[/yellow]")
for chan in selected_channels: for chan in selected_channels:
backup_exists = (self.exporter.export_path / "message_backup" / f"{chan.id}.json").exists() backup_exists = (self.exporter.export_path / "message_backup" / f"{chan.id}.json").exists()
is_sync = backup_exists and not force_overwrite is_sync = backup_exists and not force_overwrite
label = "Syncing Backup" if is_sync else "Backing up" label = "Syncing Backup" if is_sync else "Backing up"
self.app.call_from_thread(modal_prog.write, f"[cyan]{label}: {chan.name}[/cyan]") modal_prog.write(f"[cyan]{label}: {chan.name}[/cyan]")
async def update_msg_count(name, count): async def update_msg_count(name, count):
self.app.call_from_thread(modal_prog.set_status, f"{name}: {count} messages") modal_prog.set_status(f"{name}: {count} messages")
await self.exporter.export_channel_messages(chan.id, progress_callback=update_msg_count, force=force_overwrite) await self.exporter.export_channel_messages(chan.id, progress_callback=update_msg_count, force=force_overwrite)
await self.exporter.export_threads(chan.id, progress_callback=update_msg_count, force=force_overwrite) await self.exporter.export_threads(chan.id, progress_callback=update_msg_count, force=force_overwrite)
self.app.call_from_thread(modal_prog.write, f"[green]Completed: {chan.name}[/green]") modal_prog.write(f"[green]Completed: {chan.name}[/green]")
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]") modal_prog.write("[bold green]Message backup complete![/bold green]")
self.app.call_from_thread(modal_prog.phase_report, "Message Backup") 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]") modal_prog.write(f"[bold red]Message backup failed: {e}[/bold red]")
self.app.call_from_thread(modal_prog.phase_report, "Message Backup", "error") modal_prog.phase_report("Message Backup", "error")
finally: finally:
await self.engine.close_connections() await self.engine.close_connections()
@work(exclusive=True, thread=True) @work(exclusive=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.push_screen(modal_prog)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
self.app.call_from_thread(modal_prog.phase_progress) modal_prog.phase_progress()
try: try:
self.app.call_from_thread(modal_prog.set_status, "Starting sync...") modal_prog.set_status("Starting sync...")
await self.engine.discord_reader.start() await self.engine.discord_reader.start()
await self.exporter.setup() await self.exporter.setup()
self.app.call_from_thread(modal_prog.write, "Updating structure...") modal_prog.write("Updating structure...")
await self.exporter.export_metadata() await self.exporter.export_metadata()
await self.exporter.download_server_assets() await self.exporter.download_server_assets()
await self.exporter.export_channels_structure() await self.exporter.export_channels_structure()
@ -294,25 +293,25 @@ class BackupPane(Container):
] ]
if not selected_channels: if not selected_channels:
self.app.call_from_thread(modal_prog.write, "[yellow]No existing backups found to sync.[/yellow]") modal_prog.write("[yellow]No existing backups found to sync.[/yellow]")
else: else:
self.app.call_from_thread(modal_prog.write, f"[yellow]Syncing {len(selected_channels)} channels...[/yellow]") modal_prog.write(f"[yellow]Syncing {len(selected_channels)} channels...[/yellow]")
for chan in selected_channels: for chan in selected_channels:
self.app.call_from_thread(modal_prog.write, f"[cyan]Syncing: {chan.name}[/cyan]") modal_prog.write(f"[cyan]Syncing: {chan.name}[/cyan]")
async def update_msg_count(name, count): async def update_msg_count(name, count):
self.app.call_from_thread(modal_prog.set_status, f"{name}: {count} messages") modal_prog.set_status(f"{name}: {count} messages")
await self.exporter.export_channel_messages(chan.id, progress_callback=update_msg_count, force=False) await self.exporter.export_channel_messages(chan.id, progress_callback=update_msg_count, force=False)
await self.exporter.export_threads(chan.id, progress_callback=update_msg_count, force=False) await self.exporter.export_threads(chan.id, progress_callback=update_msg_count, force=False)
self.app.call_from_thread(modal_prog.write, f"[green]Synced: {chan.name}[/green]") modal_prog.write(f"[green]Synced: {chan.name}[/green]")
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]") modal_prog.write("[bold green]Sync operation complete![/bold green]")
self.app.call_from_thread(modal_prog.phase_report, "Backup Sync") 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]") modal_prog.write(f"[bold red]Sync failed: {e}[/bold red]")
self.app.call_from_thread(modal_prog.phase_report, "Backup Sync", "error") modal_prog.phase_report("Backup Sync", "error")
finally: finally:
await self.engine.close_connections() await self.engine.close_connections()

View file

@ -365,17 +365,20 @@ class OptionSelectModal(ModalScreen[list[str]]):
OptionSelectModal { align: center middle; } OptionSelectModal { align: center middle; }
#opt_dialog { #opt_dialog {
width: 60; width: 60;
height: auto; height: 80%;
border: solid green; border: solid green;
background: $surface; background: $surface;
padding: 1 2; padding: 1 2;
} }
#opt_title { text-style: bold; margin-bottom: 1; text-align: center; width: 100%; } #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_scroll { margin-bottom: 1; height: 1fr; overflow-y: auto; }
#opt_buttons { height: auto; } #opt_buttons { height: auto; }
#opt_buttons Button { width: 1fr; margin: 0 1; } #opt_buttons Button { width: 1fr; margin: 0 1; }
#opt_batch_buttons { height: auto; margin-bottom: 1; } #opt_batch_buttons { height: auto; margin-bottom: 1; }
#opt_batch_buttons Button { width: 1fr; margin: 0 1; } #opt_batch_buttons Button { width: 1fr; margin: 0 1; }
RadioButton { background: transparent; }
RadioButton:focus { background: $accent 20%; }
""" """
def __init__(self, title: str, options: list[tuple[str, str]]): def __init__(self, title: str, options: list[tuple[str, str]]):
@ -391,7 +394,7 @@ class OptionSelectModal(ModalScreen[list[str]]):
yield Button("Select All", id="btn_opt_all") yield Button("Select All", id="btn_opt_all")
yield Button("Deselect All", id="btn_opt_none") yield Button("Deselect All", id="btn_opt_none")
with VerticalScroll(id="opt_scroll"): with Vertical(id="opt_scroll"):
for opt_id, label in self._options: for opt_id, label in self._options:
yield RadioButton(label, id=f"opt_{opt_id}") yield RadioButton(label, id=f"opt_{opt_id}")

View file

@ -319,6 +319,7 @@ class ShuttlePane(Container):
choice = await modal.phase_wait_confirm() choice = await modal.phase_wait_confirm()
if choice == "btn_back": if choice == "btn_back":
modal.dismiss() modal.dismiss()
self._open_clone_menu()
return return
elif choice == "btn_main_menu": elif choice == "btn_main_menu":
modal.dismiss() modal.dismiss()
@ -358,6 +359,7 @@ class ShuttlePane(Container):
choice = await modal.phase_wait_confirm() choice = await modal.phase_wait_confirm()
if choice == "btn_back": if choice == "btn_back":
modal.dismiss() modal.dismiss()
self._open_sync_menu()
return return
elif choice == "btn_main_menu": elif choice == "btn_main_menu":
modal.dismiss() modal.dismiss()
@ -575,12 +577,14 @@ class ShuttlePane(Container):
async def update_scan(current_stats): async def update_scan(current_stats):
modal.set_status(f"[cyan]Scanned {current_stats['messages']} items...") modal.set_status(f"[cyan]Scanned {current_stats['messages']} items...")
logger.info(f"Analyzing message history for Discord #{source_channel.name}...")
stats_analysis = 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=int(last_migrated) if last_migrated else None, after_message_id=int(last_migrated) if last_migrated else None,
progress_callback=update_scan, progress_callback=update_scan,
) )
logger.info(f"Analysis complete: {stats_analysis['messages']} new messages found.")
self.engine.is_running = False self.engine.is_running = False
# Set initial total stats for the confirmation block # Set initial total stats for the confirmation block
@ -599,6 +603,7 @@ class ShuttlePane(Container):
# Phase 2: Confirmation # Phase 2: Confirmation
choice = await modal.phase_wait_confirm(show_continue=has_previous) choice = await modal.phase_wait_confirm(show_continue=has_previous)
logger.info(f"User confirmation choice: {choice}")
if choice == "btn_back": if choice == "btn_back":
modal.dismiss() modal.dismiss()
continue # Return to channel picker continue # Return to channel picker
@ -611,11 +616,15 @@ class ShuttlePane(Container):
after_id = None after_id = None
if choice == "btn_continue" and last_migrated: if choice == "btn_continue" and last_migrated:
logger.info("Proceeding with 'Continue Migration' (incremental sink).")
after_id = int(last_migrated) after_id = int(last_migrated)
elif choice == "btn_start_id": elif choice == "btn_start_id":
# Fallback to full for now since we don't have an ID input dialog yet # 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]") modal.write("[yellow]Custom Message ID start not fully implemented, starting from beginning.[/yellow]")
logger.info("Proceeding with 'Start from ID' (fallback to begin).")
after_id = None after_id = None
else:
logger.info("Proceeding with 'Start from First' (clean sink).")
# If we are here, we are proceeding with migration # If we are here, we are proceeding with migration
break break
@ -629,6 +638,7 @@ class ShuttlePane(Container):
total_threads = stats_analysis["threads"] total_threads = stats_analysis["threads"]
total_attachments = stats_analysis["attachments"] total_attachments = stats_analysis["attachments"]
logger.info(f"Execution started for #{source_channel.name} -> {platform_name} @ {target_channel.get('name')}")
self.engine.is_running = True self.engine.is_running = True
async def update_msg(current_stats): async def update_msg(current_stats):
@ -684,164 +694,116 @@ class ShuttlePane(Container):
def _open_danger_menu(self): def _open_danger_menu(self):
options = [ options = [
("dz_del_channels", "Delete ALL Channels & Categories", "error"), ("dz_del_channels", "Delete ALL Channels & Categories"),
("dz_reset_perms", "Reset ALL Channel Permissions", "error"), ("dz_reset_perms", "Reset ALL Channel Permissions"),
("dz_del_roles", "Delete ALL Roles", "error"), ("dz_del_roles", "Delete ALL Roles"),
("dz_del_assets", "Delete ALL Emojis & Stickers", "error"), ("dz_del_assets", "Delete ALL Emojis & Stickers"),
] ]
def on_result(choice): def on_result(selections: list[str] | None):
if choice == "dz_del_channels": if selections:
self._confirm_danger("Delete ALL channels and categories? This is IRREVERSIBLE.", self.run_dz_delete_channels) self.run_batch_danger(selections)
elif choice == "dz_reset_perms": self.app.push_screen(OptionSelectModal("⚠ DANGER ZONE ⚠", options), on_result)
self._confirm_danger("Reset ALL channel permissions? This is IRREVERSIBLE.", self.run_dz_reset_perms)
elif choice == "dz_del_roles":
self._confirm_danger("Delete ALL roles? This is IRREVERSIBLE.", self.run_dz_delete_roles)
elif choice == "dz_del_assets":
self._confirm_danger("Delete ALL emojis and stickers? This is IRREVERSIBLE.", self.run_dz_delete_assets)
self.app.push_screen(SubMenuModal("⚠ DANGER ZONE ⚠", options), on_result)
def _confirm_danger(self, message: str, callback): @work(exclusive=True)
async def do_confirm(): async def run_batch_danger(self, selections: list[str]) -> None:
modal = ProgressScreen() modal = ProgressScreen()
self.app.push_screen(modal) self.app.push_screen(modal)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
try:
modal.set_status(f"[bold red]DANGER:[/bold red] {message}") modal.set_status(f"Awaiting Confirmation for {len(selections)} Destructive Operations...")
modal.write("[bold red]WARNING: THIS WILL DELETE DATA PERMANENTLY! MUST PROCEED TO CONTINUE.[/bold red]") modal.write("[bold red]WARNING: THIS WILL DELETE DATA PERMANENTLY! MUST PROCEED TO CONTINUE.[/bold red]")
choice = await modal.phase_wait_confirm() choice = await modal.phase_wait_confirm()
modal.dismiss() if choice == "btn_back":
if choice == "btn_start_first": modal.dismiss()
callback() self._open_danger_menu()
return
elif choice == "btn_main_menu": elif choice == "btn_main_menu":
modal.dismiss()
self.app.switch_screen("config_selection") self.app.switch_screen("config_selection")
return
asyncio.create_task(do_confirm()) modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
modal.phase_progress()
self.engine.is_running = True
await self.engine.start_target_only()
@work(exclusive=True) for sel in selections:
async def run_dz_delete_channels(self) -> None: if not self.engine.is_running: break
if sel == "dz_del_channels":
await self._logic_dz_delete_channels(modal)
elif sel == "dz_reset_perms":
await self._logic_dz_reset_perms(modal)
elif sel == "dz_del_roles":
await self._logic_dz_delete_roles(modal)
elif sel == "dz_del_assets":
await self._logic_dz_delete_assets(modal)
modal.phase_report("Danger Zone Operations Complete")
modal.write("[bold green]All selected destructive operations finished.[/bold green]")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
modal.phase_report("Danger Zone Batch", "error")
finally:
self.engine.is_running = False
await self.engine.close_target_only()
async def _logic_dz_delete_channels(self, modal: ProgressScreen) -> None:
if self.target_platform == "fluxer": if self.target_platform == "fluxer":
from src.fluxer.danger_zone import danger_delete_all_channels from src.fluxer.danger_zone import danger_delete_all_channels
else: else:
from src.stoat.danger_zone import danger_delete_all_channels from src.stoat.danger_zone import danger_delete_all_channels
modal = ProgressScreen() modal.set_status("[red]Deleting channels...")
self.app.push_screen(modal) async def on_deleted(name, current, total):
await asyncio.sleep(0.1) modal.set_status(f"[red]Deleting: {name}")
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) modal.set_progress(current, total)
modal.phase_progress()
try: count = await danger_delete_all_channels(self.engine, progress_callback=on_deleted)
modal.set_status("[red]Deleting channels...") modal.write(f"[bold green]Success! {count} channels/categories deleted.[/bold green]")
await self.engine.start_target_only() await log_audit_event(self.engine, "Danger Zone: Channels Wiped", f"Deleted {count} channels and categories.")
async def on_deleted(name, current, total): async def _logic_dz_reset_perms(self, modal: ProgressScreen) -> None:
modal.set_status(f"[red]Deleting: {name}")
modal.set_progress(current, total)
count = await danger_delete_all_channels(self.engine, progress_callback=on_deleted)
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()
@work(exclusive=True)
async def run_dz_reset_perms(self) -> None:
if self.target_platform == "fluxer": if self.target_platform == "fluxer":
from src.fluxer.danger_zone import danger_reset_channel_permissions from src.fluxer.danger_zone import danger_reset_channel_permissions
else: else:
from src.stoat.danger_zone import danger_reset_channel_permissions from src.stoat.danger_zone import danger_reset_channel_permissions
modal = ProgressScreen() modal.set_status("[red]Resetting permissions...")
self.app.push_screen(modal) async def on_reset(name, current, total):
await asyncio.sleep(0.1) modal.set_status(f"[red]Resetting: {name}")
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) modal.set_progress(current, total)
modal.phase_progress()
try: count = await danger_reset_channel_permissions(self.engine, progress_callback=on_reset)
modal.set_status("[red]Resetting permissions...") modal.write(f"[bold green]Success! Permissions reset on {count} items.[/bold green]")
await self.engine.start_target_only() await log_audit_event(self.engine, "Danger Zone: Permissions Wiped", f"Reset permissions on {count} items.")
async def on_reset(name, current, total): async def _logic_dz_delete_roles(self, modal: ProgressScreen) -> None:
modal.set_status(f"[red]Resetting: {name}")
modal.set_progress(current, total)
count = await danger_reset_channel_permissions(self.engine, progress_callback=on_reset)
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()
@work(exclusive=True)
async def run_dz_delete_roles(self) -> None:
if self.target_platform == "fluxer": if self.target_platform == "fluxer":
from src.fluxer.danger_zone import danger_delete_all_roles from src.fluxer.danger_zone import danger_delete_all_roles
else: else:
from src.stoat.danger_zone import danger_delete_all_roles from src.stoat.danger_zone import danger_delete_all_roles
modal = ProgressScreen() modal.set_status("[red]Deleting roles...")
self.app.push_screen(modal) async def on_deleted(name, current, total):
await asyncio.sleep(0.1) modal.set_status(f"[red]Deleting role: {name}")
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) modal.set_progress(current, total)
modal.phase_progress()
try: count = await danger_delete_all_roles(self.engine, progress_callback=on_deleted)
modal.set_status("[red]Deleting roles...") modal.write(f"[bold green]Success! {count} roles deleted.[/bold green]")
await self.engine.start_target_only() await log_audit_event(self.engine, "Danger Zone: Roles Wiped", f"Deleted {count} roles.")
async def on_deleted(name, current, total): async def _logic_dz_delete_assets(self, modal: ProgressScreen) -> None:
modal.set_status(f"[red]Deleting role: {name}")
modal.set_progress(current, total)
count = await danger_delete_all_roles(self.engine, progress_callback=on_deleted)
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()
@work(exclusive=True)
async def run_dz_delete_assets(self) -> None:
if self.target_platform == "fluxer": if self.target_platform == "fluxer":
from src.fluxer.danger_zone import danger_delete_all_emojis_and_stickers from src.fluxer.danger_zone import danger_delete_all_emojis_and_stickers
else: else:
from src.stoat.danger_zone import danger_delete_all_emojis_and_stickers from src.stoat.danger_zone import danger_delete_all_emojis_and_stickers
modal = ProgressScreen() modal.set_status("[red]Deleting assets...")
self.app.push_screen(modal) async def on_deleted(name, asset_type, current, total):
await asyncio.sleep(0.1) modal.set_status(f"[red]Deleting {asset_type}: {name}")
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) modal.set_progress(current, total)
modal.phase_progress()
try: counts = await danger_delete_all_emojis_and_stickers(self.engine, progress_callback=on_deleted)
modal.set_status("[red]Deleting assets...") modal.write(f"[bold green]Success! {counts.get('emojis', 0)} emojis, {counts.get('stickers', 0)} stickers deleted.[/bold green]")
await self.engine.start_target_only() await log_audit_event(self.engine, "Danger Zone: Assets Wiped", f"Deleted {counts.get('emojis', 0)} emojis and {counts.get('stickers', 0)} stickers.")
async def on_deleted(name, asset_type, current, total):
modal.set_status(f"[red]Deleting {asset_type}: {name}")
modal.set_progress(current, total)
counts = await danger_delete_all_emojis_and_stickers(self.engine, progress_callback=on_deleted)
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()