diff --git a/disco-reaper.py b/disco-reaper.py index 4b9aafd..c480b48 100644 --- a/disco-reaper.py +++ b/disco-reaper.py @@ -1,9 +1,6 @@ import sys -import argparse -import asyncio import logging -from src.ui.reaper_app import run_disco_reaper -from src.ui.reaper_tui import run_disco_reaper_tui +from src.ui.main_app import run_disco_reaper_tui from src.core.configuration import load_config def setup_logging(): @@ -60,23 +57,14 @@ def relaunch_in_terminal(): except Exception: continue -async def main(): +def main(): relaunch_in_terminal() setup_logging() - - parser = argparse.ArgumentParser(description="Disco Reaper") - parser.add_argument("--cli", action="store_true", help="Run the legacy CLI interface") - parser.add_argument("--tui", action="store_true", help="Run the Textual TUI interface (default)") - args, _ = parser.parse_known_args() - - if args.cli: - await run_disco_reaper() - else: - await run_disco_reaper_tui() + run_disco_reaper_tui() if __name__ == "__main__": try: - asyncio.run(main()) + main() except KeyboardInterrupt: print("\nOperation terminated by user.") sys.exit(0) diff --git a/launch-reaper-LINUX.sh b/launch-reaper-LINUX.sh deleted file mode 100755 index d024be6..0000000 --- a/launch-reaper-LINUX.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/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 -if [[ "$1" == "--cli" ]]; then - echo "Starting Disco Reaper in CLI mode..." - python3 disco-reaper.py --cli -elif [[ "$1" == "--tui" ]]; then - echo "Starting Disco Reaper in TUI mode..." - python3 disco-reaper.py --tui -else - # Default to TUI if no arguments or other arguments - echo "Starting Disco Reaper (Default: TUI)..." - python3 disco-reaper.py --tui "$@" -fi diff --git a/launch-shuttle-LINUX.sh b/launch-shuttle-LINUX.sh deleted file mode 100755 index fe28a3d..0000000 --- a/launch-shuttle-LINUX.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/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 server-shuttle.py "$@" - diff --git a/launch-shuttle-MAC.sh b/launch-shuttle-MAC.sh deleted file mode 100644 index b586131..0000000 --- a/launch-shuttle-MAC.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/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..." - # On macOS, python3 is the standard command - 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 server-shuttle.py "$@" diff --git a/launch-shuttle-WIN.bat b/launch-shuttle-WIN.bat deleted file mode 100644 index 7c465ec..0000000 --- a/launch-shuttle-WIN.bat +++ /dev/null @@ -1,38 +0,0 @@ -@echo off -SET "DIR=%~dp0" -cd /d "%DIR%" - -:: Check if venv exists, create it if not -if not exist "%DIR%venv" ( - echo Virtual environment not found. Creating one... - python -m venv venv - - if %ERRORLEVEL% neq 0 ( - echo Error: Failed to create virtual environment. - pause - exit /b 1 - ) - - :: Activate and install requirements - call venv\Scripts\activate - - if exist "requirements.txt" ( - echo Installing requirements... - python -m pip install --upgrade pip - pip install -r requirements.txt - ) else ( - echo Warning: requirements.txt not found. Skipping dependency installation. - ) -) else ( - :: Activate existing virtual environment - call venv\Scripts\activate -) - -:: Run the application -python server-shuttle.py %* - -if %ERRORLEVEL% neq 0 ( - echo. - echo application exited with error code %ERRORLEVEL% - pause -) diff --git a/launch-unifier-LINUX.sh b/launch-unifier-LINUX.sh deleted file mode 100755 index 71beddc..0000000 --- a/launch-unifier-LINUX.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/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 -echo "Starting Unified TUI..." -python3 unifier.py "$@" diff --git a/launch.sh b/launch.sh new file mode 100755 index 0000000..93e61d5 --- /dev/null +++ b/launch.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Disco Reaper — unified launcher + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +cd "$DIR" + +# ── Virtualenv setup ───────────────────────────────────────────────────────── +if [ ! -d "$DIR/venv" ]; then + echo "Virtual environment not found. Creating..." + python3 -m venv "$DIR/venv" + if [ $? -ne 0 ]; then + echo "Error: Failed to create virtual environment." + exit 1 + fi + source "$DIR/venv/bin/activate" + if [ -f "$DIR/requirements.txt" ]; then + echo "Installing requirements..." + pip install --upgrade pip -q + pip install -r "$DIR/requirements.txt" -q + fi +else + source "$DIR/venv/bin/activate" +fi + +# ── Launch ─────────────────────────────────────────────────────────────────── +python3 "$DIR/disco-reaper.py" "$@" diff --git a/server-shuttle.py b/server-shuttle.py deleted file mode 100644 index 5a4c766..0000000 --- a/server-shuttle.py +++ /dev/null @@ -1,162 +0,0 @@ -import sys -import asyncio -import logging -from pathlib import Path -from src.ui.shuttle_app import run_cli -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('.shuttle.log', mode='a')] - if level == logging.DEBUG: - handlers.append(logging.StreamHandler(sys.stdout)) - - logging.basicConfig( - format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s', - datefmt='%H:%M:%S', - level=level, - handlers=handlers - ) - -def select_platform(config): - from rich.console import Console - from rich.prompt import Prompt - from rich.panel import Panel - - console = Console() - - fillers = [ "DISCORD_BOT_TOKEN", "FLUXER_BOT_TOKEN", "STOAT_BOT_TOKEN", - "000000000000000000", - "DISCORD_SERVER_ID", "FLUXER_COMMUNITY_ID", "STOAT_SERVER_ID", - "", None - ] - - fluxer_set = config.fluxer_bot_token not in fillers and config.fluxer_community_id not in fillers - stoat_set = config.stoat_bot_token not in fillers and config.stoat_server_id not in fillers - - if fluxer_set and not stoat_set: - return "fluxer" - elif stoat_set and not fluxer_set: - return "stoat" - elif fluxer_set and stoat_set: - console.print(Panel.fit( - "[bold]Both Fluxer and Stoat configurations found.[/bold]\n" - "Which one do you want to use?", - title="[bold cyan]Platform Selection[/bold cyan]" - )) - console.print("(1) [bold blue]Fluxer[/bold blue]") - console.print("(2) [bold red]Stoat[/bold red]") - console.print("(Q) [bold dim]Quit[/bold dim]") - console.print("") - - choice = Prompt.ask("Select an option [[bold cyan]1/2/Q[/bold cyan]]", choices=["1", "2", "Q", "q"], show_choices=False).upper() - if choice == "1": - return "fluxer" - elif choice == "2": - return "stoat" - else: - sys.exit(0) - else: - # Both are fillers - console.print(Panel.fit( - "[bold]First setup, Tool configuration[/bold]\n" - "Which platform do you want to migrate to?", - title="[bold cyan]Initial Setup[/bold cyan]" - )) - console.print("(1) [bold blue]Fluxer[/bold blue]") - console.print("(2) [bold red]Stoat[/bold red]") - console.print("(Q) [bold dim]Quit[/bold dim]") - console.print("") - - choice = Prompt.ask("Select an option [[bold cyan]1/2/Q[/bold cyan]]", choices=["1", "2", "Q", "q"], show_choices=False).upper() - if choice == "1": - return "fluxer" - elif choice == "2": - return "stoat" - else: - sys.exit(0) - -def relaunch_in_terminal(): - """Detects if running without a terminal on Linux and relaunches in one.""" - import os - import sys - import subprocess - import shutil - - # Only attempt on Linux - if sys.platform != "linux": - return - - # Check if we have a TTY on stdin or stdout, or if already relaunched - is_tty = sys.stdin.isatty() or sys.stdout.isatty() - if is_tty or os.environ.get("SERVER_SHUTTLE_RELAUNCHED"): - return - - # Diagnostic logging to help debug why it fails on some distros - debug_log = "/tmp/shuttle_terminal_debug.log" - with open(debug_log, "a") as f: - f.write(f"Relaunching... isatty={is_tty}, env={os.environ.get('SERVER_SHUTTLE_RELAUNCHED')}\n") - - # List of terminals to try with their specific execution flags - terminals = [ - ("gnome-terminal", ["--"]), # Modern GNOME Terminal - ("ptyxis", ["--"]), # Fedora/Modern GNOME - ("x-terminal-emulator", ["-e"]), # Ubuntu/Debian standard - ("kgx", ["-e"]), # GNOME Console - ("konsole", ["-e"]), - ("xfce4-terminal", ["-e"]), - ("lxterminal", ["-e"]), - ("mate-terminal", ["-e"]), - ("alacritty", ["-e"]), - ("kitty", []), - ("xterm", ["-e"]), - ] - - # Resolve the absolute path to ourselves. - # frozen=True means we are running from a PyInstaller bundle. - if getattr(sys, 'frozen', False): - executable = os.path.abspath(sys.argv[0]) - else: - executable = sys.executable - - args = [executable] + sys.argv[1:] - - # Set env var to prevent loops - env = os.environ.copy() - env["SERVER_SHUTTLE_RELAUNCHED"] = "1" - - for term, cmd_args in terminals: - if shutil.which(term): - with open(debug_log, "a") as f: - f.write(f"Found terminal: {term}\n") - try: - # Construct command: term [args] executable [sys.argv] - subprocess.Popen([term] + cmd_args + args, env=env) - sys.exit(0) - except Exception as e: - with open(debug_log, "a") as f: - f.write(f"Failed to launch {term}: {e}\n") - continue - -def main(): - relaunch_in_terminal() - config = load_config() - setup_logging() - try: - platform = select_platform(config) - asyncio.run(run_cli(target_platform=platform)) - except KeyboardInterrupt: - print("\nOperation terminated by user.") - sys.exit(0) - except Exception as e: - print(f"Failed to start tool: {e}") - sys.exit(1) - -if __name__ == "__main__": - main() diff --git a/src/core/base.py b/src/core/base.py index 9db29e2..a6e9b3e 100644 --- a/src/core/base.py +++ b/src/core/base.py @@ -10,35 +10,27 @@ from src.stoat.writer import StoatWriter logger = logging.getLogger(__name__) class MigrationContext: - """Holds state and connections for reading from Discord and writing to Fluxer.""" + """Holds state and connections for reading from Discord and writing to the target platform.""" - def __init__(self, config: AppConfig, target_platform: str = "fluxer"): + def __init__(self, config: AppConfig, target_platform: str | None = None): self.config = config - self.target_platform = target_platform + # If caller didn't specify, fall back to config value + self.target_platform = target_platform or config.target_platform or "fluxer" - # Use the target server/community ID for state file naming so each - # target server gets its own independent migration history. - if target_platform == "stoat": - server_id = config.stoat_server_id - else: - server_id = config.fluxer_community_id - - # Fallback for unconfigured platforms - if not server_id or server_id in [ - "000000000000000000", "DISCORD_SERVER_ID", "FLUXER_COMMUNITY_ID", "STOAT_SERVER_ID" - ]: + server_id = config.target_server_id or "unconfigured" + fillers = {"000000000000000000", "DISCORD_SERVER_ID", "FLUXER_COMMUNITY_ID", + "STOAT_SERVER_ID", "TARGET_SERVER_ID", ""} + if server_id in fillers: server_id = "unconfigured" - # Try to find an existing folder for this server_id + # Try to find an existing state folder for this server_id import os from pathlib import Path state_file: str | Path = "" messages_file: str | Path = "" - if server_id and server_id not in ["000000000000000000", "DISCORD_SERVER_ID", "FLUXER_COMMUNITY_ID", "STOAT_SERVER_ID", "unconfigured", ""]: - # If a folder doesn't exist yet, we stick with the generic server_id names, - # but they won't be saved until set_folder is called. + if server_id != "unconfigured": for d in Path(".").iterdir(): if d.is_dir() and d.name.endswith(f"-{server_id}"): state_file = d / "state-migration.json" @@ -55,20 +47,19 @@ class MigrationContext: server_id=config.discord_server_id ) - self.fluxer_writer = FluxerWriter( - token=config.fluxer_bot_token or "", - community_id=config.fluxer_community_id or "", - api_url=config.fluxer_api_url or "default" - ) - - self.stoat_writer = StoatWriter( - token=config.stoat_bot_token or "", - community_id=config.stoat_server_id or "", - api_url=config.stoat_api_url or "default" - ) + # Build the writer for the active target platform only + token = config.target_bot_token or "" + community_id = config.target_server_id or "" + api_url = config.target_api_url or "default" - self.writer = self.fluxer_writer if target_platform == "fluxer" else self.stoat_writer - + if self.target_platform == "stoat": + self.writer = StoatWriter(token=token, community_id=community_id, api_url=api_url) + self.stoat_writer = self.writer + self.fluxer_writer = FluxerWriter(token="", community_id="", api_url="default") + else: + self.writer = FluxerWriter(token=token, community_id=community_id, api_url=api_url) + self.fluxer_writer = self.writer + self.stoat_writer = StoatWriter(token="", community_id="", api_url="default") self.is_running = False @@ -76,7 +67,7 @@ class MigrationContext: """Returns connection validation status as a dictionary.""" try: d_valid = await self.discord_reader.validate() - f_valid = await self.fluxer_writer.validate() + t_valid = await self.writer.validate() return { "discord_token": d_valid.get("token", False), "discord_bot_name": d_valid.get("bot_name"), @@ -84,19 +75,19 @@ class MigrationContext: "discord_server_name": d_valid.get("server_name"), "discord_intents": d_valid.get("intents", {}), "discord_permissions": d_valid.get("permissions", {}), - "fluxer_token": f_valid.get("token", False), - "fluxer_bot_name": f_valid.get("bot_name"), - "fluxer_community": f_valid.get("community", False), - "fluxer_community_name": f_valid.get("community_name"), - "fluxer_permissions": f_valid.get("permissions", {}) + "target_token": t_valid.get("token", False), + "target_bot_name": t_valid.get("bot_name"), + "target_community": t_valid.get("community", False), + "target_community_name": t_valid.get("community_name"), + "target_permissions": t_valid.get("permissions", {}) } except Exception as e: logger.error(f"Validation failed with exception: {e}") return { "discord_token": False, "discord_server": False, - "fluxer_token": False, - "fluxer_community": False + "target_token": False, + "target_community": False } async def start_connections(self): diff --git a/src/core/configuration.py b/src/core/configuration.py index 1c7359b..fad6e87 100644 --- a/src/core/configuration.py +++ b/src/core/configuration.py @@ -11,29 +11,55 @@ class MigrationSettings(BaseModel): class AppConfig(BaseModel): discord_bot_token: str discord_server_id: str - fluxer_bot_token: Optional[str] = Field(default=None) - fluxer_community_id: Optional[str] = Field(default=None) - fluxer_api_url: Optional[str] = Field(default=None) - stoat_bot_token: Optional[str] = Field(default=None) - stoat_server_id: Optional[str] = Field(default=None) - stoat_api_url: Optional[str] = Field(default=None) - use_stoat: bool = Field(default=False) - use_fluxer: bool = Field(default=False) + tool_mode: str = Field(default="backup_only") # direct_transfer | backup_transfer | backup_only + target_platform: str = Field(default="none") # fluxer | stoat | none + target_bot_token: Optional[str] = Field(default=None) + target_server_id: Optional[str] = Field(default=None) + target_api_url: Optional[str] = Field(default="default") migration: MigrationSettings = Field(default_factory=MigrationSettings) + # ── backward‑compat shims (read‑only) ──────────────────────────────── + # The rest of the codebase (fluxer/stoat modules) still reads these. + # They all delegate to the unified target_* fields. + + @property + def use_fluxer(self) -> bool: + return self.target_platform == "fluxer" + + @property + def use_stoat(self) -> bool: + return self.target_platform == "stoat" + + @property + def fluxer_bot_token(self) -> Optional[str]: + return self.target_bot_token if self.target_platform == "fluxer" else None + + @property + def fluxer_community_id(self) -> Optional[str]: + return self.target_server_id if self.target_platform == "fluxer" else None + + @property + def fluxer_api_url(self) -> Optional[str]: + return self.target_api_url if self.target_platform == "fluxer" else None + + @property + def stoat_bot_token(self) -> Optional[str]: + return self.target_bot_token if self.target_platform == "stoat" else None + + @property + def stoat_server_id(self) -> Optional[str]: + return self.target_server_id if self.target_platform == "stoat" else None + + @property + def stoat_api_url(self) -> Optional[str]: + return self.target_api_url if self.target_platform == "stoat" else None + def load_config(config_path: Union[str, Path] = "config.yaml") -> AppConfig: path = Path(config_path) if not path.exists(): - # Create dummy config if missing config = AppConfig( discord_bot_token="DISCORD_BOT_TOKEN", discord_server_id="DISCORD_SERVER_ID", - fluxer_bot_token="FLUXER_BOT_TOKEN", - fluxer_community_id="FLUXER_COMMUNITY_ID", - fluxer_api_url="default", - stoat_bot_token="STOAT_BOT_TOKEN", - stoat_server_id="STOAT_SERVER_ID", - stoat_api_url="default" ) save_config(config, path) print(f"Created default configuration: {config_path}") @@ -44,12 +70,47 @@ def load_config(config_path: Union[str, Path] = "config.yaml") -> AppConfig: if not data: raise ValueError("Configuration file is empty or invalid YAML.") - + + # ── migrate legacy configs that still have separate fluxer/stoat fields ── + if "fluxer_bot_token" in data or "stoat_bot_token" in data: + if data.get("fluxer_bot_token") and data["fluxer_bot_token"] not in ("FLUXER_BOT_TOKEN", None): + data.setdefault("target_platform", "fluxer") + data.setdefault("target_bot_token", data["fluxer_bot_token"]) + data.setdefault("target_server_id", data.get("fluxer_community_id")) + data.setdefault("target_api_url", data.get("fluxer_api_url", "default")) + elif data.get("stoat_bot_token") and data["stoat_bot_token"] not in ("STOAT_BOT_TOKEN", None): + data.setdefault("target_platform", "stoat") + data.setdefault("target_bot_token", data["stoat_bot_token"]) + data.setdefault("target_server_id", data.get("stoat_server_id")) + data.setdefault("target_api_url", data.get("stoat_api_url", "default")) + # Remove legacy keys so they don't conflict with the model + for key in ("fluxer_bot_token", "fluxer_community_id", "fluxer_api_url", + "stoat_bot_token", "stoat_server_id", "stoat_api_url", + "use_fluxer", "use_stoat"): + data.pop(key, None) + return AppConfig(**data) def save_config(config: AppConfig, config_path: Union[str, Path] = "config.yaml"): path = Path(config_path) - # Dump model to dictionary, excluding None values to keep the YAML clean data = config.model_dump(exclude_none=True) with open(path, "w", encoding="utf-8") as f: yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False) + +def get_available_configs() -> list[str]: + """Returns a list of available configuration names from `Reaper-*` folders.""" + configs = [] + for item in Path(".").iterdir(): + if item.is_dir() and item.name.startswith("Reaper-"): + config_name = item.name[len("Reaper-"):] + if (item / "config.yaml").exists(): + configs.append(config_name) + return sorted(configs) + +def create_new_config(name: str) -> Path: + """Creates a new configuration folder and default config file.""" + folder_path = Path(f"Reaper-{name}") + folder_path.mkdir(exist_ok=True) + config_path = folder_path / "config.yaml" + load_config(config_path) # creates default + return folder_path diff --git a/src/core/state.py b/src/core/state.py index b0cdc39..5fece7d 100644 --- a/src/core/state.py +++ b/src/core/state.py @@ -222,8 +222,9 @@ class MigrationState: self.last_message_timestamps.clear() self.save_messages() - def set_folder(self, server_id: str, clean_name: str): - new_folder = Path(f"{clean_name}-{server_id}") + 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}" # 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: @@ -234,7 +235,7 @@ class MigrationState: except Exception as e: logger.debug(f"Could not rename {self.state_file.parent} to {new_folder}: {e}") - new_folder.mkdir(exist_ok=True) + new_folder.mkdir(parents=True, exist_ok=True) self.state_file = new_folder / "state-migration.json" self.messages_file = new_folder / "message-tracker.json" diff --git a/src/disco_reaper/exporter.py b/src/disco_reaper/exporter.py index 89ce644..7bcda77 100644 --- a/src/disco_reaper/exporter.py +++ b/src/disco_reaper/exporter.py @@ -11,11 +11,12 @@ logger = logging.getLogger(__name__) class DiscordExporter: """Core logic for exporting Discord server data.""" - def __init__(self, reader): + def __init__(self, reader, base_dir: Path | str = ""): self.reader = reader self.server_name = "" self.server_id = "" self.user_cache = {} + self.base_dir = Path(base_dir) if base_dir else Path(".") async def setup(self): """Prepares the output directory and fetches server metadata.""" @@ -26,7 +27,7 @@ class DiscordExporter: # 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 = self.base_dir / f"DISCORD-{self.server_id}" self.export_path.mkdir(parents=True, exist_ok=True) # Consolidate media into one folder diff --git a/src/ui/reaper_tui.py b/src/ui/backup_screen.py similarity index 93% rename from src/ui/reaper_tui.py rename to src/ui/backup_screen.py index 2ad34d8..a0182b2 100644 --- a/src/ui/reaper_tui.py +++ b/src/ui/backup_screen.py @@ -146,38 +146,141 @@ class ProgressModal(ModalScreen[None]): btn.variant = "success" self.query_one("#progress_bar", ProgressBar).update(total=100, progress=100) -class MainScreen(Screen): +class BackupScreen(Screen): """Main screen of the application.""" + CSS = """ + #backup_scroll { + align: center middle; + } + + #main_container { + width: 80%; + height: auto; + min-height: 20; + border: solid green; + padding: 1 2; + margin: 2 0; + } + + #info_container { + height: auto; + margin-bottom: 2; + border: tall cyan; + padding: 1; + } + + #actions_container { + height: auto; + layout: vertical; + align: center top; + margin-top: 1; + } + + Button { + width: 100%; + margin-bottom: 1; + } + + #config_dialog { + width: 60%; + height: 60%; + border: thick $background 80%; + background: $surface; + padding: 1 2; + } + #config_title { text-style: bold; margin-bottom: 1; } + #config_buttons { height: auto; margin-top: 1; } + #config_buttons Button { width: 1fr; margin: 0 1; } + + #channel_dialog { + width: 80%; + height: 80%; + border: thick $background 80%; + background: $surface; + padding: 1 2; + } + #chan_title { text-style: bold; margin-bottom: 1; } + + .category_header { + margin-top: 1; + background: $primary 10%; + text-style: bold; + padding-left: 1; + } + + #channel_list_scroll { + height: 1fr; + border: solid $primary; + margin-bottom: 1; + padding: 0 1; + } + + #select_all_buttons { height: auto; margin-bottom: 1; } + #select_all_buttons Button { width: auto; margin-right: 1; } + + #confirm_buttons { height: auto; margin-top: 1; } + #confirm_buttons Button { width: auto; margin: 0 1; } + + RadioButton:focus { + background: transparent; + border: none; + color: $text; + } + RadioButton > .radio-button--label { + padding: 0 1; + } + RadioButton:focus > .radio-button--label { + background: transparent; + text-style: none; + } + + #progress_dialog { + width: 80%; + height: 80%; + border: thick $background 80%; + background: $surface; + padding: 1 2; + } + #progress_status { text-style: bold; margin-bottom: 1; } + #progress_bar { margin-bottom: 1; } + #progress_log { height: 1fr; margin-bottom: 1; border: solid $primary; } + .label_warning { color: yellow; margin-right: 1; content-align: center middle;} + """ + BINDINGS = [ ("q", "app.exit", "Quit"), ("c", "config", "Config"), + ("b", "app.pop_screen", "Back"), ] - def __init__(self): - super().__init__() - self.config_path = "config.yaml" + def __init__(self, cfg_name: str, cfg_path: Path, *args, **kwargs): + super().__init__(*args, **kwargs) + self.cfg_name = cfg_name + self.config_path = cfg_path self.config = load_config(self.config_path) self.engine = MigrationContext(self.config, target_platform="fluxer") - self.exporter = DiscordExporter(self.engine.discord_reader) + self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=f"Reaper-{self.cfg_name}") self.validation_results = {} self.tokens_valid = False def compose(self) -> ComposeResult: yield Header(show_clock=True) - with Container(id="main_container"): - yield Label("Disco Reaper - Server Backup Tool", id="title_label") - with Vertical(id="info_container"): - yield Label("Loading...", id="lbl_server") - yield Label("", id="lbl_bot") - yield Label("", id="lbl_backup") - with Vertical(id="actions_container"): - yield Button("Backup Server Profile", id="btn_backup_profile", disabled=True) - yield Button("Backup Channel Messages", id="btn_backup_msgs", disabled=True) - yield Button("Update & Sync Backup", id="btn_backup_sync", disabled=True) - yield Rule() - yield Button("Configuration", id="btn_config") - yield Button("Exit", id="btn_exit", variant="error") + with VerticalScroll(id="backup_scroll"): + with Container(id="main_container"): + yield Label("Disco Reaper - Server Backup Tool", id="title_label") + with Vertical(id="info_container"): + yield Label("Loading...", id="lbl_server") + yield Label("", id="lbl_bot") + yield Label("", id="lbl_backup") + with Vertical(id="actions_container"): + yield Button("Backup Server Profile", id="btn_backup_profile", disabled=True) + yield Button("Backup Channel Messages", id="btn_backup_msgs", disabled=True) + yield Button("Update & Sync Backup", id="btn_backup_sync", disabled=True) + yield Rule() + yield Button("Configuration", id="btn_config") + yield Button("Back", id="btn_back") + yield Button("Exit", id="btn_exit", variant="error") yield Footer() def on_mount(self) -> None: @@ -236,8 +339,7 @@ class MainScreen(Screen): if not d_name or not d_id: return None - safe_name = re.sub(r'[^a-zA-Z0-9_\-\.]', '_', d_name) - export_path = Path(".") / f"EXPORT-{safe_name}-{d_id}" + export_path = Path(f"Reaper-{self.cfg_name}") / f"DISCORD-{d_id}" profile_file = export_path / "server_profile.json" if profile_file.exists(): @@ -262,7 +364,7 @@ class MainScreen(Screen): self.config.discord_server_id = reply["server"] save_config(self.config, self.config_path) self.engine = MigrationContext(self.config, target_platform="fluxer") - self.exporter = DiscordExporter(self.engine.discord_reader) + self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=f"Reaper-{self.cfg_name}") self.validate_config() self.app.push_screen( @@ -273,6 +375,8 @@ class MainScreen(Screen): def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "btn_exit": self.app.exit() + elif event.button.id == "btn_back": + self.app.pop_screen() elif event.button.id == "btn_config": self.open_config() elif event.button.id == "btn_backup_profile": @@ -460,115 +564,4 @@ class MainScreen(Screen): self.app.call_from_thread(modal_prog.set_status, "Finished.") self.app.call_from_thread(modal_prog.allow_close) -class ReaperUI(App): - CSS = """ - Screen { - align: center middle; - } - - #title_label { - text-style: bold; - color: green; - margin-bottom: 1; - content-align: center middle; - width: 100%; - } - - #main_container { - width: 80%; - height: 80%; - border: solid green; - padding: 1 2; - } - - #info_container { - height: auto; - margin-bottom: 2; - border: tall cyan; - padding: 1; - } - - #actions_container { - height: auto; - layout: vertical; - align: center top; - margin-top: 1; - } - - Button { - width: 100%; - margin-bottom: 1; - } - - #config_dialog { - width: 60%; - height: 60%; - border: thick $background 80%; - background: $surface; - padding: 1 2; - } - #config_title { text-style: bold; margin-bottom: 1; } - #config_buttons { height: auto; margin-top: 1; } - #config_buttons Button { width: 1fr; margin: 0 1; } - - #channel_dialog { - width: 80%; - height: 80%; - border: thick $background 80%; - background: $surface; - padding: 1 2; - } - #chan_title { text-style: bold; margin-bottom: 1; } - - .category_header { - margin-top: 1; - background: $primary 10%; - text-style: bold; - padding-left: 1; - } - #channel_list_scroll { - height: 1fr; - border: solid $primary; - margin-bottom: 1; - padding: 0 1; - } - - #select_all_buttons { height: auto; margin-bottom: 1; } - #select_all_buttons Button { width: auto; margin-right: 1; } - - #confirm_buttons { height: auto; margin-top: 1; } - #confirm_buttons Button { width: auto; margin: 0 1; } - - RadioButton:focus { - background: transparent; - border: none; - color: $text; - } - RadioButton > .radio-button--label { - padding: 0 1; - } - RadioButton:focus > .radio-button--label { - background: transparent; - text-style: none; - } - - #progress_dialog { - width: 80%; - height: 80%; - border: thick $background 80%; - background: $surface; - padding: 1 2; - } - #progress_status { text-style: bold; margin-bottom: 1; } - #progress_bar { margin-bottom: 1; } - #progress_log { height: 1fr; margin-bottom: 1; border: solid $primary; } - .label_warning { color: yellow; margin-right: 1; content-align: center middle;} - """ - - def on_mount(self) -> None: - self.push_screen(MainScreen()) - -async def run_disco_reaper_tui(config_path="config.yaml"): - app = ReaperUI() - await app.run_async() diff --git a/src/ui/main_app.py b/src/ui/main_app.py new file mode 100644 index 0000000..63c60bb --- /dev/null +++ b/src/ui/main_app.py @@ -0,0 +1,346 @@ +import re +import os +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal, Vertical, VerticalScroll +from textual.widgets import ( + Header, Footer, Button, Label, Input, ListItem, + ListView, Rule, RadioButton, RadioSet, +) +from textual.screen import Screen, ModalScreen + +from src.core.configuration import ( + get_available_configs, create_new_config, load_config, save_config, +) + + +# ────────────────────────────────────────────────────────────────────────────── +# Modal: create a new Reaper-* config folder +# ────────────────────────────────────────────────────────────────────────────── + +class NewConfigModal(ModalScreen[str]): + """Modal to enter a name for a new configuration.""" + + DEFAULT_CSS = """ + NewConfigModal { align: center middle; } + #new_config_dialog { + width: 50; height: auto; + border: thick $background 80%; background: $surface; padding: 1 2; + } + #new_config_title { text-style: bold; margin-bottom: 1; } + #new_config_buttons { height: auto; margin-top: 1; } + #new_config_buttons Button { width: 1fr; margin: 0 1; } + """ + + def compose(self) -> ComposeResult: + with Vertical(id="new_config_dialog"): + yield Label("Enter new configuration name:", id="new_config_title") + yield Input(placeholder="e.g. MyServer", id="new_config_input") + with Horizontal(id="new_config_buttons"): + yield Button("Create", variant="success", id="btn_create") + yield Button("Cancel", variant="primary", id="btn_cancel") + + def _get_sanitized_name(self) -> str: + raw = self.query_one("#new_config_input", Input).value.strip() + return re.sub(r"[^a-zA-Z0-9_-]+", "-", raw).strip("-") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_create": + name = self._get_sanitized_name() + if name: + self.dismiss(name) + elif event.button.id == "btn_cancel": + self.dismiss(None) + + def on_key(self, event) -> None: + if event.key == "enter": + name = self._get_sanitized_name() + if name: + self.dismiss(name) + elif event.key == "escape": + self.dismiss(None) + + +# ────────────────────────────────────────────────────────────────────────────── +# Screen 1: pick (or create) a Reaper-* config +# ────────────────────────────────────────────────────────────────────────────── + +class ConfigSelectionScreen(Screen): + """Screen to select or create a Reaper configuration.""" + + DEFAULT_CSS = """ + ConfigSelectionScreen { align: center middle; } + #config_sel_container { + width: 60; height: auto; + border: solid green; padding: 1 2; + } + #config_sel_title { + text-style: bold; color: green; margin-bottom: 1; + content-align: center middle; width: 100%; + } + #config_list_container { + height: 10; max-height: 20; + border: solid $primary; margin-bottom: 1; + } + #config_sel_actions { height: auto; margin-top: 1; } + #config_sel_actions Button { width: 1fr; margin: 0 1; } + """ + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Container(id="config_sel_container"): + yield Label("Disco Reaper — Select Configuration", id="config_sel_title") + with VerticalScroll(id="config_list_container"): + yield ListView(id="config_list") + with Horizontal(id="config_sel_actions"): + yield Button("Create New Config", id="btn_new_config", variant="success") + yield Button("Exit", id="btn_exit", variant="error") + yield Footer() + + def on_mount(self) -> None: + self.refresh_configs() + + def on_screen_resume(self) -> None: + self.refresh_configs() + + def refresh_configs(self) -> None: + configs = get_available_configs() + lv = self.query_one("#config_list", ListView) + lv.clear() + for c in configs: + lv.append(ListItem(Label(f"Reaper-{c}"), name=c)) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + cfg_name = event.item.name + cfg_path = Path(f"Reaper-{cfg_name}") / "config.yaml" + self.app.push_screen(ConfigScreen(cfg_name, cfg_path)) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_new_config": + def cb(name: str | None): + if name: + create_new_config(name) + self.refresh_configs() + self.app.push_screen(NewConfigModal(), cb) + elif event.button.id == "btn_exit": + self.app.exit() + + +# ────────────────────────────────────────────────────────────────────────────── +# Screen 2: edit config + pick mode + start +# ────────────────────────────────────────────────────────────────────────────── + +_MODE_MAP = { + "radio_direct": "direct_transfer", + "radio_backup": "backup_transfer", + "radio_bkonly": "backup_only", +} +_MODE_LABELS = { + "direct_transfer": "radio_direct", + "backup_transfer": "radio_backup", + "backup_only": "radio_bkonly", +} + +_PLAT_MAP = { + "radio_fluxer": "fluxer", + "radio_stoat": "stoat", +} +_PLAT_LABELS = { + "fluxer": "radio_fluxer", + "stoat": "radio_stoat", +} + + +class ConfigScreen(Screen): + """Configuration screen — Discord config, tool mode, and target platform.""" + + DEFAULT_CSS = """ + ConfigScreen { align: center middle; } + #cfg_scroll { width: 100%; height: 100%; align: center middle; } + #cfg_container { + width: 70; height: auto; + border: solid green; padding: 1 2; margin: 1 0; + } + #cfg_title { + text-style: bold; color: green; margin-bottom: 1; + content-align: center middle; width: 100%; + } + .section_title { + text-style: bold; color: cyan; margin-top: 1; margin-bottom: 0; + } + .field_label { margin-top: 1; } + #cfg_container Input { margin-bottom: 0; } + #mode_radio, #plat_radio { + height: auto; margin: 0 0 0 2; + } + #target_section { height: auto; } + #cfg_actions { height: auto; margin-top: 1; } + #cfg_actions Button { width: 1fr; margin: 0 1; } + """ + + BINDINGS = [("escape", "go_back", "Back")] + + def __init__(self, cfg_name: str, cfg_path: Path, *args, **kwargs): + super().__init__(*args, **kwargs) + self.cfg_name = cfg_name + self.cfg_path = cfg_path + self.config = load_config(cfg_path) + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with VerticalScroll(id="cfg_scroll"): + with Container(id="cfg_container"): + yield Label(f"Configuration — {self.cfg_name}", id="cfg_title") + + # ── Discord ────────────────────────────────────────────── + yield Label("Discord", classes="section_title") + yield Rule() + yield Label("Bot Token:", classes="field_label") + yield Input( + value=self.config.discord_bot_token or "", + id="inp_discord_token", + password=True, + ) + yield Label("Server ID:", classes="field_label") + yield Input( + value=self.config.discord_server_id or "", + id="inp_discord_server", + ) + + # ── Reaper Mode ────────────────────────────────────────── + yield Label("Reaper Mode", classes="section_title") + yield Rule() + cur_mode = self.config.tool_mode or "backup_only" + with RadioSet(id="mode_radio"): + yield RadioButton( + "Shuttle Transfer (direct migration)", + id="radio_direct", + value=(cur_mode == "direct_transfer"), + ) + yield RadioButton( + "Backup & Migrate (backup first, then migrate)", + id="radio_backup", + value=(cur_mode == "backup_transfer"), + ) + yield RadioButton( + "Backup Only (local backup, no migration)", + id="radio_bkonly", + value=(cur_mode == "backup_only"), + ) + + # ── Target Platform (hidden for backup_only) ───────────── + with Vertical(id="target_section"): + yield Label("Target Platform", classes="section_title") + yield Rule() + cur_plat = self.config.target_platform or "none" + with RadioSet(id="plat_radio"): + yield RadioButton( + "Fluxer", + id="radio_fluxer", + value=(cur_plat == "fluxer"), + ) + yield RadioButton( + "Stoat", + id="radio_stoat", + value=(cur_plat == "stoat"), + ) + yield Label("Bot Token:", classes="field_label") + yield Input( + value=self.config.target_bot_token or "", + id="inp_target_token", + password=True, + ) + yield Label("Community / Server ID:", classes="field_label") + yield Input( + value=self.config.target_server_id or "", + id="inp_target_server", + ) + + yield Rule() + with Horizontal(id="cfg_actions"): + yield Button("Save & Start", variant="success", id="btn_start") + yield Button("Save", variant="primary", id="btn_save") + yield Button("Back", id="btn_back") + yield Footer() + + def on_mount(self) -> None: + self._toggle_target_section() + + # ── show / hide the target platform section ────────────────────────── + + def _get_selected_mode(self) -> str: + for rb in self.query("#mode_radio RadioButton"): + if rb.value: + return _MODE_MAP.get(rb.id, "backup_only") + return "backup_only" + + def _get_selected_platform(self) -> str: + for rb in self.query("#plat_radio RadioButton"): + if rb.value: + return _PLAT_MAP.get(rb.id, "fluxer") + return "fluxer" + + def _toggle_target_section(self) -> None: + section = self.query_one("#target_section") + section.display = self._get_selected_mode() != "backup_only" + + def on_radio_set_changed(self, event: RadioSet.Changed) -> None: + if event.radio_set.id == "mode_radio": + self._toggle_target_section() + + # ── save / start ───────────────────────────────────────────────────── + + def _collect_and_save(self) -> None: + self.config.discord_bot_token = self.query_one("#inp_discord_token", Input).value.strip() or self.config.discord_bot_token + self.config.discord_server_id = self.query_one("#inp_discord_server", Input).value.strip() or self.config.discord_server_id + self.config.tool_mode = self._get_selected_mode() + + if self.config.tool_mode != "backup_only": + self.config.target_platform = self._get_selected_platform() + self.config.target_bot_token = self.query_one("#inp_target_token", Input).value.strip() or self.config.target_bot_token + self.config.target_server_id = self.query_one("#inp_target_server", Input).value.strip() or self.config.target_server_id + else: + self.config.target_platform = "none" + + save_config(self.config, self.cfg_path) + + def _launch_mode(self) -> None: + mode = self.config.tool_mode + if mode == "direct_transfer": + from src.ui.shuttle_screen import ShuttleScreen + self.app.push_screen(ShuttleScreen(self.cfg_name, self.cfg_path)) + elif mode == "backup_transfer": + from src.ui.backup_screen import BackupScreen + self.app.push_screen(BackupScreen(self.cfg_name, self.cfg_path)) + else: # backup_only + from src.ui.backup_screen import BackupScreen + self.app.push_screen(BackupScreen(self.cfg_name, self.cfg_path)) + + def action_go_back(self) -> None: + self.app.pop_screen() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_back": + self.app.pop_screen() + elif event.button.id == "btn_save": + self._collect_and_save() + self.notify("Configuration saved.", severity="information") + elif event.button.id == "btn_start": + self._collect_and_save() + self._launch_mode() + + +# ────────────────────────────────────────────────────────────────────────────── +# App +# ────────────────────────────────────────────────────────────────────────────── + +class ReaperApp(App): + + def on_mount(self) -> None: + self.push_screen(ConfigSelectionScreen()) + + +def run_disco_reaper_tui(): + app = ReaperApp() + app.run() diff --git a/src/ui/reaper_app.py b/src/ui/reaper_app.py deleted file mode 100644 index c38e849..0000000 --- a/src/ui/reaper_app.py +++ /dev/null @@ -1,350 +0,0 @@ -import sys -import asyncio -import discord -import logging -import time -import re -import json -from datetime import datetime -from pathlib import Path -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.disco_reaper.exporter import DiscordExporter - -console = Console() - -class DiscoReaperCLI: - """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 - - def get_backup_info(self): - """Checks for existing backup and returns formatted timestamp.""" - d_name = self.validation_results.get("discord_server_name") - d_id = self.config.discord_server_id - if not d_name or not d_id or d_id == "DISCORD_SERVER_ID": - return None - - safe_name = re.sub(r'[^a-zA-Z0-9_\-\.]', '_', d_name) - export_path = Path(".") / f"EXPORT-{safe_name}-{d_id}" - profile_file = export_path / "server_profile.json" - - if profile_file.exists(): - try: - with open(profile_file, "r", encoding="utf-8") as f: - data = json.load(f) - ts_str = data.get("last_backup") - if ts_str: - dt = datetime.fromisoformat(ts_str) - return dt.strftime("%d-%b-%Y %H:%M") - except Exception: - pass - return None - - async def run(self): - await self.validate_config() - - try: - while True: - console.print("") - console.print(Panel.fit("Disco Reaper - Server Backup Tool", 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}") - - 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}") - - backup_ts = self.get_backup_info() - if backup_ts: - console.print(f"[bold cyan]Backup Found:[/bold cyan] [bold yellow]{backup_ts}[/bold yellow]") - - console.print("\n[bold]Main Menu[/bold]") - console.print("(1) Backup Server Profile") - console.print("(2) Backup Channel Messages") - console.print("(3) Update & Sync Backup") - console.print("(4) Configuration") - console.print("(Q) Exit") - - choice = Prompt.ask("\nSelect an option", choices=["1", "2", "3", "4", "Q"], default="Q", show_choices=False).upper() - - if choice == "1": - await self.backup_server_profile() - elif choice == "2": - await self.backup_messages() - elif choice == "3": - await self.sync_backup() - elif choice == "4": - await self.edit_configuration() - elif choice == "Q": - break - except (KeyboardInterrupt, asyncio.CancelledError): - console.print("\n[yellow]Operation terminated by user.[/yellow]") - finally: - await self.engine.close_connections() - - async def backup_server_profile(self): - """Option 1: Backup name, banner, logo, roles, structure, and custom assets.""" - if not self.tokens_valid: return - try: - await self.engine.discord_reader.start() - meta = await self.exporter.setup() - - with console.status("[yellow]Backing up server profile & skeleton...[/yellow]"): - # 1. Profile & Assets - await self.exporter.export_metadata() - await self.exporter.download_server_assets() - - # 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]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: 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_messages(self, force_overwrite=None, skip_found_prompt=False, auto_sync_existing=False): - """Option 2: Backup Channels structure and all message history.""" - if not self.tokens_valid: return - try: - await self.engine.discord_reader.start() - await self.exporter.setup() - - # 1. Export structure first - with console.status("[yellow]Exporting channel structure...[/yellow]"): - await self.exporter.export_channels_structure() - - # 2. Select Channels - with console.status("[yellow]Fetching channels & categories...[/yellow]"): - all_channels = await self.engine.discord_reader.get_channels() - all_categories = await self.engine.discord_reader.get_categories() - cat_map = {c.id: c.name for c in all_categories} - - # Filter for exportable channels - eligible_channels = [ - c for c in all_channels - if c.type in [discord.ChannelType.text, discord.ChannelType.news, discord.ChannelType.forum] - ] - - if not eligible_channels: - console.print("[yellow]No text/news channels found to backup.[/yellow]") - return - - selected_channels = [] - - if auto_sync_existing: - selected_channels = [ - c for c in eligible_channels - if (self.exporter.export_path / "message_backup" / f"{c.id}.json").exists() - ] - if not selected_channels: - console.print("[yellow]No existing backups found to sync.[/yellow]") - return - # In auto-sync mode, we always use sync (force_overwrite=False) and skip prompts - force_overwrite = False - skip_found_prompt = True - else: - console.print(f"\n[bold]Select Channels to Backup ({len(eligible_channels)} total):[/bold]") - - # Identify duplicate names to show categories for them - name_counts = {} - for chan in eligible_channels: - name_counts[chan.name] = name_counts.get(chan.name, 0) + 1 - - for i, chan in enumerate(eligible_channels): - display_name = chan.name - if name_counts[chan.name] > 1: - cat_name = cat_map.get(chan.category_id) - if cat_name: - display_name = f"{chan.name} [{cat_name}]" - - # Check for existing backup - found_prefix = "" - backup_file = self.exporter.export_path / "message_backup" / f"{chan.id}.json" - if backup_file.exists(): - found_prefix = "[green][FOUND][/green] " - - console.print(f"({i+1}) {found_prefix}{display_name}") - - console.print("(A) [bold green]All Channels[/bold green]") - console.print("(B) Back") - - choices = [str(i+1) for i in range(len(eligible_channels))] + ["A", "B", "a", "b"] - selection_input = Prompt.ask("\nSelect option or indices (e.g. 1,2,5)", default="B") - - if selection_input.upper() == "B": - return - - if selection_input.upper() == "A": - selected_channels = eligible_channels - else: - try: - # Handle multiple indices like '1,2,5' - indices = [int(i.strip()) - 1 for i in selection_input.split(",") if i.strip().isdigit()] - selected_channels = [eligible_channels[i] for i in indices if 0 <= i < len(eligible_channels)] - except Exception: - console.print("[red]Invalid selection. Aborting.[/red]") - return - - if not selected_channels: - console.print("[yellow]No valid channels selected.[/yellow]") - return - - # Check if any have [FOUND] - any_found = False - for chan in selected_channels: - if (self.exporter.export_path / "message_backup" / f"{chan.id}.json").exists(): - any_found = True - break - - if force_overwrite is None: - force_overwrite = False - if any_found and not skip_found_prompt: - console.print("\n[bold yellow]Existing backup(s) found for some selected channels.[/bold yellow]") - console.print("(Y) Update & Sync Backup") - console.print("(F) [bold red]Force Overwrite Backup[/bold red]") - console.print("(B) Back") - - sync_choice = Prompt.ask("\nSelect option", choices=["Y", "F", "B"], default="Y").upper() - if sync_choice == "B": - return - force_overwrite = (sync_choice == "F") - - console.print(f"\n[yellow]Starting backup for {len(selected_channels)} channels...[/yellow]") - - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TextColumn("- [bold yellow]{task.fields[msg_count]} {task.fields[suffix]}"), - console=console - ) as progress: - for chan in selected_channels: - # Determine if it's a sync or fresh backup - 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" - suffix = "new messages" if is_sync else "messages" - - # Create a specific task for each channel to keep it on its own line - task_id = progress.add_task(f"[cyan]{label}: {chan.name}", total=None, msg_count=0, suffix=suffix) - - async def update_msg_count(name, count, tid=task_id): - progress.update(tid, msg_count=count) - - 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) - - # Mark as finished (stop spinner) - progress.stop_task(task_id) - progress.update(task_id, description=f"[green]Completed: {chan.name}") - - console.print("[bold green]Message backup complete![/bold green]") - # Update last_backup in server_profile.json - await self.exporter.export_metadata() - except Exception as e: - console.print(f"[bold red]Message backup failed: {e}[/bold red]") - finally: - await self.engine.close_connections() - - async def sync_backup(self): - """Option 3: Update & Sync Backup (Automated incremental sync for existing backups).""" - console.print("[yellow]Syncing backup... (Updating existing data)[/yellow]") - await self.backup_server_profile() - # Automatically select and sync existing backups - await self.backup_messages(auto_sync_existing=True) - - 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_disco_reaper(config_path="config.yaml"): - app = DiscoReaperCLI(config_path) - await app.run() diff --git a/src/ui/shuttle_screen.py b/src/ui/shuttle_screen.py new file mode 100644 index 0000000..52ef9a7 --- /dev/null +++ b/src/ui/shuttle_screen.py @@ -0,0 +1,1157 @@ +""" +Shuttle Screen – native Textual TUI for direct server-to-server migration. +Ports every operation from the old Rich CLI (MigrationCLI) into Textual +Screens, Modals, and Workers. +""" + +import sys +import asyncio +import discord +import logging +import re +import time +import aiohttp +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal, Vertical, VerticalScroll +from textual.widgets import ( + Header, Footer, Button, Static, Label, Input, + Checkbox, RadioButton, ProgressBar, RichLog, Rule, + ListItem, ListView, RadioSet, +) +from textual.screen import Screen, ModalScreen +from textual import work +from textual.worker import Worker, WorkerState + +from src.core.configuration import load_config, save_config +from src.core.base import MigrationContext +from src.core.audit import log_audit_event + +import src.fluxer.roles_permissions as fluxer_roles +import src.stoat.roles_permissions as stoat_roles +import src.fluxer.emoji_stickers as fluxer_emoji_stickers +import src.stoat.emoji_stickers as stoat_emoji_stickers +import src.fluxer.server_metadata as fluxer_metadata +import src.stoat.server_metadata as stoat_metadata +import src.fluxer.migrate_message as fluxer_migrate +import src.stoat.migrate_message as stoat_migrate + + +# --------------------------------------------------------------------------- +# Rate-limit handler (global, shared with logging subsystem) +# --------------------------------------------------------------------------- +global_rate_limit_msg = "" +global_rate_limit_expires = 0.0 + + +class RateLimitHandler(logging.Handler): + """Intercepts library logs to capture rate-limit messages.""" + + def __init__(self): + super().__init__() + + def emit(self, record): + try: + msg = record.getMessage() + if "retry" in msg.lower() and ("rate limit" in msg.lower() or "429" in msg): + match = re.search(r"in ([\d.]+)\s*(?:seconds?|s)", msg, re.IGNORECASE) + if match: + seconds = match.group(1) + platform = "API" + if "discord" in record.name.lower(): + platform = "Discord" + elif "fluxer" in record.name.lower(): + platform = "Fluxer" + elif "stoat" in record.name.lower(): + platform = "Stoat" + global global_rate_limit_msg, global_rate_limit_expires + global_rate_limit_msg = f"{platform} rate limit {seconds}s" + try: + global_rate_limit_expires = time.time() + float(seconds) + except ValueError: + pass + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Shared modals +# --------------------------------------------------------------------------- + +class ProgressModal(ModalScreen[None]): + """Modal to display progress for any long-running operation.""" + + def compose(self) -> ComposeResult: + with Vertical(id="progress_dialog"): + yield Label("Operation Status", id="progress_status") + yield ProgressBar(total=None, show_eta=False, id="progress_bar") + yield RichLog(id="progress_log", highlight=True, markup=True) + yield Button("Close", id="btn_close_progress", disabled=True) + + def on_button_pressed(self, event: Button.Pressed): + if event.button.id == "btn_close_progress": + self.dismiss(None) + + def write(self, message: str): + self.query_one("#progress_log", RichLog).write(message) + + def set_status(self, status: str): + self.query_one("#progress_status", Label).update(status) + + def set_progress(self, current: int, total: int): + bar = self.query_one("#progress_bar", ProgressBar) + bar.update(total=total, progress=current) + + def allow_close(self): + btn = self.query_one("#btn_close_progress", Button) + btn.disabled = False + btn.variant = "success" + self.query_one("#progress_bar", ProgressBar).update(total=100, progress=100) + + +class ShuttleConfigModal(ModalScreen[dict]): + """Modal for editing all shuttle-mode tokens & IDs.""" + + def __init__(self, config, target_platform: str): + super().__init__() + self.config = config + self.target_platform = target_platform + + def compose(self) -> ComposeResult: + with Vertical(id="shuttle_config_dialog"): + yield Label("Shuttle Configuration", id="config_title") + yield Label("Discord Bot Token:") + yield Input(value=self.config.discord_bot_token or "", id="inp_d_token") + yield Label("Discord Server ID:") + yield Input(value=self.config.discord_server_id or "", id="inp_d_server") + + plat_label = "Fluxer" if self.target_platform == "fluxer" else "Stoat" + yield Label(f"{plat_label} Bot Token:") + yield Input(value=self.config.target_bot_token or "", id="inp_t_token") + yield Label(f"{plat_label} Server/Community ID:") + yield Input(value=self.config.target_server_id or "", id="inp_t_server") + + with Horizontal(id="config_buttons"): + yield Button("Save", variant="success", id="btn_save") + yield Button("Cancel", variant="primary", id="btn_cancel") + + def on_button_pressed(self, event: Button.Pressed): + if event.button.id == "btn_save": + self.dismiss({ + "d_token": self.query_one("#inp_d_token", Input).value, + "d_server": self.query_one("#inp_d_server", Input).value, + "t_token": self.query_one("#inp_t_token", Input).value, + "t_server": self.query_one("#inp_t_server", Input).value, + }) + elif event.button.id == "btn_cancel": + self.dismiss(None) + + +class PlatformSelectModal(ModalScreen[str]): + """Modal for selecting target platform (fluxer / stoat).""" + + def compose(self) -> ComposeResult: + with Vertical(id="platform_select_dialog"): + yield Label("Select Target Platform", id="platform_title") + yield Button("Fluxer", variant="primary", id="btn_fluxer") + yield Button("Stoat", variant="warning", id="btn_stoat") + yield Rule() + yield Button("Cancel", id="btn_cancel_platform") + + def on_button_pressed(self, event: Button.Pressed): + if event.button.id == "btn_fluxer": + self.dismiss("fluxer") + elif event.button.id == "btn_stoat": + self.dismiss("stoat") + elif event.button.id == "btn_cancel_platform": + self.dismiss(None) + + +class SubMenuModal(ModalScreen[str]): + """A generic sub-menu modal that presents a list of labelled buttons.""" + + def __init__(self, title: str, options: list[tuple[str, str, str]]): + """options: list of (button_id, label, variant)""" + super().__init__() + self._title = title + self._options = options + + def compose(self) -> ComposeResult: + with Vertical(id="submenu_dialog"): + yield Label(self._title, id="submenu_title") + for btn_id, label, variant in self._options: + yield Button(label, id=btn_id, variant=variant) + yield Rule() + yield Button("Cancel", id="btn_cancel_sub") + + def on_button_pressed(self, event: Button.Pressed): + if event.button.id == "btn_cancel_sub": + self.dismiss(None) + else: + self.dismiss(event.button.id) + + +class ConfirmModal(ModalScreen[bool]): + """Simple Yes / No confirmation modal.""" + + def __init__(self, message: str, danger: bool = False): + super().__init__() + self._message = message + self._danger = danger + + def compose(self) -> ComposeResult: + with Vertical(id="confirm_dialog"): + yield Label(self._message, id="confirm_msg") + with Horizontal(id="confirm_buttons"): + yield Button("Confirm", variant="error" if self._danger else "success", id="btn_yes") + yield Button("Cancel", variant="primary", id="btn_no") + + def on_button_pressed(self, event: Button.Pressed): + self.dismiss(event.button.id == "btn_yes") + + +class ChannelPickerModal(ModalScreen[int]): + """Modal listing Discord channels for single-channel selection.""" + + def __init__(self, channels: list, categories: dict, label: str = "Select Channel"): + super().__init__() + self._channels = channels + self._categories = categories + self._label = label + + def compose(self) -> ComposeResult: + with Vertical(id="chanpick_dialog"): + yield Label(self._label, id="chanpick_title") + with VerticalScroll(id="chanpick_scroll"): + cat_grouped: dict[int | None, list] = {} + for c in self._channels: + cat_id = getattr(c, "category_id", None) if not isinstance(c, dict) else c.get("parent_id") + cat_grouped.setdefault(cat_id, []).append(c) + + for cat_id in sorted(cat_grouped, key=lambda k: self._categories.get(k, "") if k else ""): + if cat_id is not None and cat_id in self._categories: + yield Label(f"[cyan]{self._categories[cat_id]}[/cyan]", classes="category_header") + for c in cat_grouped[cat_id]: + if isinstance(c, dict): + name = c.get("name", "Unnamed") + cid = c.get("id") + else: + name = c.name + cid = c.id + yield RadioButton(name, value=False, id=f"chpk_{cid}") + + with Horizontal(id="chanpick_buttons"): + yield Button("Select", variant="success", id="btn_pick_ok") + yield Button("Cancel", id="btn_pick_cancel") + + def on_button_pressed(self, event: Button.Pressed): + if event.button.id == "btn_pick_cancel": + self.dismiss(None) + elif event.button.id == "btn_pick_ok": + for rb in self.query(RadioButton): + if rb.value and rb.id and rb.id.startswith("chpk_"): + self.dismiss(int(rb.id.split("_", 1)[1])) + return + # Nothing selected + return + + +# --------------------------------------------------------------------------- +# ShuttleScreen — main screen for Shuttle Mode +# --------------------------------------------------------------------------- + +class ShuttleScreen(Screen): + """Native Textual screen for Shuttle (direct migration) mode.""" + + CSS = """ + #shuttle_scroll { + align: center middle; + } + + #shuttle_container { + width: 80%; + height: auto; + min-height: 25; + border: solid #4641D9; + padding: 1 2; + margin: 2 0; + } + #shuttle_title { + text-style: bold; + color: #4641D9; + margin-bottom: 1; + content-align: center middle; + width: 100%; + } + #shuttle_info { + height: auto; + margin-bottom: 2; + border: tall cyan; + padding: 1; + } + #shuttle_actions { + height: auto; + layout: vertical; + align: center top; + margin-top: 1; + } + #shuttle_actions Button { + width: 100%; + margin-bottom: 1; + } + /* Modals */ + #shuttle_config_dialog, #platform_select_dialog, #submenu_dialog, #confirm_dialog { + width: 60%; + height: auto; + max-height: 80%; + border: thick $background 80%; + background: $surface; + padding: 1 2; + } + #progress_dialog { + width: 80%; + height: 80%; + border: thick $background 80%; + background: $surface; + padding: 1 2; + } + #chanpick_dialog { + width: 70%; + height: 75%; + border: thick $background 80%; + background: $surface; + padding: 1 2; + } + #chanpick_scroll { + height: 1fr; + border: solid $primary; + margin-bottom: 1; + padding: 0 1; + } + .category_header { + margin-top: 1; + background: $primary 10%; + text-style: bold; + padding-left: 1; + } + #config_title, #platform_title, #submenu_title, #confirm_msg, #chanpick_title { + text-style: bold; margin-bottom: 1; + } + #config_buttons, #confirm_buttons, #chanpick_buttons { + height: auto; margin-top: 1; + } + #config_buttons Button, #confirm_buttons Button, #chanpick_buttons Button { + width: 1fr; margin: 0 1; + } + #progress_status { text-style: bold; margin-bottom: 1; } + #progress_bar { margin-bottom: 1; } + #progress_log { height: 1fr; margin-bottom: 1; border: solid $primary; } + RadioButton:focus { background: transparent; border: none; } + RadioButton > .radio-button--label { padding: 0 1; } + RadioButton:focus > .radio-button--label { background: transparent; text-style: none; } + """ + + BINDINGS = [ + ("q", "app.exit", "Quit"), + ("b", "go_back", "Back"), + ] + + def __init__(self, cfg_name: str, cfg_path: Path, *args, **kwargs): + super().__init__(*args, **kwargs) + self.cfg_name = cfg_name + self.config_path = cfg_path + self.config = load_config(self.config_path) + self.target_platform: str | None = None + self.engine: MigrationContext | None = None + self.validation_results: dict = {} + self.tokens_valid = False + self.permissions_complete = False + + # Register rate-limit handler + rl = RateLimitHandler() + logging.getLogger("discord").addHandler(rl) + logging.getLogger("fluxer").addHandler(rl) + logging.getLogger("stoat").addHandler(rl) + + # ── helpers ────────────────────────────────────────────────────────── + + def _base_dir(self) -> str: + return f"Reaper-{self.cfg_name}" + + def _rebuild_engine(self): + self.engine = MigrationContext(self.config, self.target_platform) + + def _update_info_labels(self): + d_name = self.validation_results.get("discord_server_name") + d_disp = f"[green]\"{d_name}\"[/green]" if d_name else "[red]NOT SET UP[/red]" + self.query_one("#lbl_discord", Label).update(f"Discord: {d_disp}") + + if self.target_platform == "fluxer": + t_name = self.validation_results.get("target_community_name") + t_disp = f"[green]\"{t_name}\"[/green]" if t_name else "[red]NOT SET UP[/red]" + self.query_one("#lbl_target", Label).update(f"Fluxer: {t_disp}") + else: + t_name = self.validation_results.get("target_community_name") + t_disp = f"[green]\"{t_name}\"[/green]" if t_name else "[red]NOT SET UP[/red]" + self.query_one("#lbl_target", Label).update(f"Stoat: {t_disp}") + + if not self.tokens_valid: + val = "[red][INVALID][/red]" + elif not self.permissions_complete: + val = "[yellow][PERMISSION MISSING][/yellow]" + else: + val = "[green][VALID][/green]" + self.query_one("#lbl_status", Label).update(f"Status: {val}") + + enabled = self.tokens_valid + for bid in ("#btn_clone", "#btn_roles", "#btn_emojis", "#btn_metadata", "#btn_messages", "#btn_danger"): + self.query_one(bid, Button).disabled = not enabled + + # ── compose ────────────────────────────────────────────────────────── + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with VerticalScroll(id="shuttle_scroll"): + with Container(id="shuttle_container"): + yield Label("Shuttle Mode", id="shuttle_title") + with Vertical(id="shuttle_info"): + yield Label("Discord: [yellow]Loading...[/yellow]", id="lbl_discord") + yield Label("Target: [yellow]Loading...[/yellow]", id="lbl_target") + yield Label("Status: [yellow]Validating...[/yellow]", id="lbl_status") + with Vertical(id="shuttle_actions"): + yield Button("Clone Server Template", id="btn_clone", disabled=True) + yield Button("Copy Roles & Permissions", id="btn_roles", disabled=True) + yield Button("Copy Emojis & Stickers", id="btn_emojis", disabled=True) + yield Button("Sync Server Profile", id="btn_metadata", disabled=True) + yield Button("Migrate Message History", id="btn_messages", disabled=True) + yield Rule() + yield Button("Configuration", id="btn_config") + yield Button("Danger Zone ⚠", id="btn_danger", variant="error", disabled=True) + yield Rule() + yield Button("Back", id="btn_back") + yield Footer() + + # ── lifecycle ──────────────────────────────────────────────────────── + + def on_mount(self) -> None: + # Platform is already configured in config.yaml via ConfigScreen + self.target_platform = self.config.target_platform or "fluxer" + self._rebuild_engine() + self.run_validate() + + def action_go_back(self): + self.app.pop_screen() + + # ── button routing ─────────────────────────────────────────────────── + + def on_button_pressed(self, event: Button.Pressed): + bid = event.button.id + if bid == "btn_back": + self.app.pop_screen() + elif bid == "btn_config": + self._open_config() + elif bid == "btn_clone": + self.run_clone_template() + elif bid == "btn_roles": + self._open_roles_menu() + elif bid == "btn_emojis": + self._open_emoji_menu() + elif bid == "btn_metadata": + self._open_metadata_menu() + elif bid == "btn_messages": + self.run_migrate_messages() + elif bid == "btn_danger": + self._open_danger_menu() + + # ── (0) validation ─────────────────────────────────────────────────── + + @work(exclusive=True) + async def run_validate(self) -> None: + self.validation_results = { + "discord_token": False, "discord_bot_name": None, + "discord_server": False, "discord_server_name": None, + "discord_intents": {}, "discord_permissions": {}, + "target_token": False, "target_bot_name": None, + "target_community": False, "target_community_name": None, + "target_permissions": {}, + "discord_timeout": False, "target_timeout": False, + } + self.tokens_valid = False + self.permissions_complete = False + + fillers = [ + "DISCORD_BOT_TOKEN", "FLUXER_BOT_TOKEN", "STOAT_BOT_TOKEN", + "TARGET_BOT_TOKEN", + "000000000000000000", "DISCORD_SERVER_ID", "FLUXER_COMMUNITY_ID", + "STOAT_SERVER_ID", "TARGET_SERVER_ID", "", None, + ] + d_dummy = self.config.discord_bot_token in fillers or self.config.discord_server_id in fillers + t_dummy = (self.config.target_bot_token or "") in fillers or (self.config.target_server_id or "") in fillers + + tasks = {} + if not d_dummy: + tasks["discord"] = asyncio.create_task(self.engine.discord_reader.validate()) + if not t_dummy: + tasks["target"] = asyncio.create_task(self.engine.writer.validate()) + + all_tasks = list(tasks.values()) + try: + done = set() + if all_tasks: + done, _ = await asyncio.wait(all_tasks, timeout=10.0) + + # Discord + dt = tasks.get("discord") + if dt and dt in done: + res = dt.result() + 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") + self.validation_results["discord_intents"] = res.get("intents", {}) + self.validation_results["discord_permissions"] = res.get("permissions", {}) + elif dt and dt not in done: + self.validation_results["discord_timeout"] = True + dt.cancel() + + # Target platform + tt = tasks.get("target") + if tt and tt in done: + res = tt.result() + self.validation_results["target_token"] = res.get("token", False) + self.validation_results["target_bot_name"] = res.get("bot_name") + self.validation_results["target_community"] = res.get("community", False) + self.validation_results["target_community_name"] = res.get("community_name") + self.validation_results["target_permissions"] = res.get("permissions", {}) + elif tt and tt not in done: + self.validation_results["target_timeout"] = True + tt.cancel() + + # Compute validity + discord_ok = self.validation_results.get("discord_token") and self.validation_results.get("discord_server") + target_ok = self.validation_results.get("target_token") and self.validation_results.get("target_community") + self.tokens_valid = bool(discord_ok and target_ok) + + # Set state folder + if self.tokens_valid: + srv_id = self.config.target_server_id + srv_name = self.validation_results.get("target_community_name", "unknown") + if srv_id and srv_name: + safe = re.sub(r"[^a-zA-Z0-9_\-\.]", "_", srv_name) + self.engine.state.set_folder(str(srv_id), safe, base_dir=self._base_dir()) + + # Check permissions + self.permissions_complete = True + if self.tokens_valid: + di = self.validation_results.get("discord_intents", {}) + dp = self.validation_results.get("discord_permissions", {}) + if not all([di.get("message_content"), dp.get("view_channel"), dp.get("read_message_history")]): + self.permissions_complete = False + tp = self.validation_results.get("target_permissions", {}) + if tp and not all(tp.values()): + self.permissions_complete = False + except Exception: + pass + finally: + for t in all_tasks: + if not t.done(): + t.cancel() + + self._update_info_labels() + + # ── (1) clone server template ──────────────────────────────────────── + + @work(exclusive=True) + async def run_clone_template(self) -> None: + if self.target_platform == "fluxer": + from src.fluxer.clone_server import sync_channel_state, migrate_channels + else: + from src.stoat.clone_server import sync_channel_state, migrate_channels + + modal = ProgressModal() + self.app.push_screen(modal) + await asyncio.sleep(0.1) + + try: + modal.set_status("Fetching server structure...") + await self.engine.start_connections() + await sync_channel_state(self.engine) + categories = await self.engine.discord_reader.get_categories() + channels = await self.engine.discord_reader.get_channels() + + cached = sum(1 for c in categories if self.engine.state.get_fluxer_category_id(str(c.id))) + cached += sum(1 for c in channels if self.engine.state.get_fluxer_channel_id(str(c.id))) + total = len(categories) + len(channels) + + modal.write(f"[yellow]Found {total} items, {cached} already cloned.[/yellow]") + modal.set_status("Cloning channels...") + + async def update_progress(item_name, status, current, total): + color = "cyan" if status == "Copying" else "yellow" + modal.set_status(f"[{color}]{status}: {item_name}[/{color}]") + modal.set_progress(current, total) + + self.engine.is_running = True + cloned_info = await migrate_channels(self.engine, progress_callback=update_progress, force=False) + + modal.write("[bold green]Server Template cloned![/bold green]") + if cloned_info and cloned_info.get("structure"): + lines = ["Successfully cloned channels and categories from Discord:"] + cats = sorted(cloned_info["structure"].keys(), key=lambda x: (x == "No Category", x)) + for cat_name in cats: + ch_names = cloned_info["structure"][cat_name] + if cat_name in cloned_info.get("categories_created", []) or ch_names: + lines.append(f"- **{cat_name}**") + for n in sorted(ch_names): + lines.append(f" - {n}") + await log_audit_event(self.engine, "Server Template Cloned", "\n".join(lines)) + else: + await log_audit_event(self.engine, "Server Template Cloned", "No new items were cloned.") + except Exception as e: + modal.write(f"[bold red]Error: {e}[/bold red]") + finally: + self.engine.is_running = False + await self.engine.close_connections() + modal.set_status("Finished.") + modal.allow_close() + + # ── (2) roles & permissions ────────────────────────────────────────── + + def _open_roles_menu(self): + options = [ + ("sub_clone_roles", "Clone Roles & Role Permissions", "primary"), + ("sub_sync_perms", "Sync Channel & Category Permissions", "warning"), + ] + def on_result(choice): + if choice == "sub_clone_roles": + self.run_clone_roles() + elif choice == "sub_sync_perms": + self.run_sync_permissions() + self.app.push_screen(SubMenuModal("Roles & Permissions", options), on_result) + + @work(exclusive=True) + async def run_clone_roles(self) -> None: + roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles + modal = ProgressModal() + self.app.push_screen(modal) + await asyncio.sleep(0.1) + + try: + modal.set_status("Checking existing roles...") + await self.engine.start_connections() + await roles_mod.sync_roles_state(self.engine) + roles = await self.engine.discord_reader.get_roles() + + cached = sum(1 for r in roles if self.engine.state.get_target_role_id(str(r.id))) + modal.write(f"[yellow]Found {len(roles)} roles, {cached} already cloned.[/yellow]") + modal.set_status("Cloning roles...") + + async def update(name, current, total): + modal.set_status(f"[cyan]Copying: {name}[/cyan]") + modal.set_progress(current, total) + + self.engine.is_running = True + cloned = await roles_mod.migrate_roles(self.engine, progress_callback=update, force=False) + + modal.write("[bold green]Role migration complete![/bold green]") + if cloned: + desc = "Successfully cloned roles:\n" + "\n".join(f"- {r}" for r in cloned) + await log_audit_event(self.engine, "Roles Cloned", desc) + else: + await log_audit_event(self.engine, "Roles Cloned", "No new roles were cloned.") + except Exception as e: + modal.write(f"[bold red]Error: {e}[/bold red]") + finally: + self.engine.is_running = False + await self.engine.close_connections() + modal.set_status("Finished.") + modal.allow_close() + + @work(exclusive=True) + async def run_sync_permissions(self) -> None: + roles_mod = fluxer_roles if self.target_platform == "fluxer" else stoat_roles + modal = ProgressModal() + self.app.push_screen(modal) + await asyncio.sleep(0.1) + + try: + modal.set_status("Syncing permissions...") + await self.engine.start_connections() + + async def update(name, current, total): + modal.set_status(f"[cyan]Syncing: {name}[/cyan]") + modal.set_progress(current, total) + + self.engine.is_running = True + synced = await roles_mod.sync_permissions(self.engine, progress_callback=update) + + modal.write("[bold green]Permission sync complete![/bold green]") + if synced and synced.get("structure"): + lines = ["Synchronized permission overrides:"] + cats = sorted(synced["structure"].keys(), key=lambda x: (x == "No Category", x)) + for cat_name in cats: + ch_names = synced["structure"][cat_name] + if cat_name in synced.get("categories_synced", []) or ch_names: + lines.append(f"- **{cat_name}**") + for n in sorted(ch_names): + lines.append(f" - {n}") + await log_audit_event(self.engine, "Permissions Synced", "\n".join(lines)) + else: + await log_audit_event(self.engine, "Permissions Synced", "No permissions synchronized.") + except Exception as e: + modal.write(f"[bold red]Error: {e}[/bold red]") + finally: + self.engine.is_running = False + await self.engine.close_connections() + modal.set_status("Finished.") + modal.allow_close() + + # ── (3) emojis & stickers ──────────────────────────────────────────── + + def _open_emoji_menu(self): + options = [ + ("sub_emoji", "Sync Emojis only", "primary"), + ("sub_sticker", "Sync Stickers only", "primary"), + ("sub_both", "Sync Emojis & Stickers", "success"), + ] + def on_result(choice): + types = [] + if choice == "sub_emoji": + types = ["Emoji"] + elif choice == "sub_sticker": + types = ["Sticker"] + elif choice == "sub_both": + types = ["Emoji", "Sticker"] + if types: + self.run_copy_emojis(types) + self.app.push_screen(SubMenuModal("Emojis & Stickers", options), on_result) + + @work(exclusive=True) + async def run_copy_emojis(self, types_to_include: list[str]) -> None: + asset_mod = stoat_emoji_stickers if self.target_platform == "stoat" else fluxer_emoji_stickers + modal = ProgressModal() + self.app.push_screen(modal) + await asyncio.sleep(0.1) + + try: + modal.set_status("Checking existing assets...") + await self.engine.start_connections() + await asset_mod.sync_assets_state(self.engine) + + modal.set_status("Copying assets...") + + async def update(name, item_type, current, total): + modal.set_status(f"[cyan]Copying {item_type}: {name}[/cyan]") + modal.set_progress(current, total) + + self.engine.is_running = True + cloned = await asset_mod.migrate_emojis( + self.engine, + progress_callback=update, + types_to_include=types_to_include, + force=False, + ) + + modal.write("[bold green]Asset migration complete![/bold green]") + if cloned and (cloned.get("Emoji") or cloned.get("Sticker")): + lines = [] + if cloned.get("Emoji"): + lines.append("Emojis cloned:") + for n in cloned["Emoji"]: + lines.append(f"- {n}") + if cloned.get("Sticker"): + lines.append("Stickers cloned:") + for n in cloned["Sticker"]: + lines.append(f"- {n}") + await log_audit_event(self.engine, "Emojis & Stickers Cloned", "\n".join(lines)) + else: + await log_audit_event(self.engine, "Emojis & Stickers Cloned", "No new assets cloned.") + except Exception as e: + modal.write(f"[bold red]Error: {e}[/bold red]") + finally: + self.engine.is_running = False + await self.engine.close_connections() + modal.set_status("Finished.") + modal.allow_close() + + # ── (4) server metadata sync ───────────────────────────────────────── + + def _open_metadata_menu(self): + options = [ + ("sub_name", "Sync Name only", "primary"), + ("sub_icon", "Sync Icon only", "primary"), + ("sub_banner", "Sync Banner only", "primary"), + ("sub_all_meta", "Sync Everything", "success"), + ] + def on_result(choice): + comps = [] + if choice == "sub_name": + comps = ["name"] + elif choice == "sub_icon": + comps = ["icon"] + elif choice == "sub_banner": + comps = ["banner"] + elif choice == "sub_all_meta": + comps = ["name", "icon", "banner"] + if comps: + self.run_sync_metadata(comps) + self.app.push_screen(SubMenuModal("Sync Server Profile", options), on_result) + + @work(exclusive=True) + async def run_sync_metadata(self, components: list[str]) -> None: + meta_mod = fluxer_metadata if self.target_platform == "fluxer" else stoat_metadata + modal = ProgressModal() + self.app.push_screen(modal) + await asyncio.sleep(0.1) + + try: + modal.set_status("Syncing server metadata...") + await self.engine.start_connections() + + async def progress_cb(item, status): + color = "green" if status == "DONE" else "red" if status == "ERROR" else "yellow" + modal.write(f"{item} [[bold {color}]{status}[/bold {color}]]") + + cloned = await meta_mod.sync_server_metadata(self.engine, progress_cb, components=components) + + modal.write("[bold green]Server profile sync finished![/bold green]") + + lines = ["Synchronized Community profile:"] + if "name" in cloned: + lines.append(f"- **Name**: {cloned['name']}") + if "icon" in cloned: + lines.append("- **Icon**") + if "banner" in cloned: + lines.append("- **Banner**") + files = [] + if "icon" in cloned: + ext = "gif" if cloned["icon"].startswith(b"GIF") else "png" + files.append({"filename": f"icon.{ext}", "data": cloned["icon"]}) + if "banner" in cloned: + ext = "gif" if cloned["banner"].startswith(b"GIF") else "png" + files.append({"filename": f"banner.{ext}", "data": cloned["banner"]}) + if cloned: + await log_audit_event(self.engine, "Server Profile Synced", "\n".join(lines), files=files) + else: + await log_audit_event(self.engine, "Server Profile Synced", "Nothing synchronized.") + except Exception as e: + modal.write(f"[bold red]Error: {e}[/bold red]") + finally: + await self.engine.close_connections() + modal.set_status("Finished.") + modal.allow_close() + + # ── (5) message migration ──────────────────────────────────────────── + + @work(exclusive=True) + async def run_migrate_messages(self) -> None: + if not self.tokens_valid: + return + + migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate + platform_name = self.target_platform.capitalize() + + modal = ProgressModal() + self.app.push_screen(modal) + await asyncio.sleep(0.1) + + try: + modal.set_status("Fetching channels...") + await self.engine.start_connections() + + full_d = await self.engine.discord_reader.get_channels() + d_channels = [c for c in full_d if c.type in [discord.ChannelType.text, discord.ChannelType.news]] + d_cats = await self.engine.discord_reader.get_categories() + d_cat_map = {c.id: c.name for c in d_cats} + + if not d_channels: + modal.write("[yellow]No text channels found.[/yellow]") + modal.allow_close() + return + + # Pick source channel via modal + self.app.pop_screen() # pop progress temporarily + + loop = asyncio.get_running_loop() + src_future = loop.create_future() + + def on_src(cid): + if not src_future.done(): + src_future.set_result(cid) + + self.app.push_screen(ChannelPickerModal(d_channels, d_cat_map, "Select Source Discord Channel"), on_src) + src_id = await src_future + + if src_id is None: + await self.engine.close_connections() + return + + source_channel = next(c for c in d_channels if c.id == src_id) + + # Pick target channel + modal2_status = ProgressModal() + self.app.push_screen(modal2_status) + await asyncio.sleep(0.1) + modal2_status.set_status(f"Fetching {platform_name} channels...") + + full_f = await self.engine.writer.get_channels() + f_channels = [c for c in full_f if c.get("name") not in ["reaper_logs", "reaper-logs"] and c.get("type") not in [2, 4]] + + if not f_channels: + modal2_status.write(f"[yellow]No channels found in {platform_name} community.[/yellow]") + modal2_status.allow_close() + await self.engine.close_connections() + return + + # Auto-match + mapped_id = self.engine.state.get_fluxer_channel_id(str(source_channel.id)) + recommended = None + if mapped_id: + recommended = next((c for c in f_channels if str(c.get("id", "")) == mapped_id), None) + if not recommended: + recommended = next((c for c in f_channels if c.get("name") == source_channel.name), None) + + self.app.pop_screen() # pop status + + target_cat_names = {str(c.get("id")): c.get("name") for c in full_f if c.get("type") == 4} + + tgt_future = loop.create_future() + + def on_tgt(cid): + if not tgt_future.done(): + tgt_future.set_result(cid) + + self.app.push_screen(ChannelPickerModal(f_channels, target_cat_names, f"Select Target {platform_name} Channel"), on_tgt) + tgt_id = await tgt_future + + if tgt_id is None: + await self.engine.close_connections() + return + + target_channel = next(c for c in f_channels if c.get("id") == tgt_id) + + # Determine after_id + after_id = None + last_migrated = self.engine.state.get_last_message_id(str(source_channel.id)) + if last_migrated: + after_id = int(last_migrated) + + # Analyze + modal = ProgressModal() + self.app.push_screen(modal) + await asyncio.sleep(0.1) + modal.set_status("Analyzing channel...") + + self.engine.is_running = True + stats = {"messages": 0, "threads": 0, "attachments": 0} + + async def update_scan(count): + modal.set_status(f"[cyan]Scanned {count} items...") + + stats = await migrate_mod.analyze_migration( + self.engine, + source_channel_id=source_channel.id, + after_message_id=after_id, + progress_callback=update_scan, + ) + self.engine.is_running = False + + modal.write(f"[cyan]Messages: {stats['messages']}, Threads: {stats['threads']}, Attachments: {stats['attachments']}[/cyan]") + modal.write(f"Migrating Discord [cyan]#{source_channel.name}[/cyan] → {platform_name} [green]#{target_channel.get('name')}[/green]") + modal.set_status("Migrating messages...") + + total_messages = stats["messages"] + self.engine.is_running = True + + async def update_msg(count): + modal.set_status(f"[cyan]Migrated {count}/{total_messages} messages...") + modal.set_progress(count, total_messages) + + result = await migrate_mod.migrate_messages( + self.engine, + source_channel_id=source_channel.id, + target_channel_id=target_channel.get("id"), + after_message_id=after_id, + progress_callback=update_msg, + ) + + if self.engine.is_running: + modal.write(f"[bold green]Success! {result['messages']} messages migrated.[/bold green]") + event_title = "Message History Migrated" + else: + modal.write(f"[bold yellow]Interrupted! {result['messages']} messages migrated.[/bold yellow]") + event_title = "Message History Migration Interrupted" + + lines = [f"Migrated Discord #{source_channel.name} → {platform_name} #{target_channel.get('name')}:"] + lines.append(f"{result['messages']} messages, {result['attachments']} attachments, {result['threads']} threads") + await log_audit_event(self.engine, event_title, "\n".join(lines)) + + except Exception as e: + err = str(e) + if "MissingPermission" in err and "Masquerade" in err: + modal.write("[bold red]Bot is missing the 'Masquerade' permission.[/bold red]") + else: + modal.write(f"[bold red]Error: {err}[/bold red]") + finally: + self.engine.is_running = False + await self.engine.close_connections() + modal.set_status("Finished.") + modal.allow_close() + + # ── (6) configuration ──────────────────────────────────────────────── + + def _open_config(self): + def on_result(data: dict | None): + if data is None: + return + self.config.discord_bot_token = data["d_token"] or self.config.discord_bot_token + self.config.discord_server_id = data["d_server"] or self.config.discord_server_id + self.config.target_bot_token = data["t_token"] or self.config.target_bot_token + self.config.target_server_id = data["t_server"] or self.config.target_server_id + save_config(self.config, self.config_path) + self._rebuild_engine() + self.run_validate() + self.app.push_screen(ShuttleConfigModal(self.config, self.target_platform), on_result) + + # ── (7) danger zone ────────────────────────────────────────────────── + + 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"), + ] + 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 _confirm_danger(self, message: str, callback): + def on_confirm(confirmed: bool): + if confirmed: + callback() + self.app.push_screen(ConfirmModal(message, danger=True), on_confirm) + + @work(exclusive=True) + async def run_dz_delete_channels(self) -> 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 = ProgressModal() + self.app.push_screen(modal) + await asyncio.sleep(0.1) + + try: + modal.set_status("[red]Deleting channels...") + await self.engine.start_target_only() + + 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.write(f"[bold green]{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]") + finally: + await self.engine.close_target_only() + modal.set_status("Finished.") + modal.allow_close() + + @work(exclusive=True) + async def run_dz_reset_perms(self) -> 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 = ProgressModal() + self.app.push_screen(modal) + await asyncio.sleep(0.1) + + try: + modal.set_status("[red]Resetting permissions...") + await self.engine.start_target_only() + + 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.write(f"[bold green]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]") + finally: + await self.engine.close_target_only() + modal.set_status("Finished.") + modal.allow_close() + + @work(exclusive=True) + async def run_dz_delete_roles(self) -> 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 = ProgressModal() + self.app.push_screen(modal) + await asyncio.sleep(0.1) + + try: + modal.set_status("[red]Deleting roles...") + await self.engine.start_target_only() + + 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.write(f"[bold green]{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]") + finally: + await self.engine.close_target_only() + modal.set_status("Finished.") + modal.allow_close() + + @work(exclusive=True) + async def run_dz_delete_assets(self) -> 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 = ProgressModal() + self.app.push_screen(modal) + await asyncio.sleep(0.1) + + 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.write(f"[bold green]{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]") + finally: + await self.engine.close_target_only() + modal.set_status("Finished.") + modal.allow_close() diff --git a/src/ui/unifier_tui.py b/src/ui/unifier_tui.py index 2d64049..d0ec923 100644 --- a/src/ui/unifier_tui.py +++ b/src/ui/unifier_tui.py @@ -201,7 +201,7 @@ class ReaperScreen(Screen): @property def exporter(self): return self.app.exporter - @work(exclusive=True, thread=True) + @work(exclusive=True) async def validate_config(self) -> None: def update_ui(server_text, bot_text, backup_text, buttons_enabled): self.query_one("#lbl_server", Label).update(f"Source Server: {server_text}") @@ -213,17 +213,17 @@ class ReaperScreen(Screen): d_token = self.config.discord_bot_token fillers = ["DISCORD_BOT_TOKEN", "000000000000000000", "DISCORD_SERVER_ID", "", None] if d_token in fillers or self.config.discord_server_id in fillers: - self.app.call_from_thread(update_ui, "[red]NOT CONNECTED[/red]", "[red]UNKNOWN[/red]", "", False) + update_ui("[red]NOT CONNECTED[/red]", "[red]UNKNOWN[/red]", "", False) self.tokens_valid = False return - self.app.call_from_thread(update_ui, "[yellow]Validating...[/yellow]", "[yellow]Validating...[/yellow]", "", False) + update_ui("[yellow]Validating...[/yellow]", "[yellow]Validating...[/yellow]", "", False) try: res = await self.engine.discord_reader.validate() except Exception as e: res = {} - self.app.call_from_thread(update_ui, f"[red]Validation Error: {e}[/red]", "", "", False) + update_ui(f"[red]Validation Error: {e}[/red]", "", "", False) self.tokens_valid = False return @@ -246,7 +246,7 @@ class ReaperScreen(Screen): if backup_ts: backup_text = f"Backup Found: [yellow]{backup_ts}[/yellow]" - self.app.call_from_thread(update_ui, server_display, bot_display, backup_text, self.tokens_valid) + update_ui(server_display, bot_display, backup_text, self.tokens_valid) def get_backup_info(self): d_name = self.validation_results.get("discord_server_name") @@ -299,48 +299,48 @@ class ReaperScreen(Screen): elif event.button.id == "btn_backup_sync": self.run_backup_sync() - @work(exclusive=True, thread=True) + @work(exclusive=True) async def run_backup_profile(self) -> None: modal = ProgressModal() - self.app.call_from_thread(self.app.push_screen, modal) + self.app.push_screen(modal) await asyncio.sleep(0.1) try: - self.app.call_from_thread(modal.set_status, "Starting readers...") + modal.set_status("Starting readers...") await self.engine.discord_reader.start() meta = await self.exporter.setup() - self.app.call_from_thread(modal.write_to_log, "[yellow]Backing up server profile & skeleton...[/yellow]") + modal.write_to_log("[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_to_log, "Exporting structure...") + modal.write_to_log("Exporting structure...") _, cat_count, chan_count = await self.exporter.export_channels_structure() - self.app.call_from_thread(modal.write_to_log, "Exporting assets...") + modal.write_to_log("Exporting assets...") e_count, s_count = await self.exporter.export_assets() - self.app.call_from_thread(modal.write_to_log, f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]") - self.app.call_from_thread(modal.write_to_log, f"- {cat_count} categories & {chan_count} channels") - self.app.call_from_thread(modal.write_to_log, f"- {e_count} emojis, {s_count} stickers.") + modal.write_to_log(f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]") + modal.write_to_log(f"- {cat_count} categories & {chan_count} channels") + modal.write_to_log(f"- {e_count} emojis, {s_count} stickers.") except discord.Forbidden as e: - self.app.call_from_thread(modal.write_to_log, f"[bold red]Backup failed: {e}[/bold red]") + modal.write_to_log(f"[bold red]Backup failed: {e}[/bold red]") except Exception as e: - self.app.call_from_thread(modal.write_to_log, f"[bold red]Error: {e}[/bold red]") + modal.write_to_log(f"[bold red]Error: {e}[/bold red]") finally: await self.engine.close_connections() - self.app.call_from_thread(modal.set_status, "Finished.") - self.app.call_from_thread(modal.allow_close) + modal.set_status("Finished.") + modal.allow_close() - @work(exclusive=True, thread=True) + @work(exclusive=True) async def run_backup_messages(self) -> None: modal_prog = ProgressModal() - 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() @@ -355,8 +355,8 @@ class ReaperScreen(Screen): ] if not eligible_channels: - self.app.call_from_thread(modal_prog.write_to_log, "[yellow]No text/news channels found to backup.[/yellow]") - self.app.call_from_thread(modal_prog.allow_close) + modal_prog.write_to_log("[yellow]No text/news channels found to backup.[/yellow]") + modal_prog.allow_close() return any_found = False @@ -366,7 +366,7 @@ class ReaperScreen(Screen): any_found = True backed_up_ids.add(chan.id) - self.app.call_from_thread(self.app.pop_screen) + self.app.pop_screen() loop = asyncio.get_running_loop() future = loop.create_future() @@ -375,8 +375,7 @@ class ReaperScreen(Screen): if not future.done(): future.set_result(reply) - self.app.call_from_thread( - self.app.push_screen, + self.app.push_screen( ChannelSelectModal(eligible_channels, cat_map, backed_up_ids, any_found), check_channels ) @@ -390,50 +389,50 @@ class ReaperScreen(Screen): selected_channels = [c for c in eligible_channels if c.id in selected_ids] - self.app.call_from_thread(self.app.push_screen, modal_prog) + self.app.push_screen(modal_prog) await asyncio.sleep(0.1) 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_to_log, f"[yellow]Starting backup for {total_chans} channels...[/yellow]") + modal_prog.set_status("Backing up messages...") + modal_prog.write_to_log(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_to_log, f"[cyan]{label}: {chan.name}[/cyan]") + modal_prog.write_to_log(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_to_log, f"[green]Completed: {chan.name}[/green]") + modal_prog.write_to_log(f"[green]Completed: {chan.name}[/green]") await self.exporter.export_metadata() - self.app.call_from_thread(modal_prog.write_to_log, "[bold green]Message backup complete![/bold green]") + modal_prog.write_to_log("[bold green]Message backup complete![/bold green]") except Exception as e: - self.app.call_from_thread(modal_prog.write_to_log, f"[bold red]Message backup failed: {e}[/bold red]") + modal_prog.write_to_log(f"[bold red]Message backup failed: {e}[/bold red]") finally: await self.engine.close_connections() - self.app.call_from_thread(modal_prog.set_status, "Finished.") - self.app.call_from_thread(modal_prog.allow_close) + modal_prog.set_status("Finished.") + modal_prog.allow_close() - @work(exclusive=True, thread=True) + @work(exclusive=True) async def run_backup_sync(self) -> None: modal_prog = ProgressModal() - 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, "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_to_log, "Updating structure...") + modal_prog.write_to_log("Updating structure...") await self.exporter.export_metadata() await self.exporter.download_server_assets() await self.exporter.export_channels_structure() @@ -451,28 +450,28 @@ class ReaperScreen(Screen): ] if not selected_channels: - self.app.call_from_thread(modal_prog.write_to_log, "[yellow]No existing backups found to sync.[/yellow]") + modal_prog.write_to_log("[yellow]No existing backups found to sync.[/yellow]") else: - self.app.call_from_thread(modal_prog.write_to_log, f"[yellow]Syncing {len(selected_channels)} channels...[/yellow]") + modal_prog.write_to_log(f"[yellow]Syncing {len(selected_channels)} channels...[/yellow]") for chan in selected_channels: - self.app.call_from_thread(modal_prog.write_to_log, f"[cyan]Syncing: {chan.name}[/cyan]") + modal_prog.write_to_log(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_to_log, f"[green]Synced: {chan.name}[/green]") + modal_prog.write_to_log(f"[green]Synced: {chan.name}[/green]") await self.exporter.export_metadata() - self.app.call_from_thread(modal_prog.write_to_log, "[bold green]Sync operation complete![/bold green]") + modal_prog.write_to_log("[bold green]Sync operation complete![/bold green]") except Exception as e: - self.app.call_from_thread(modal_prog.write_to_log, f"[bold red]Sync failed: {e}[/bold red]") + modal_prog.write_to_log(f"[bold red]Sync failed: {e}[/bold red]") finally: await self.engine.close_connections() - self.app.call_from_thread(modal_prog.set_status, "Finished.") - self.app.call_from_thread(modal_prog.allow_close) + modal_prog.set_status("Finished.") + modal_prog.allow_close() # ------------------------------------------------------------------------- # Shuttle Screen @@ -576,7 +575,7 @@ class ShuttleScreen(Screen): is_stoat = getattr(self.config, 'use_stoat', False) return self.app.stoat_engine if is_stoat else self.app.fluxer_engine - @work(exclusive=True, thread=True) + @work(exclusive=True) async def validate_config(self) -> None: is_stoat = getattr(self.config, 'use_stoat', False) platform_name = "Stoat" if is_stoat else "Fluxer" @@ -589,7 +588,7 @@ class ShuttleScreen(Screen): for btn_id in ["#btn_clone", "#btn_roles", "#btn_emojis", "#btn_meta", "#btn_history", "#btn_danger"]: self.query_one(btn_id, Button).disabled = not buttons_enabled - self.app.call_from_thread(update_ui, "[yellow]Validating...[/yellow]", "[yellow]Validating...[/yellow]", "[yellow]Validating...[/yellow]", False) + update_ui("[yellow]Validating...[/yellow]", "[yellow]Validating...[/yellow]", "[yellow]Validating...[/yellow]", False) engine = self.engine @@ -667,10 +666,10 @@ class ShuttleScreen(Screen): else: perms_display = "[green]VALID[/green]" - self.app.call_from_thread(update_ui, t_display, d_display, perms_display, self.tokens_valid) + update_ui(t_display, d_display, perms_display, self.tokens_valid) except Exception as e: - self.app.call_from_thread(update_ui, f"[red]Validation Error: {e}[/red]", "", "", False) + update_ui(f"[red]Validation Error: {e}[/red]", "", "", False) self.tokens_valid = False def on_button_pressed(self, event: Button.Pressed) -> None: @@ -699,10 +698,10 @@ class ShuttleScreen(Screen): elif event.button.id == "btn_history": pass # Implement later - @work(exclusive=True, thread=True) + @work(exclusive=True) async def run_clone(self) -> None: modal_prog = ProgressModal() - self.app.call_from_thread(self.app.push_screen, modal_prog) + self.app.push_screen(modal_prog) await asyncio.sleep(0.1) is_stoat = getattr(self.config, 'use_stoat', False) @@ -712,25 +711,23 @@ class ShuttleScreen(Screen): from src.stoat.clone_server import sync_channel_state, migrate_channels try: - self.app.call_from_thread(modal_prog.set_status, "Fetching structure...") + modal_prog.set_status("Fetching structure...") await self.engine.start_connections() - self.app.call_from_thread(modal_prog.write_to_log, "Syncing channel state...") + modal_prog.write_to_log("Syncing channel state...") await sync_channel_state(self.engine) categories = await self.engine.discord_reader.get_categories() channels = await self.engine.discord_reader.get_channels() async def update_progress(item_name: str, status: str, current: int, total: int): - self.app.call_from_thread(modal_prog.set_status, f"{status} Channel: {item_name} ({current}/{total})") + modal_prog.set_status(f"{status} Channel: {item_name} ({current}/{total})") - self.app.call_from_thread(modal_prog.write_to_log, f"[yellow]Starting Channel Cloning...[/yellow]") + modal_prog.write_to_log(f"[yellow]Starting Channel Cloning...[/yellow]") self.engine.is_running = True - # Use False for force. UI logic should have an intermediate confirmation for Force in future iterations - # For this unified TUI, we default to sync cloned_info = await migrate_channels(self.engine, progress_callback=update_progress, force=False) - self.app.call_from_thread(modal_prog.write_to_log, "[bold green]Server Template cloned![/bold green]") + modal_prog.write_to_log("[bold green]Server Template cloned![/bold green]") if cloned_info and cloned_info.get("structure"): await log_audit_event(self.engine, "Server Template Cloned", "Successfully cloned target structure.") @@ -738,37 +735,37 @@ class ShuttleScreen(Screen): await log_audit_event(self.engine, "Server Template Cloned", "No new channels were cloned.") except Exception as e: - self.app.call_from_thread(modal_prog.write_to_log, f"[bold red]Error during channel clone: {e}[/bold red]") + modal_prog.write_to_log(f"[bold red]Error during channel clone: {e}[/bold red]") finally: await self.engine.close_connections() self.engine.is_running = False - self.app.call_from_thread(modal_prog.set_status, "Finished.") - self.app.call_from_thread(modal_prog.allow_close) + modal_prog.set_status("Finished.") + modal_prog.allow_close() - @work(exclusive=True, thread=True) + @work(exclusive=True) async def run_roles(self) -> None: modal_prog = ProgressModal() - self.app.call_from_thread(self.app.push_screen, modal_prog) + self.app.push_screen(modal_prog) await asyncio.sleep(0.1) is_stoat = getattr(self.config, 'use_stoat', False) roles_mod = stoat_roles if is_stoat else fluxer_roles try: - self.app.call_from_thread(modal_prog.set_status, "Fetching roles...") + modal_prog.set_status("Fetching roles...") await self.engine.start_connections() - self.app.call_from_thread(modal_prog.write_to_log, "Syncing role state...") + modal_prog.write_to_log("Syncing role state...") await roles_mod.sync_roles_state(self.engine) async def update_progress(item_name: str, current: int, total: int): - self.app.call_from_thread(modal_prog.set_status, f"Copying Role: {item_name} ({current}/{total})") + modal_prog.set_status(f"Copying Role: {item_name} ({current}/{total})") - self.app.call_from_thread(modal_prog.write_to_log, f"[yellow]Starting Role Migration...[/yellow]") + modal_prog.write_to_log(f"[yellow]Starting Role Migration...[/yellow]") self.engine.is_running = True cloned_roles = await roles_mod.migrate_roles(self.engine, progress_callback=update_progress, force=False) - self.app.call_from_thread(modal_prog.write_to_log, "[bold green]Role migration complete![/bold green]") + modal_prog.write_to_log("[bold green]Role migration complete![/bold green]") if cloned_roles: await log_audit_event(self.engine, "Roles Cloned", "Successfully cloned roles.") @@ -776,79 +773,79 @@ class ShuttleScreen(Screen): await log_audit_event(self.engine, "Roles Cloned", "No new roles were cloned.") except Exception as e: - self.app.call_from_thread(modal_prog.write_to_log, f"[bold red]Error during role clone: {e}[/bold red]") + modal_prog.write_to_log(f"[bold red]Error during role clone: {e}[/bold red]") finally: await self.engine.close_connections() self.engine.is_running = False - self.app.call_from_thread(modal_prog.set_status, "Finished.") - self.app.call_from_thread(modal_prog.allow_close) + modal_prog.set_status("Finished.") + modal_prog.allow_close() - @work(exclusive=True, thread=True) + @work(exclusive=True) async def run_emojis(self) -> None: modal_prog = ProgressModal() - self.app.call_from_thread(self.app.push_screen, modal_prog) + self.app.push_screen(modal_prog) await asyncio.sleep(0.1) is_stoat = getattr(self.config, 'use_stoat', False) emo_mod = stoat_emoji_stickers if is_stoat else fluxer_emoji_stickers try: - self.app.call_from_thread(modal_prog.set_status, "Fetching emojis...") + modal_prog.set_status("Fetching emojis...") await self.engine.start_connections() - self.app.call_from_thread(modal_prog.write_to_log, "Syncing emoji & sticker state...") + modal_prog.write_to_log("Syncing emoji & sticker state...") await emo_mod.sync_emojis_state(self.engine) await emo_mod.sync_stickers_state(self.engine) async def update_progress(item_name: str, item_type: str, current: int, total: int): - self.app.call_from_thread(modal_prog.set_status, f"Copying {item_type}: {item_name} ({current}/{total})") + modal_prog.set_status(f"Copying {item_type}: {item_name} ({current}/{total})") - self.app.call_from_thread(modal_prog.write_to_log, f"[yellow]Starting Asset Migration...[/yellow]") + modal_prog.write_to_log(f"[yellow]Starting Asset Migration...[/yellow]") self.engine.is_running = True # Using force=False res_e, res_s = await emo_mod.migrate_emojis(self.engine, progress_callback=update_progress, force=False) - self.app.call_from_thread(modal_prog.write_to_log, "[bold green]Asset migration complete![/bold green]") + modal_prog.write_to_log("[bold green]Asset migration complete![/bold green]") await log_audit_event(self.engine, "Assets Cloned", f"E: {res_e} S: {res_s}") except Exception as e: - self.app.call_from_thread(modal_prog.write_to_log, f"[bold red]Error during asset migrate: {e}[/bold red]") + modal_prog.write_to_log(f"[bold red]Error during asset migrate: {e}[/bold red]") finally: await self.engine.close_connections() self.engine.is_running = False - self.app.call_from_thread(modal_prog.set_status, "Finished.") - self.app.call_from_thread(modal_prog.allow_close) + modal_prog.set_status("Finished.") + modal_prog.allow_close() - @work(exclusive=True, thread=True) + @work(exclusive=True) async def run_meta(self) -> None: modal_prog = ProgressModal() - self.app.call_from_thread(self.app.push_screen, modal_prog) + self.app.push_screen(modal_prog) await asyncio.sleep(0.1) is_stoat = getattr(self.config, 'use_stoat', False) meta_mod = stoat_metadata if is_stoat else fluxer_metadata try: - self.app.call_from_thread(modal_prog.set_status, "Connecting to platforms...") + modal_prog.set_status("Connecting to platforms...") await self.engine.start_connections() def cb(item, status): - self.app.call_from_thread(modal_prog.write_to_log, f"{item}: {status}") + modal_prog.write_to_log(f"{item}: {status}") - self.app.call_from_thread(modal_prog.write_to_log, f"[yellow]Starting Meta Migration...[/yellow]") + modal_prog.write_to_log(f"[yellow]Starting Meta Migration...[/yellow]") self.engine.is_running = True await meta_mod.migrate_server_metadata(self.engine, progress_callback=cb) - self.app.call_from_thread(modal_prog.write_to_log, "[bold green]Meta migration complete![/bold green]") + modal_prog.write_to_log("[bold green]Meta migration complete![/bold green]") except Exception as e: - self.app.call_from_thread(modal_prog.write_to_log, f"[bold red]Error during meta migrate: {e}[/bold red]") + modal_prog.write_to_log(f"[bold red]Error during meta migrate: {e}[/bold red]") finally: await self.engine.close_connections() self.engine.is_running = False - self.app.call_from_thread(modal_prog.set_status, "Finished.") - self.app.call_from_thread(modal_prog.allow_close) + modal_prog.set_status("Finished.") + modal_prog.allow_close() # ------------------------------------------------------------------------- diff --git a/unifier.py b/unifier.py deleted file mode 100644 index 78843a5..0000000 --- a/unifier.py +++ /dev/null @@ -1,68 +0,0 @@ -import sys -import asyncio -import logging -from src.ui.unifier_tui import run_unifier_tui - -def setup_logging(): - level = logging.INFO - handlers = [logging.FileHandler('.unifier.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("DISCO_UNIFIER_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["DISCO_UNIFIER_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() - - await run_unifier_tui() - -if __name__ == "__main__": - try: - asyncio.run(main()) - except KeyboardInterrupt: - print("\nOperation terminated by user.") - sys.exit(0) - except Exception as e: - print(f"Error: {e}") - sys.exit(1)