implement basic discord backup
This commit is contained in:
parent
dddaaf7de9
commit
b9a89a4d2b
5 changed files with 626 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -25,6 +25,7 @@ wheels/
|
||||||
# Virtual Environment
|
# Virtual Environment
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
|
reference/
|
||||||
|
|
||||||
# Configuration and Secrets
|
# Configuration and Secrets
|
||||||
configs/
|
configs/
|
||||||
|
|
@ -39,3 +40,5 @@ config.yaml
|
||||||
test_*.py
|
test_*.py
|
||||||
test_release.zip
|
test_release.zip
|
||||||
test_release/
|
test_release/
|
||||||
|
|
||||||
|
EXPORT-*/
|
||||||
|
|
|
||||||
73
discord-exodus.py
Normal file
73
discord-exodus.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from src.ui.exodus_app import run_exodus
|
||||||
|
from src.core.configuration import load_config
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
try:
|
||||||
|
config = load_config()
|
||||||
|
log_level_str = config.migration.log_level.upper()
|
||||||
|
level = getattr(logging, log_level_str, logging.INFO)
|
||||||
|
except Exception:
|
||||||
|
level = logging.INFO
|
||||||
|
|
||||||
|
handlers = [logging.FileHandler('exodus.log', mode='a')]
|
||||||
|
logging.basicConfig(
|
||||||
|
format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s',
|
||||||
|
datefmt='%H:%M:%S',
|
||||||
|
level=level,
|
||||||
|
handlers=handlers
|
||||||
|
)
|
||||||
|
|
||||||
|
def relaunch_in_terminal():
|
||||||
|
"""Detects if running without a terminal on Linux and relaunches in one."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
if sys.platform != "linux":
|
||||||
|
return
|
||||||
|
|
||||||
|
is_tty = sys.stdin.isatty() or sys.stdout.isatty()
|
||||||
|
if is_tty or os.environ.get("EXODUS_RELAUNCHED"):
|
||||||
|
return
|
||||||
|
|
||||||
|
terminals = [
|
||||||
|
("gnome-terminal", ["--"]),
|
||||||
|
("ptyxis", ["--"]),
|
||||||
|
("x-terminal-emulator", ["-e"]),
|
||||||
|
("kgx", ["-e"]),
|
||||||
|
("konsole", ["-e"]),
|
||||||
|
("xfce4-terminal", ["-e"]),
|
||||||
|
("xterm", ["-e"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
executable = sys.executable if not getattr(sys, 'frozen', False) else os.path.abspath(sys.argv[0])
|
||||||
|
args = [executable] + sys.argv[1:]
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["EXODUS_RELAUNCHED"] = "1"
|
||||||
|
|
||||||
|
for term, cmd_args in terminals:
|
||||||
|
if shutil.which(term):
|
||||||
|
try:
|
||||||
|
subprocess.Popen([term] + cmd_args + args, env=env)
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
relaunch_in_terminal()
|
||||||
|
setup_logging()
|
||||||
|
try:
|
||||||
|
await run_exodus()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nExiting...")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
33
launch-exodus-LINUX.sh
Executable file
33
launch-exodus-LINUX.sh
Executable file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Get the directory where the script is located
|
||||||
|
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||||
|
cd "$DIR"
|
||||||
|
|
||||||
|
# Check if venv exists, create it if not
|
||||||
|
if [ ! -d "$DIR/venv" ]; then
|
||||||
|
echo "Virtual environment not found. Creating one..."
|
||||||
|
python3 -m venv "$DIR/venv"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Failed to create virtual environment."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Activate and install requirements
|
||||||
|
source "$DIR/venv/bin/activate"
|
||||||
|
|
||||||
|
if [ -f "$DIR/requirements.txt" ]; then
|
||||||
|
echo "Installing requirements..."
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r "$DIR/requirements.txt"
|
||||||
|
else
|
||||||
|
echo "Warning: requirements.txt not found. Skipping dependency installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Activate existing virtual environment
|
||||||
|
source "$DIR/venv/bin/activate"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
python3 discord-exodus.py "$@"
|
||||||
301
src/exodus/exporter.py
Normal file
301
src/exodus/exporter.py
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import discord
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, List, Optional, AsyncGenerator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class DiscordExporter:
|
||||||
|
"""Core logic for exporting Discord server data."""
|
||||||
|
|
||||||
|
def __init__(self, reader):
|
||||||
|
self.reader = reader
|
||||||
|
self.server_name = ""
|
||||||
|
self.server_id = ""
|
||||||
|
|
||||||
|
async def setup(self):
|
||||||
|
"""Prepares the output directory and fetches server metadata."""
|
||||||
|
metadata = await self.reader.get_server_metadata()
|
||||||
|
self.server_name = metadata.get("name", "Unknown Server")
|
||||||
|
self.server_id = metadata.get("id", "0")
|
||||||
|
|
||||||
|
# Create safe folder name
|
||||||
|
import re
|
||||||
|
safe_name = re.sub(r'[^a-zA-Z0-9_\-\.]', '_', self.server_name)
|
||||||
|
self.export_path = Path(".") / f"EXPORT-{safe_name}-{self.server_id}"
|
||||||
|
self.export_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Consolidate media into one folder
|
||||||
|
self.media_path = self.export_path / "server_media"
|
||||||
|
self.media_path.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
logger.info(f"Export directory set to: {self.export_path}")
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
async def export_metadata(self):
|
||||||
|
"""Saves server metadata to a JSON file."""
|
||||||
|
metadata = await self.reader.get_server_metadata()
|
||||||
|
output_file = self.export_path / "server_metadata.json"
|
||||||
|
with open(output_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(metadata, f, indent=4, ensure_ascii=False)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
async def export_roles(self):
|
||||||
|
"""Exports all roles to roles.json."""
|
||||||
|
roles = await self.reader.get_roles()
|
||||||
|
role_data = []
|
||||||
|
for r in roles:
|
||||||
|
role_data.append({
|
||||||
|
"id": str(r.id),
|
||||||
|
"name": r.name,
|
||||||
|
"color": str(r.color),
|
||||||
|
"position": r.position,
|
||||||
|
"permissions": r.permissions.value,
|
||||||
|
"hoist": r.hoist,
|
||||||
|
"mentionable": r.mentionable
|
||||||
|
})
|
||||||
|
|
||||||
|
output_file = self.export_path / "roles.json"
|
||||||
|
with open(output_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(role_data, f, indent=4, ensure_ascii=False)
|
||||||
|
return role_data
|
||||||
|
|
||||||
|
async def export_emojis_stickers(self):
|
||||||
|
"""Exports all emojis and stickers, plus server icon/banner, to server_media folder."""
|
||||||
|
emojis = await self.reader.get_emojis()
|
||||||
|
stickers = await self.reader.get_stickers()
|
||||||
|
metadata = await self.reader.get_server_metadata()
|
||||||
|
|
||||||
|
# Download Server Icon
|
||||||
|
if metadata.get("icon_url"):
|
||||||
|
try:
|
||||||
|
# We need a way to download from URL directly or use reader
|
||||||
|
# Since DiscordReader doesn't have a direct "download from URL" we might need one
|
||||||
|
# but for guild icon/banner we can use guild object if available in reader.
|
||||||
|
if self.reader.guild and self.reader.guild.icon:
|
||||||
|
data = await self.reader.download_asset(self.reader.guild.icon)
|
||||||
|
ext = "gif" if self.reader.guild.icon.is_animated() else "png"
|
||||||
|
with open(self.media_path / f"server_icon.{ext}", "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to download server icon: {e}")
|
||||||
|
|
||||||
|
# Download Server Banner
|
||||||
|
if metadata.get("banner_url"):
|
||||||
|
try:
|
||||||
|
if self.reader.guild and self.reader.guild.banner:
|
||||||
|
data = await self.reader.download_asset(self.reader.guild.banner)
|
||||||
|
ext = "gif" if self.reader.guild.banner.is_animated() else "png"
|
||||||
|
with open(self.media_path / f"server_banner.{ext}", "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to download server banner: {e}")
|
||||||
|
|
||||||
|
emoji_data = []
|
||||||
|
for e in emojis:
|
||||||
|
ext = "gif" if e.animated else "png"
|
||||||
|
filename = f"emoji_{e.name}_{e.id}.{ext}"
|
||||||
|
emoji_path = self.media_path / filename
|
||||||
|
try:
|
||||||
|
data = await self.reader.download_emoji(e)
|
||||||
|
with open(emoji_path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
emoji_data.append({
|
||||||
|
"id": str(e.id),
|
||||||
|
"name": e.name,
|
||||||
|
"animated": e.animated,
|
||||||
|
"filename": filename
|
||||||
|
})
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(f"Failed to download emoji {e.name}: {ex}")
|
||||||
|
|
||||||
|
sticker_data = []
|
||||||
|
for s in stickers:
|
||||||
|
ext = "png"
|
||||||
|
if s.url:
|
||||||
|
if ".json" in str(s.url): ext = "json"
|
||||||
|
elif ".gif" in str(s.url): ext = "gif"
|
||||||
|
elif ".webp" in str(s.url): ext = "webp"
|
||||||
|
|
||||||
|
filename = f"sticker_{s.name}_{s.id}.{ext}"
|
||||||
|
sticker_path = self.media_path / filename
|
||||||
|
try:
|
||||||
|
data = await self.reader.download_sticker(s)
|
||||||
|
with open(sticker_path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
sticker_data.append({
|
||||||
|
"id": str(s.id),
|
||||||
|
"name": s.name,
|
||||||
|
"filename": filename
|
||||||
|
})
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(f"Failed to download sticker {s.name}: {ex}")
|
||||||
|
|
||||||
|
with open(self.export_path / "emojis.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(emoji_data, f, indent=4, ensure_ascii=False)
|
||||||
|
with open(self.export_path / "stickers.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(sticker_data, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
return len(emoji_data), len(sticker_data)
|
||||||
|
|
||||||
|
async def export_channels_structure(self):
|
||||||
|
"""Exports categories and channels hierarchy."""
|
||||||
|
categories = await self.reader.get_categories()
|
||||||
|
channels = await self.reader.get_channels()
|
||||||
|
|
||||||
|
structure = []
|
||||||
|
for cat in categories:
|
||||||
|
cat_channels = [c for c in channels if c.category_id == cat.id]
|
||||||
|
structure.append({
|
||||||
|
"type": "category",
|
||||||
|
"id": str(cat.id),
|
||||||
|
"name": cat.name,
|
||||||
|
"position": cat.position,
|
||||||
|
"channels": [self._format_channel(c) for c in cat_channels]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Uncategorized
|
||||||
|
uncategorized = [c for c in channels if not c.category_id]
|
||||||
|
if uncategorized:
|
||||||
|
structure.append({
|
||||||
|
"type": "category",
|
||||||
|
"id": "uncategorized",
|
||||||
|
"name": "Uncategorized",
|
||||||
|
"channels": [self._format_channel(c) for c in uncategorized]
|
||||||
|
})
|
||||||
|
|
||||||
|
output_file = self.export_path / "channels_structure.json"
|
||||||
|
with open(output_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(structure, f, indent=4, ensure_ascii=False)
|
||||||
|
return structure
|
||||||
|
|
||||||
|
def _format_channel(self, c):
|
||||||
|
return {
|
||||||
|
"id": str(c.id),
|
||||||
|
"name": c.name,
|
||||||
|
"type": str(c.type),
|
||||||
|
"position": c.position,
|
||||||
|
"topic": getattr(c, "topic", None),
|
||||||
|
"nsfw": getattr(c, "nsfw", False)
|
||||||
|
}
|
||||||
|
|
||||||
|
async def export_channel_messages(self, channel_id: int, progress_callback=None):
|
||||||
|
"""Exports all messages from a channel, including attachments, pins, reactions."""
|
||||||
|
channel = await self.reader.get_channel(channel_id)
|
||||||
|
channel_name = channel.name
|
||||||
|
|
||||||
|
# Create channel-specific folder
|
||||||
|
chan_dir = self.export_path / "channels" / f"{channel_name}_{channel_id}"
|
||||||
|
attach_dir = chan_dir / "attachments"
|
||||||
|
chan_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
attach_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
async for msg in self.reader.fetch_message_history(channel_id):
|
||||||
|
msg_data = await self._format_message(msg, attach_dir)
|
||||||
|
messages.append(msg_data)
|
||||||
|
count += 1
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback(channel_name, count)
|
||||||
|
|
||||||
|
# Export pins
|
||||||
|
pins = []
|
||||||
|
if hasattr(channel, "pins"):
|
||||||
|
try:
|
||||||
|
pins_objects = await channel.pins()
|
||||||
|
pins = [str(p.id) for p in pins_objects]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch pins for {channel_name}: {e}")
|
||||||
|
|
||||||
|
output_data = {
|
||||||
|
"channel_info": self._format_channel(channel),
|
||||||
|
"messages": messages,
|
||||||
|
"pins": pins
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(chan_dir / "messages.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(output_data, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Also check for threads
|
||||||
|
if hasattr(channel, "threads"):
|
||||||
|
# We fetch threads separately if needed, or they might be returned by get_channels if we are not careful
|
||||||
|
pass
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def _format_message(self, msg, attach_dir):
|
||||||
|
attachments = []
|
||||||
|
for a in msg.attachments:
|
||||||
|
# Using ID to avoid collisions
|
||||||
|
filename = f"{a.id}_{a.filename}"
|
||||||
|
try:
|
||||||
|
# Optimized download can be added later (e.g. queue)
|
||||||
|
# data = await self.reader.download_attachment(a)
|
||||||
|
# with open(attach_dir / filename, "wb") as f:
|
||||||
|
# f.write(data)
|
||||||
|
attachments.append({
|
||||||
|
"id": str(a.id),
|
||||||
|
"filename": a.filename,
|
||||||
|
"url": a.url,
|
||||||
|
"local_path": f"attachments/{filename}"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to download attachment {a.filename} from message {msg.id}: {e}")
|
||||||
|
|
||||||
|
reactions = []
|
||||||
|
for r in msg.reactions:
|
||||||
|
emoji_str = str(r.emoji) if not r.is_custom_emoji() else f"{r.emoji.name}:{r.emoji.id}"
|
||||||
|
reactions.append({
|
||||||
|
"emoji": emoji_str,
|
||||||
|
"count": r.count
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(msg.id),
|
||||||
|
"author": {
|
||||||
|
"id": str(msg.author.id),
|
||||||
|
"name": msg.author.name,
|
||||||
|
"discriminator": msg.author.discriminator,
|
||||||
|
"avatar": str(msg.author.avatar.url) if msg.author.avatar else None,
|
||||||
|
"bot": msg.author.bot
|
||||||
|
},
|
||||||
|
"content": msg.content,
|
||||||
|
"timestamp": msg.created_at.isoformat(),
|
||||||
|
"edited_timestamp": msg.edited_at.isoformat() if msg.edited_at else None,
|
||||||
|
"attachments": attachments,
|
||||||
|
"embeds": [e.to_dict() for e in msg.embeds],
|
||||||
|
"reactions": reactions,
|
||||||
|
"type": str(msg.type),
|
||||||
|
"is_pinned": msg.pinned
|
||||||
|
}
|
||||||
|
|
||||||
|
async def export_threads(self, channel_id: int):
|
||||||
|
"""Exports active and archived threads for a channel."""
|
||||||
|
channel = await self.reader.get_channel(channel_id)
|
||||||
|
if not hasattr(channel, "threads") and not hasattr(channel, "archived_threads"):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
all_threads = []
|
||||||
|
try:
|
||||||
|
# Active threads
|
||||||
|
if hasattr(channel, "threads"):
|
||||||
|
all_threads.extend(channel.threads)
|
||||||
|
|
||||||
|
# Archived threads (can be private or public)
|
||||||
|
if hasattr(channel, "archived_threads"):
|
||||||
|
async for thread in channel.archived_threads(limit=None):
|
||||||
|
all_threads.append(thread)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch threads for {channel.name}: {e}")
|
||||||
|
|
||||||
|
thread_count = 0
|
||||||
|
for thread in all_threads:
|
||||||
|
await self.export_channel_messages(thread.id)
|
||||||
|
thread_count += 1
|
||||||
|
|
||||||
|
return thread_count
|
||||||
216
src/ui/exodus_app.py
Normal file
216
src/ui/exodus_app.py
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.prompt import Prompt, Confirm
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
||||||
|
|
||||||
|
from src.core.configuration import load_config, save_config
|
||||||
|
from src.core.base import MigrationContext
|
||||||
|
from src.exodus.exporter import DiscordExporter
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
class ExodusCLI:
|
||||||
|
"""CLI app to manage Discord server data export."""
|
||||||
|
|
||||||
|
def __init__(self, config_path="config.yaml"):
|
||||||
|
self.config_path = config_path
|
||||||
|
try:
|
||||||
|
self.config = load_config(self.config_path)
|
||||||
|
# We only need the Discord side of MigrationContext
|
||||||
|
self.engine = MigrationContext(self.config, target_platform="fluxer")
|
||||||
|
self.exporter = DiscordExporter(self.engine.discord_reader)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[bold red]Failed to load config: {e}[/bold red]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
self.tokens_valid = False
|
||||||
|
|
||||||
|
async def validate_config(self):
|
||||||
|
self.validation_results = {
|
||||||
|
"discord_token": False, "discord_bot_name": None,
|
||||||
|
"discord_server": False, "discord_server_name": None,
|
||||||
|
"discord_timeout": False
|
||||||
|
}
|
||||||
|
|
||||||
|
d_token = self.config.discord_bot_token
|
||||||
|
fillers = ["DISCORD_BOT_TOKEN", "000000000000000000", "DISCORD_SERVER_ID", "", None]
|
||||||
|
discord_dummy = d_token in fillers or self.config.discord_server_id in fillers
|
||||||
|
|
||||||
|
if discord_dummy:
|
||||||
|
console.print("[bold yellow]Discord setup incomplete in config.yaml[/bold yellow]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with console.status("[yellow]Validating Discord token...[/yellow]"):
|
||||||
|
res = await self.engine.discord_reader.validate()
|
||||||
|
self.validation_results["discord_token"] = res.get("token", False)
|
||||||
|
self.validation_results["discord_bot_name"] = res.get("bot_name")
|
||||||
|
self.validation_results["discord_server"] = res.get("server", False)
|
||||||
|
self.validation_results["discord_server_name"] = res.get("server_name")
|
||||||
|
|
||||||
|
if not res.get("token"):
|
||||||
|
console.print("[bold red]Discord Token validation failed.[/bold red]")
|
||||||
|
elif not res.get("server"):
|
||||||
|
console.print("[bold red]Discord Server ID validation failed.[/bold red]")
|
||||||
|
else:
|
||||||
|
self.tokens_valid = True
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[bold red]Discord validation failed: {e}[/bold red]")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
await self.validate_config()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
console.print("")
|
||||||
|
console.print(Panel.fit("Discord Exodus - Server Exporter", style="bold green"))
|
||||||
|
|
||||||
|
d_name = self.validation_results.get("discord_server_name")
|
||||||
|
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}")
|
||||||
|
|
||||||
|
console.print("\n[bold]Main Menu[/bold]")
|
||||||
|
console.print("(1) Setup Export Folder & Metadata")
|
||||||
|
console.print("(2) Export Roles")
|
||||||
|
console.print("(3) Export Emojis & Stickers")
|
||||||
|
console.print("(4) Export Channels Structure")
|
||||||
|
console.print("(5) Export All Message History (Long operation)")
|
||||||
|
console.print("(C) Configuration")
|
||||||
|
console.print("(Q) Exit")
|
||||||
|
|
||||||
|
choice = Prompt.ask("\nSelect an option", choices=["1", "2", "3", "4", "5", "C", "Q"], default="Q", show_choices=False).upper()
|
||||||
|
|
||||||
|
if choice == "1":
|
||||||
|
await self.setup_export()
|
||||||
|
elif choice == "2":
|
||||||
|
await self.export_roles()
|
||||||
|
elif choice == "3":
|
||||||
|
await self.export_assets()
|
||||||
|
elif choice == "4":
|
||||||
|
await self.export_structure()
|
||||||
|
elif choice == "5":
|
||||||
|
await self.export_messages()
|
||||||
|
elif choice == "C":
|
||||||
|
await self.edit_configuration()
|
||||||
|
elif choice == "Q":
|
||||||
|
await self.engine.close_connections()
|
||||||
|
break
|
||||||
|
|
||||||
|
async def setup_export(self):
|
||||||
|
if not self.tokens_valid: return
|
||||||
|
try:
|
||||||
|
await self.engine.discord_reader.start()
|
||||||
|
with console.status("[yellow]Setting up export directory...[/yellow]"):
|
||||||
|
meta = await self.exporter.setup()
|
||||||
|
await self.exporter.export_metadata()
|
||||||
|
console.print(f"[bold green]Export directory ready: {self.exporter.export_path}[/bold green]")
|
||||||
|
console.print(f"Server: [bold]{meta['name']}[/bold] ({meta['id']})")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[bold red]Setup failed: {e}[/bold red]")
|
||||||
|
finally:
|
||||||
|
await self.engine.close_connections()
|
||||||
|
|
||||||
|
async def export_roles(self):
|
||||||
|
if not self.tokens_valid: return
|
||||||
|
try:
|
||||||
|
await self.engine.discord_reader.start()
|
||||||
|
await self.exporter.setup()
|
||||||
|
with console.status("[yellow]Exporting roles...[/yellow]"):
|
||||||
|
roles = await self.exporter.export_roles()
|
||||||
|
console.print(f"[bold green]Exported {len(roles)} roles to roles.json[/bold green]")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[bold red]Role export failed: {e}[/bold red]")
|
||||||
|
finally:
|
||||||
|
await self.engine.close_connections()
|
||||||
|
|
||||||
|
async def export_assets(self):
|
||||||
|
if not self.tokens_valid: return
|
||||||
|
try:
|
||||||
|
await self.engine.discord_reader.start()
|
||||||
|
await self.exporter.setup()
|
||||||
|
with console.status("[yellow]Exporting emojis and stickers...[/yellow]"):
|
||||||
|
e_count, s_count = await self.exporter.export_emojis_stickers()
|
||||||
|
console.print(f"[bold green]Exported {e_count} emojis and {s_count} stickers.[/bold green]")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[bold red]Asset export failed: {e}[/bold red]")
|
||||||
|
finally:
|
||||||
|
await self.engine.close_connections()
|
||||||
|
|
||||||
|
async def export_structure(self):
|
||||||
|
if not self.tokens_valid: return
|
||||||
|
try:
|
||||||
|
await self.engine.discord_reader.start()
|
||||||
|
await self.exporter.setup()
|
||||||
|
with console.status("[yellow]Exporting channel structure...[/yellow]"):
|
||||||
|
struct = await self.exporter.export_channels_structure()
|
||||||
|
console.print(f"[bold green]Exported channel hierarchy to channels_structure.json[/bold green]")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[bold red]Structure export failed: {e}[/bold red]")
|
||||||
|
finally:
|
||||||
|
await self.engine.close_connections()
|
||||||
|
|
||||||
|
async def export_messages(self):
|
||||||
|
if not self.tokens_valid: return
|
||||||
|
try:
|
||||||
|
await self.engine.discord_reader.start()
|
||||||
|
await self.exporter.setup()
|
||||||
|
channels = await self.engine.discord_reader.get_channels()
|
||||||
|
|
||||||
|
console.print(f"\n[yellow]Found {len(channels)} channels to export.[/yellow]")
|
||||||
|
if not Confirm.ask("Start message export? This may take a while.", default=True):
|
||||||
|
return
|
||||||
|
|
||||||
|
with Progress(
|
||||||
|
SpinnerColumn(),
|
||||||
|
TextColumn("[progress.description]{task.description}"),
|
||||||
|
BarColumn(),
|
||||||
|
TaskProgressColumn(),
|
||||||
|
console=console
|
||||||
|
) as progress:
|
||||||
|
|
||||||
|
overall_task = progress.add_task("[cyan]Exporting Channels...", total=len(channels))
|
||||||
|
|
||||||
|
for chan in channels:
|
||||||
|
if chan.type not in [discord.ChannelType.text, discord.ChannelType.news, discord.ChannelType.voice]:
|
||||||
|
progress.advance(overall_task)
|
||||||
|
continue
|
||||||
|
|
||||||
|
progress.update(overall_task, description=f"[cyan]Exporting: {chan.name}")
|
||||||
|
await self.exporter.export_channel_messages(chan.id)
|
||||||
|
# Also export threads for this channel
|
||||||
|
await self.exporter.export_threads(chan.id)
|
||||||
|
progress.advance(overall_task)
|
||||||
|
|
||||||
|
console.print("[bold green]Message export complete![/bold green]")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[bold red]Message export failed: {e}[/bold red]")
|
||||||
|
finally:
|
||||||
|
await self.engine.close_connections()
|
||||||
|
|
||||||
|
async def edit_configuration(self):
|
||||||
|
# reuse or implement simplified version of edit_configuration from app.py
|
||||||
|
console.print("\n[bold]Configuration Editor[/bold]")
|
||||||
|
d_token = Prompt.ask("Discord Bot Token", default=self.config.discord_bot_token)
|
||||||
|
d_server = Prompt.ask("Discord Server ID", default=self.config.discord_server_id)
|
||||||
|
|
||||||
|
if d_token != self.config.discord_bot_token or d_server != self.config.discord_server_id:
|
||||||
|
self.config.discord_bot_token = d_token
|
||||||
|
self.config.discord_server_id = d_server
|
||||||
|
save_config(self.config, self.config_path)
|
||||||
|
self.engine = MigrationContext(self.config, target_platform="fluxer")
|
||||||
|
self.exporter = DiscordExporter(self.engine.discord_reader)
|
||||||
|
await self.validate_config()
|
||||||
|
console.print("[bold green]Config updated![/bold green]")
|
||||||
|
else:
|
||||||
|
console.print("[yellow]No changes.[/yellow]")
|
||||||
|
|
||||||
|
async def run_exodus(config_path="config.yaml"):
|
||||||
|
app = ExodusCLI(config_path)
|
||||||
|
await app.run()
|
||||||
Loading…
Add table
Reference in a new issue