diff --git a/src/core/base.py b/src/core/base.py index a6e9b3e..7ef0dc4 100644 --- a/src/core/base.py +++ b/src/core/base.py @@ -31,11 +31,15 @@ class MigrationContext: messages_file: str | Path = "" if server_id != "unconfigured": + logger.info(f"Probing for existing migration folder for Server ID: {server_id}") for d in Path(".").iterdir(): 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" messages_file = d / "message-tracker.json" 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( state_file=state_file, diff --git a/src/core/discord_reader.py b/src/core/discord_reader.py index 4471e55..fa90190 100644 --- a/src/core/discord_reader.py +++ b/src/core/discord_reader.py @@ -179,6 +179,7 @@ class DiscordReader: channel = await self.get_channel(channel_id) if isinstance(channel, discord.TextChannel) or isinstance(channel, discord.Thread): 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 async for message in channel.history(limit=limit, oldest_first=True, after=after): yield message diff --git a/src/core/state.py b/src/core/state.py index a7d4bd7..8652514 100644 --- a/src/core/state.py +++ b/src/core/state.py @@ -45,27 +45,33 @@ class MigrationState: # 2. Load separate messages file if self.messages_file and self.messages_file.exists(): - with open(self.messages_file, "r", encoding="utf-8") as f: - msg_data = json.load(f) - - # Check for new schema (nested under 'channels') - if "channels" in msg_data: - self.channel_messages = msg_data.get("channels", {}) - else: - # Legacy schema detection & conversion to a default 'unknown_channel' just in case, - # though new migrations shouldn't hit this based on previous removals. - legacy_map = msg_data.get("messages", {}) - legacy_ids = msg_data.get("last_message_ids", {}) - legacy_times = msg_data.get("last_message_timestamps", {}) + logger.info(f"Loading messages from {self.messages_file.name}") + try: + with open(self.messages_file, "r", encoding="utf-8") as f: + msg_data = json.load(f) - 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 "" + # Check for new schema (nested under 'channels') + if "channels" in msg_data: + self.channel_messages = msg_data.get("channels", {}) + logger.debug(f"Loaded {len(self.channel_messages)} tracked channels.") + 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).""" if not self.state_file: return + + logger.debug(f"Saving state to {self.state_file.name}") data = { "channels": self.channel_map, "categories": self.category_map, @@ -81,18 +89,26 @@ class MigrationState: "stickers": self.sticker_map, "audit_log_channel": self.audit_log_channel } - with open(self.state_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) + try: + 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): """Saves only the message tracking data.""" if not self.messages_file: return + + logger.debug(f"Saving messages to {self.messages_file.name}") data = { "channels": self.channel_messages } - with open(self.messages_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) + try: + 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 --- @@ -260,11 +276,13 @@ class MigrationState: def set_folder(self, server_id: str, clean_name: str, base_dir: Path | str = ""): base = Path(base_dir) if base_dir else Path(".") 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 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) 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: self.state_file.parent.rename(new_folder) except Exception as e: @@ -275,4 +293,5 @@ class MigrationState: self.state_file = new_folder / "state-migration.json" self.messages_file = new_folder / "message-tracker.json" + logger.debug("Re-loading data from new folder location.") self.load() diff --git a/src/fluxer/emoji_stickers.py b/src/fluxer/emoji_stickers.py index 036d6c0..261bc9f 100644 --- a/src/fluxer/emoji_stickers.py +++ b/src/fluxer/emoji_stickers.py @@ -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. """ + logger.info("Synchronizing asset mappings (emojis/stickers) with Fluxer...") discord_emojis = await context.discord_reader.get_emojis() discord_stickers = await context.discord_reader.get_stickers() @@ -80,14 +81,16 @@ async def migrate_emojis(context: MigrationContext, progress_callback: Callable[ total = len(objs) cloned_assets: dict[str, dict[str, str]] = {"Emoji": {}, "Sticker": {}} - if total == 0: - return cloned_assets + logger.info(f"Migrating {total} assets to Fluxer (Types: {', '.join(types_to_include)})...") 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: if obj_type == "Emoji": + logger.debug(f"Migrating emoji: {obj.name}") img_data = await context.discord_reader.download_emoji(obj) fluxer_id = await context.fluxer_writer.create_emoji( 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) cloned_assets["Emoji"][obj.name] = fluxer_id else: + logger.debug(f"Migrating sticker: {obj.name}") img_data = await context.discord_reader.download_sticker(obj) fluxer_id = await context.fluxer_writer.create_sticker( name=obj.name, diff --git a/src/fluxer/migrate_message.py b/src/fluxer/migrate_message.py index 6d5672b..2b4274f 100644 --- a/src/fluxer/migrate_message.py +++ b/src/fluxer/migrate_message.py @@ -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]: """Migrate messages for a specific channel and returns detailed statistics.""" - stats = { - "messages": 0, - "attachments": 0, - "threads": 0, - "first_message_url": None, - "last_message_url": None - } + stats = {"messages": 0, "threads": 0, "attachments": 0} + + logger.info(f"Starting message migration: Discord #{source_channel_id} -> Fluxer #{target_channel_id}") + if after_message_id: + logger.info(f"Resuming migration from after message ID: {after_message_id}") + try: async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id): if not context.is_running: + logger.warning("Migration interrupted by user (is_running=False)") break @@ -190,6 +190,10 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta reply_to_fluxer_id = None 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)) + 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( channel_id=target_channel_id, @@ -210,6 +214,10 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta stats["messages"] += 1 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) if hasattr(msg, 'thread') and msg.thread: thread = msg.thread diff --git a/src/fluxer/roles_permissions.py b/src/fluxer/roles_permissions.py index 4d06d39..45d7709 100644 --- a/src/fluxer/roles_permissions.py +++ b/src/fluxer/roles_permissions.py @@ -10,6 +10,7 @@ async def sync_roles_state(context: MigrationContext): """ 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() 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: """Syncs category and channel role overrides/permissions.""" + logger.info("Starting permissions synchronization...") categories = await context.discord_reader.get_categories() channels = await context.discord_reader.get_channels() @@ -56,7 +58,10 @@ async def sync_permissions(context: MigrationContext, progress_callback: Callabl current_idx = 0 if total == 0: + logger.info("No mapped categories or channels found for permission sync.") return synced_info + + logger.info(f"Syncing permissions for {len(categories)} categories and {len(channels)} channels.") async def _sync_overwrites(discord_item, fluxer_id): """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 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 @@ -133,12 +139,14 @@ async def migrate_roles(context: MigrationContext, progress_callback: Callable[[ total = len(roles) cloned_role_names = [] - if total == 0: - return cloned_role_names + logger.info(f"Migrating {total} roles to Fluxer...") 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( name=role.name, color=role.color.value, diff --git a/src/fluxer/server_metadata.py b/src/fluxer/server_metadata.py index 307aac4..dafcbbb 100644 --- a/src/fluxer/server_metadata.py +++ b/src/fluxer/server_metadata.py @@ -14,10 +14,12 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal if "name" in components: try: name = metadata.get("name") + logger.info(f"Syncing server name: {name}") await context.writer.update_guild_metadata(name=name) cloned_data["name"] = name 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") # 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) if icon_bytes: + logger.info(f"Uploading server icon ({len(icon_bytes)} bytes)...") await context.writer.update_guild_metadata(icon=icon_bytes) cloned_data["icon"] = icon_bytes await progress_callback("Server Icon", "DONE") else: + logger.info("No server icon found to sync.") 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") # 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) if banner_bytes: + logger.info(f"Uploading server banner ({len(banner_bytes)} bytes)...") await context.writer.update_guild_metadata(banner=banner_bytes) cloned_data["banner"] = banner_bytes await progress_callback("Server Banner", "DONE") else: + logger.info("No server banner found to sync.") 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") return cloned_data diff --git a/src/stoat/emoji_stickers.py b/src/stoat/emoji_stickers.py index c47e24c..ab944a1 100644 --- a/src/stoat/emoji_stickers.py +++ b/src/stoat/emoji_stickers.py @@ -10,6 +10,7 @@ async def sync_assets_state(context: MigrationContext): """ 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() # 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: return cloned_assets + logger.info(f"Migrating {total} assets to Stoat (Types: {', '.join(types_to_include)})...") + 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: if obj_type == "Emoji": + logger.debug(f"Migrating emoji to Stoat: {obj.name}") img_data = await context.discord_reader.download_emoji(obj) stoat_id = await context.writer.create_emoji( name=obj.name, diff --git a/src/stoat/migrate_message.py b/src/stoat/migrate_message.py index 53ff12d..f2e336a 100644 --- a/src/stoat/migrate_message.py +++ b/src/stoat/migrate_message.py @@ -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]: """Migrate messages for a specific channel using Stoat masquerade for author impersonation.""" - stats = { - "messages": 0, - "attachments": 0, - "threads": 0, - "first_message_url": None, - "last_message_url": None - } + stats = {"messages": 0, "threads": 0, "attachments": 0} + + logger.info(f"Starting message migration: Discord #{source_channel_id} -> Stoat #{target_channel_id}") + if after_message_id: + logger.info(f"Resuming migration from after message ID: {after_message_id}") + try: async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id): if not context.is_running: + logger.warning("Migration interrupted by user (is_running=False)") break @@ -187,6 +187,10 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta reply_to_stoat_id = None 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)) + 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( 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)) stats["messages"] += 1 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) if hasattr(msg, 'thread') and msg.thread: diff --git a/src/stoat/roles_permissions.py b/src/stoat/roles_permissions.py index 552323a..d9896ff 100644 --- a/src/stoat/roles_permissions.py +++ b/src/stoat/roles_permissions.py @@ -11,6 +11,7 @@ async def sync_roles_state(context: MigrationContext): """ 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() server = await context.stoat_writer._get_server() 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: """Syncs category and channel role overrides/permissions.""" + logger.info("Starting permissions synchronization for Stoat...") discord_categories = await context.discord_reader.get_categories() discord_channels = await context.discord_reader.get_channels() @@ -58,7 +60,10 @@ async def sync_permissions(context: MigrationContext, progress_callback: Callabl current_idx = 0 if total == 0: + logger.info("No mapped categories or channels found for Stoat permission sync.") 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_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) + logger.info(f"Stoat permissions sync complete: {len(synced_info['categories_synced'])} categories, {len(synced_info['channels_synced'])} channels.") return synced_info @@ -176,20 +182,17 @@ async def migrate_roles(context: MigrationContext, progress_callback: Callable[[ except Exception as 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) cloned_role_names = [] - if total == 0: - return cloned_role_names + logger.info(f"Migrating {total} roles to Stoat...") 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( name=role.name, color=role.color.value, diff --git a/src/stoat/server_metadata.py b/src/stoat/server_metadata.py index 307aac4..cccaa14 100644 --- a/src/stoat/server_metadata.py +++ b/src/stoat/server_metadata.py @@ -14,10 +14,12 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal if "name" in components: try: name = metadata.get("name") + logger.info(f"Syncing server name to Stoat: {name}") await context.writer.update_guild_metadata(name=name) cloned_data["name"] = name 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") # 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) if icon_bytes: + logger.info(f"Uploading server icon to Stoat ({len(icon_bytes)} bytes)...") await context.writer.update_guild_metadata(icon=icon_bytes) cloned_data["icon"] = icon_bytes await progress_callback("Server Icon", "DONE") else: + logger.debug("No server icon found to sync to Stoat.") 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") # 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) if banner_bytes: + logger.info(f"Uploading server banner to Stoat ({len(banner_bytes)} bytes)...") await context.writer.update_guild_metadata(banner=banner_bytes) cloned_data["banner"] = banner_bytes await progress_callback("Server Banner", "DONE") else: + logger.debug("No server banner found to sync to Stoat.") 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") return cloned_data diff --git a/src/ui/backup_ops.py b/src/ui/backup_ops.py index 59a2d06..cab8447 100644 --- a/src/ui/backup_ops.py +++ b/src/ui/backup_ops.py @@ -63,12 +63,12 @@ class BackupPane(Container): # ── validation ──────────────────────────────────────────────────────── - @work(exclusive=True, thread=True) + @work(exclusive=True) async def _validate(self) -> None: fillers = ["DISCORD_BOT_TOKEN", "000000000000000000", "DISCORD_SERVER_ID", "", None] d_token = self.config.discord_bot_token if d_token in fillers or self.config.discord_server_id in fillers: - self.app.call_from_thread(self._update_ui, "[red]NOT CONFIGURED[/red]", "", "", False) + self._update_ui("[red]NOT CONFIGURED[/red]", "", "", False) return try: res = await self.engine.discord_reader.validate() @@ -83,9 +83,9 @@ class BackupPane(Container): if info: 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: - 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: profile_file = Path(f"Reaper-{self.cfg_name}") / "server_profile.json" @@ -121,49 +121,49 @@ class BackupPane(Container): # ── workers ─────────────────────────────────────────────────────────── - @work(exclusive=True, thread=True) + @work(exclusive=True) async def run_backup_profile(self) -> None: modal = ProgressScreen() - self.app.call_from_thread(self.app.push_screen, modal) + self.app.push_screen(modal) await asyncio.sleep(0.1) - self.app.call_from_thread(modal.phase_progress) + modal.phase_progress() try: - self.app.call_from_thread(modal.set_status, "Starting readers...") + modal.set_status("Starting readers...") await self.engine.discord_reader.start() 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.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() - self.app.call_from_thread(modal.write, "Exporting assets...") + modal.write("Exporting 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"- {e_count} emojis, {s_count} stickers.") - self.app.call_from_thread(modal.phase_report, "Profile Backup") + modal.write(f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]") + modal.write(f"- {e_count} emojis, {s_count} stickers.") + modal.phase_report("Profile Backup") except discord.Forbidden as e: - self.app.call_from_thread(modal.write, f"[bold red]Backup failed: {e}[/bold red]") - self.app.call_from_thread(modal.phase_report, "Profile Backup", "error") + modal.write(f"[bold red]Backup failed: {e}[/bold red]") + modal.phase_report("Profile Backup", "error") except Exception as e: - self.app.call_from_thread(modal.write, f"[bold red]Error: {e}[/bold red]") - self.app.call_from_thread(modal.phase_report, "Profile Backup", "error") + modal.write(f"[bold red]Error: {e}[/bold red]") + modal.phase_report("Profile Backup", "error") finally: await self.engine.close_connections() - @work(exclusive=True, thread=True) + @work(exclusive=True) async def run_backup_messages(self) -> None: 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) 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.exporter.setup() @@ -178,8 +178,8 @@ class BackupPane(Container): ] if not eligible_channels: - self.app.call_from_thread(modal_prog.write, "[yellow]No text/news channels found to backup.[/yellow]") - self.app.call_from_thread(modal_prog.allow_close) + modal_prog.write("[yellow]No text/news channels found to backup.[/yellow]") + modal_prog.allow_close() return any_found = False @@ -189,7 +189,7 @@ class BackupPane(Container): any_found = True backed_up_ids.add(chan.id) - self.app.call_from_thread(self.app.pop_screen) + self.app.pop_screen() while True: loop = asyncio.get_running_loop() @@ -199,8 +199,7 @@ class BackupPane(Container): if not future.done(): 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), check_channels, ) @@ -214,69 +213,69 @@ class BackupPane(Container): 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) + self.app.push_screen(modal_prog) await asyncio.sleep(0.1) msg = "Sync existing backups" if not force_overwrite else "Overwriting existing backups" - self.app.call_from_thread(modal_prog.set_status, f"Awaiting Confirmation to backup [bold]{len(selected_channels)}[/bold] channels...") - self.app.call_from_thread(modal_prog.show_info, f"[cyan]{msg}[/cyan]", f"Targets: {', '.join([c.name for c in selected_channels[:3]])}{'...' if len(selected_channels) > 3 else ''}") + modal_prog.set_status(f"Awaiting Confirmation to backup [bold]{len(selected_channels)}[/bold] channels...") + 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) + 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") + modal_prog.dismiss() + self.app.switch_screen("config_selection") return # Proceed to progress break - self.app.call_from_thread(modal_prog.phase_progress) + modal_prog.phase_progress() total_chans = len(selected_channels) - self.app.call_from_thread(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.set_status("Backing up messages...") + modal_prog.write(f"[yellow]Starting backup for {total_chans} channels...[/yellow]") for chan in selected_channels: backup_exists = (self.exporter.export_path / "message_backup" / f"{chan.id}.json").exists() is_sync = backup_exists and not force_overwrite 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): - 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_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() - 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") + modal_prog.write("[bold green]Message backup complete![/bold green]") + modal_prog.phase_report("Message Backup") except Exception as e: - self.app.call_from_thread(modal_prog.write, f"[bold red]Message backup failed: {e}[/bold red]") - self.app.call_from_thread(modal_prog.phase_report, "Message Backup", "error") + modal_prog.write(f"[bold red]Message backup failed: {e}[/bold red]") + modal_prog.phase_report("Message Backup", "error") finally: await self.engine.close_connections() - @work(exclusive=True, thread=True) + @work(exclusive=True) async def run_backup_sync(self) -> None: 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) - self.app.call_from_thread(modal_prog.phase_progress) + modal_prog.phase_progress() 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.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.download_server_assets() await self.exporter.export_channels_structure() @@ -294,25 +293,25 @@ class BackupPane(Container): ] 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: - 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: - 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): - 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_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() - 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") + modal_prog.write("[bold green]Sync operation complete![/bold green]") + modal_prog.phase_report("Backup Sync") except Exception as e: - self.app.call_from_thread(modal_prog.write, f"[bold red]Sync failed: {e}[/bold red]") - self.app.call_from_thread(modal_prog.phase_report, "Backup Sync", "error") + modal_prog.write(f"[bold red]Sync failed: {e}[/bold red]") + modal_prog.phase_report("Backup Sync", "error") finally: await self.engine.close_connections() diff --git a/src/ui/modals.py b/src/ui/modals.py index 3f57234..8a82f39 100644 --- a/src/ui/modals.py +++ b/src/ui/modals.py @@ -365,17 +365,20 @@ class OptionSelectModal(ModalScreen[list[str]]): OptionSelectModal { align: center middle; } #opt_dialog { width: 60; - height: auto; + height: 80%; 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_scroll { margin-bottom: 1; height: 1fr; overflow-y: auto; } #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; } + RadioButton { background: transparent; } + RadioButton:focus { background: $accent 20%; } + """ 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("Deselect All", id="btn_opt_none") - with VerticalScroll(id="opt_scroll"): + with Vertical(id="opt_scroll"): for opt_id, label in self._options: yield RadioButton(label, id=f"opt_{opt_id}") diff --git a/src/ui/shuttle_ops.py b/src/ui/shuttle_ops.py index 8e3f2c1..723ce36 100644 --- a/src/ui/shuttle_ops.py +++ b/src/ui/shuttle_ops.py @@ -319,6 +319,7 @@ class ShuttlePane(Container): choice = await modal.phase_wait_confirm() if choice == "btn_back": modal.dismiss() + self._open_clone_menu() return elif choice == "btn_main_menu": modal.dismiss() @@ -358,6 +359,7 @@ class ShuttlePane(Container): choice = await modal.phase_wait_confirm() if choice == "btn_back": modal.dismiss() + self._open_sync_menu() return elif choice == "btn_main_menu": modal.dismiss() @@ -575,12 +577,14 @@ class ShuttlePane(Container): async def update_scan(current_stats): 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( self.engine, source_channel_id=source_channel.id, after_message_id=int(last_migrated) if last_migrated else None, progress_callback=update_scan, ) + logger.info(f"Analysis complete: {stats_analysis['messages']} new messages found.") self.engine.is_running = False # Set initial total stats for the confirmation block @@ -599,6 +603,7 @@ class ShuttlePane(Container): # Phase 2: Confirmation choice = await modal.phase_wait_confirm(show_continue=has_previous) + logger.info(f"User confirmation choice: {choice}") if choice == "btn_back": modal.dismiss() continue # Return to channel picker @@ -611,11 +616,15 @@ class ShuttlePane(Container): after_id = None if choice == "btn_continue" and last_migrated: + logger.info("Proceeding with 'Continue Migration' (incremental sink).") 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]") + logger.info("Proceeding with 'Start from ID' (fallback to begin).") after_id = None + else: + logger.info("Proceeding with 'Start from First' (clean sink).") # If we are here, we are proceeding with migration break @@ -629,6 +638,7 @@ class ShuttlePane(Container): total_threads = stats_analysis["threads"] 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 async def update_msg(current_stats): @@ -684,164 +694,116 @@ class ShuttlePane(Container): def _open_danger_menu(self): options = [ - ("dz_del_channels", "Delete ALL Channels & Categories", "error"), - ("dz_reset_perms", "Reset ALL Channel Permissions", "error"), - ("dz_del_roles", "Delete ALL Roles", "error"), - ("dz_del_assets", "Delete ALL Emojis & Stickers", "error"), + ("dz_del_channels", "Delete ALL Channels & Categories"), + ("dz_reset_perms", "Reset ALL Channel Permissions"), + ("dz_del_roles", "Delete ALL Roles"), + ("dz_del_assets", "Delete ALL Emojis & Stickers"), ] - def on_result(choice): - if choice == "dz_del_channels": - self._confirm_danger("Delete ALL channels and categories? This is IRREVERSIBLE.", self.run_dz_delete_channels) - elif choice == "dz_reset_perms": - 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 on_result(selections: list[str] | None): + if selections: + self.run_batch_danger(selections) + self.app.push_screen(OptionSelectModal("⚠ DANGER ZONE ⚠", options), on_result) - def _confirm_danger(self, message: str, callback): - async def do_confirm(): - modal = ProgressScreen() - self.app.push_screen(modal) - await asyncio.sleep(0.1) - - modal.set_status(f"[bold red]DANGER:[/bold red] {message}") + @work(exclusive=True) + async def run_batch_danger(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)} Destructive Operations...") 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() + if choice == "btn_back": + modal.dismiss() + self._open_danger_menu() + return elif choice == "btn_main_menu": + modal.dismiss() 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) - async def run_dz_delete_channels(self) -> None: + for sel in selections: + 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": from src.fluxer.danger_zone import danger_delete_all_channels else: from src.stoat.danger_zone import danger_delete_all_channels - modal = ProgressScreen() - self.app.push_screen(modal) - await asyncio.sleep(0.1) - modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) - modal.phase_progress() + modal.set_status("[red]Deleting channels...") + async def on_deleted(name, current, total): + modal.set_status(f"[red]Deleting: {name}") + modal.set_progress(current, total) - try: - modal.set_status("[red]Deleting channels...") - await self.engine.start_target_only() + count = await danger_delete_all_channels(self.engine, progress_callback=on_deleted) + 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.") - async def on_deleted(name, current, total): - 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: + async def _logic_dz_reset_perms(self, modal: ProgressScreen) -> None: if self.target_platform == "fluxer": from src.fluxer.danger_zone import danger_reset_channel_permissions else: from src.stoat.danger_zone import danger_reset_channel_permissions - modal = ProgressScreen() - self.app.push_screen(modal) - await asyncio.sleep(0.1) - modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) - modal.phase_progress() + modal.set_status("[red]Resetting permissions...") + async def on_reset(name, current, total): + modal.set_status(f"[red]Resetting: {name}") + modal.set_progress(current, total) - try: - modal.set_status("[red]Resetting permissions...") - await self.engine.start_target_only() + count = await danger_reset_channel_permissions(self.engine, progress_callback=on_reset) + 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.") - async def on_reset(name, current, total): - 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: + async def _logic_dz_delete_roles(self, modal: ProgressScreen) -> None: if self.target_platform == "fluxer": from src.fluxer.danger_zone import danger_delete_all_roles else: from src.stoat.danger_zone import danger_delete_all_roles - modal = ProgressScreen() - self.app.push_screen(modal) - await asyncio.sleep(0.1) - modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) - modal.phase_progress() + modal.set_status("[red]Deleting roles...") + async def on_deleted(name, current, total): + modal.set_status(f"[red]Deleting role: {name}") + modal.set_progress(current, total) - try: - modal.set_status("[red]Deleting roles...") - await self.engine.start_target_only() + count = await danger_delete_all_roles(self.engine, progress_callback=on_deleted) + 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.") - async def on_deleted(name, current, total): - 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: + async def _logic_dz_delete_assets(self, modal: ProgressScreen) -> None: if self.target_platform == "fluxer": from src.fluxer.danger_zone import danger_delete_all_emojis_and_stickers else: from src.stoat.danger_zone import danger_delete_all_emojis_and_stickers - modal = ProgressScreen() - self.app.push_screen(modal) - await asyncio.sleep(0.1) - modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) - modal.phase_progress() + modal.set_status("[red]Deleting assets...") + async def on_deleted(name, asset_type, current, total): + modal.set_status(f"[red]Deleting {asset_type}: {name}") + modal.set_progress(current, total) - try: - modal.set_status("[red]Deleting assets...") - await self.engine.start_target_only() - - 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() + counts = await danger_delete_all_emojis_and_stickers(self.engine, progress_callback=on_deleted) + 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.")