diff --git a/src/core/discord_reader.py b/src/core/discord_reader.py index a30c455..4471e55 100644 --- a/src/core/discord_reader.py +++ b/src/core/discord_reader.py @@ -1,6 +1,9 @@ import discord +import logging from typing import AsyncGenerator, Dict, Any +logger = logging.getLogger(__name__) + class DiscordReader: def __init__(self, token: str, server_id: str): self.token = token @@ -31,11 +34,11 @@ class DiscordReader: # Use fetch methods specifically to bypass dependency on gateway cache try: - logging.info(f"Fetching guild {self.server_id}...") + logger.info(f"Fetching guild {self.server_id}...") self.guild = await self.client.fetch_guild(self.server_id) - logging.info(f"Successfully fetched guild: {self.guild.name}") + logger.info(f"Successfully fetched guild: {self.guild.name}") except discord.Forbidden: - logging.error(f"403 Forbidden: Missing Access to fetch guild {self.server_id}.") + logger.error(f"403 Forbidden: Missing Access to fetch guild {self.server_id}.") raise # Pre-fetch roles via API - Handle Forbidden gracefully @@ -43,10 +46,10 @@ class DiscordReader: roles = await self.guild.fetch_roles() self.role_map = {r.id: r.name for r in roles} except discord.Forbidden: - logging.warning("403 Forbidden: Missing Access to fetch roles. Continuing without role mapping.") + logger.warning("403 Forbidden: Missing Access to fetch roles. Continuing without role mapping.") self.role_map = {} except Exception as e: - logging.error(f"Failed to fetch roles: {e}") + logger.error(f"Failed to fetch roles: {e}") self.role_map = {} async def validate(self) -> Dict[str, Any]: diff --git a/src/exodus/exporter.py b/src/exodus/exporter.py index 3d25693..158a9af 100644 --- a/src/exodus/exporter.py +++ b/src/exodus/exporter.py @@ -120,9 +120,8 @@ class DiscordExporter: else: logger.info("No server banner found to download.") - async def export_customization(self): - """Exports all emojis, stickers, members, plus server icon/banner, to server_media folder.""" - # Ensure icon/banner are downloaded + async def export_assets(self): + """Exports emojis, stickers, and server media to server_assets.json and server_media/.""" await self.download_server_assets() emojis = await self.reader.get_emojis() @@ -174,6 +173,23 @@ class DiscordExporter: except Exception as ex: logger.error(f"Failed to download sticker {s.name}: {ex}") + # Try to load existing customization to merge (if it exists) + custom_file = self.export_path / "server_assets.json" + customization = {"emojis": emoji_data, "stickers": sticker_data, "members": []} + if custom_file.exists(): + try: + with open(custom_file, "r", encoding="utf-8") as f: + old_data = json.load(f) + customization["members"] = old_data.get("members", []) + except Exception: pass + + with open(custom_file, "w", encoding="utf-8") as f: + json.dump(customization, f, indent=4, ensure_ascii=False) + + return len(emoji_data), len(sticker_data) + + async def export_members(self): + """Exports server members to server_assets.json.""" member_data = [] logger.info("Attempting to export members (requires Server Members Intent)...") try: @@ -190,20 +206,27 @@ class DiscordExporter: }) logger.info(f"Successfully exported {len(member_data)} members.") except discord.Forbidden: - logger.warning("403 Forbidden: Missing Access to fetch members. Skipping members export. Ensure 'Server Members Intent' is enabled in Discord Dev Portal.") + logger.warning("403 Forbidden: Missing Access to fetch members. Skipping members export.") + return 0 except Exception as e: logger.error(f"Failed to fetch members: {e}") + return 0 - customization = { - "emojis": emoji_data, - "stickers": sticker_data, - "members": member_data - } - - with open(self.export_path / "customization.json", "w", encoding="utf-8") as f: + # Merge with existing assets + custom_file = self.export_path / "server_assets.json" + customization = {"emojis": [], "stickers": [], "members": member_data} + if custom_file.exists(): + try: + with open(custom_file, "r", encoding="utf-8") as f: + old_data = json.load(f) + customization["emojis"] = old_data.get("emojis", []) + customization["stickers"] = old_data.get("stickers", []) + except Exception: pass + + with open(custom_file, "w", encoding="utf-8") as f: json.dump(customization, f, indent=4, ensure_ascii=False) - return len(emoji_data), len(sticker_data), len(member_data) + return len(member_data) async def export_channels_structure(self): """Exports categories and channels hierarchy.""" @@ -211,30 +234,39 @@ class DiscordExporter: channels = await self.reader.get_channels() structure = [] + chan_count = 0 + cat_count = len(categories) + for cat in categories: cat_channels = [c for c in channels if c.category_id == cat.id] + formatted_channels = [self._format_channel(c) for c in cat_channels] + chan_count += len(formatted_channels) structure.append({ "type": "category", "id": str(cat.id), "name": cat.name, "position": cat.position, - "channels": [self._format_channel(c) for c in cat_channels] + "channels": formatted_channels }) # Uncategorized uncategorized = [c for c in channels if not c.category_id] if uncategorized: + formatted_uncat = [self._format_channel(c) for c in uncategorized] + chan_count += len(formatted_uncat) structure.append({ "type": "category", "id": "uncategorized", "name": "Uncategorized", - "channels": [self._format_channel(c) for c in uncategorized] + "channels": formatted_uncat }) + # No need to increment cat_count for 'Uncategorized' usually, + # but let's see if the user wants it. For now, cat_count is real Discord categories. - output_file = self.export_path / "channels_structure.json" + output_file = self.export_path / "server_structure.json" with open(output_file, "w", encoding="utf-8") as f: json.dump(structure, f, indent=4, ensure_ascii=False) - return structure + return structure, cat_count, chan_count def _format_channel(self, c): return { diff --git a/src/ui/exodus_app.py b/src/ui/exodus_app.py index 600a525..38406a4 100644 --- a/src/ui/exodus_app.py +++ b/src/ui/exodus_app.py @@ -77,10 +77,14 @@ class ExodusCLI: d_display = f"[bold green]\"{d_name}\"[/bold green]" if d_name else "[bold red]NOT CONNECTED[/bold red]" console.print(f"[bold cyan]Source Server:[/bold cyan] {d_display}") + b_name = self.validation_results.get("discord_bot_name") + b_display = f"[bold green]\"{b_name}\"[/bold green]" if b_name else "[bold red]UNKNOWN[/bold red]" + console.print(f"[bold cyan]Bot name:[/bold cyan] {b_display}") + console.print("\n[bold]Main Menu[/bold]") - console.print("(1) Backup Basic Server Profile") - console.print("(2) Backup User Names & Avatars, other customization") - console.print("(3) Backup Message") + console.print("(1) Backup Server Profile") + console.print("(2) Backup User Names & Avatars") + console.print("(3) Backup Messages") console.print("(4) Update & Sync Backup") console.print("(C) Configuration") console.print("(Q) Exit") @@ -102,48 +106,63 @@ class ExodusCLI: break async def backup_server_profile(self): - """Option 1: Backup name, banner, logo and roles (with permissions)""" + """Option 1: Backup name, banner, logo, roles, structure, and custom assets.""" if not self.tokens_valid: return try: await self.engine.discord_reader.start() - # setup folder meta = await self.exporter.setup() - # Export metadata & assets - with console.status("[yellow]Backing up server profile & assets...[/yellow]"): + with console.status("[yellow]Backing up server profile & skeleton...[/yellow]"): + # 1. Profile & Assets await self.exporter.export_metadata() await self.exporter.download_server_assets() - - # export roles separately to handle 403 gracefully - try: - with console.status("[yellow]Backing up roles...[/yellow]"): - await self.exporter.export_roles() - except discord.Forbidden: - console.print("[yellow]Warning: 403 Forbidden. Could not backup roles (Missing Access).[/yellow]") + + # 2. Roles + try: + role_data = await self.exporter.export_roles() + r_count = len(role_data) + except discord.Forbidden: + r_count = 0 + console.print("[yellow]Warning: Could not backup roles (Missing Access).[/yellow]") + + # 3. Structure + _, cat_count, chan_count = await self.exporter.export_channels_structure() + + # 4. Emojis & Stickers + e_count, s_count = await self.exporter.export_assets() - console.print(f"[bold green]Basic Server Profile backed up to: {self.exporter.export_path}[/bold green]") + console.print(f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]") + console.print("\n[bold]Profile Backup Stats:[/bold]") + console.print("- name, logo & banner") + console.print(f"- {cat_count} categories & {chan_count} channels") + console.print(f"- {r_count} roles") + console.print(f"- {e_count} emojis, {s_count} stickers.") except discord.Forbidden as e: console.print(f"[bold red]Backup failed: {e}[/bold red]") - console.print("[yellow]Hint: This is a 'Missing Access' error. Check if your bot has 'Guilds' permissions enabled.[/yellow]") + console.print("[yellow]Hint: Missing permissions. Check bot 'Guilds' and 'Channel' access.[/yellow]") except Exception as e: console.print(f"[bold red]Backup failed: {e}[/bold red]") finally: await self.engine.close_connections() async def backup_customization(self): - """Option 2: Backup User Names & Avatars, Emojis, Stickers, etc.""" + """Option 2: Backup User Names & Avatars (Members).""" if not self.tokens_valid: return try: await self.engine.discord_reader.start() await self.exporter.setup() - with console.status("[yellow]Backing up customization (Members, Emojis & Stickers)...[/yellow]"): - e_count, s_count, m_count = await self.exporter.export_customization() - console.print(f"[bold green]Backup complete: {m_count} members, {e_count} emojis and {s_count} stickers saved to customization.json[/bold green]") + with console.status("[yellow]Backing up members and names...[/yellow]"): + m_count = await self.exporter.export_members() + + if m_count > 0: + console.print(f"[bold green]Backup complete: {m_count} members and avatars saved to customization.json[/bold green]") + else: + console.print("[yellow]No members exported. Ensure 'Server Members Intent' is enabled.[/yellow]") except discord.Forbidden as e: - console.print(f"[bold red]Customization backup failed: {e}[/bold red]") - console.print("[yellow]Hint: Missing Access. Ensure 'Server Members Intent' is enabled in the Discord Developer Portal.[/yellow]") + console.print(f"[bold red]Member backup failed: {e}[/bold red]") + console.print("[yellow]Hint: Missing Access. Ensure 'Server Members Intent' is enabled in the Developer Portal.[/yellow]") except Exception as e: - console.print(f"[bold red]Customization backup failed: {e}[/bold red]") + console.print(f"[bold red]Member backup failed: {e}[/bold red]") finally: await self.engine.close_connections()