diff --git a/.gitignore b/.gitignore index 28da47e..87f8719 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ wheels/ *.egg-info/ .installed.cfg *.egg -temp.txt +*.txt # Virtual Environment venv/ @@ -32,8 +32,8 @@ config.yaml !config.example.yaml # Logs and State -migration.log -state.json +*.log +*.json # Temporary Test Scripts test_*.py diff --git a/src/core/state.py b/src/core/state.py index 6c6c49b..7273d60 100644 --- a/src/core/state.py +++ b/src/core/state.py @@ -5,22 +5,28 @@ from typing import Dict, Any class MigrationState: """Manages persistence of the migration state to allow resumability.""" - def __init__(self, state_file: str | Path = "state.json"): + def __init__(self, state_file: str | Path = "state.json", messages_file: str | Path = "messages.json"): self.state_file = Path(state_file) + self.messages_file = Path(messages_file) + # mappings: discord_id -> fluxer_id self.channel_map: Dict[str, str] = {} self.category_map: Dict[str, str] = {} self.role_map: Dict[str, str] = {} self.emoji_map: Dict[str, str] = {} self.sticker_map: Dict[str, str] = {} - self.message_map: Dict[str, str] = {} - # tracking last message timestamp per channel to resume + # message tracking + self.message_map: Dict[str, str] = {} self.last_message_timestamps: Dict[str, str] = {} self.load() def load(self): + migrated_state = False + migrated_messages = False + + # 1. Load primary state.json if self.state_file.exists(): with open(self.state_file, "r", encoding="utf-8") as f: data = json.load(f) @@ -29,109 +35,133 @@ class MigrationState: self.role_map = data.get("roles", {}) self.emoji_map = data.get("emojis", {}) self.sticker_map = data.get("stickers", {}) - self.message_map = data.get("messages", {}) - self.last_message_timestamps = data.get("last_message_timestamps", {}) + # Check for legacy messages in state.json + if "messages" in data or "last_message_timestamps" in data: + self.message_map = data.get("messages", {}) + self.last_message_timestamps = data.get("last_message_timestamps", {}) + migrated_messages = True # We found legacy data, we should write it out to messages.json later + # Legacy Migration: Move role_, emoji_, sticker_ from channel_map to dedicated maps - migrated = False legacy_keys = list(self.channel_map.keys()) for k in legacy_keys: if k.startswith("role_"): discord_id = k.replace("role_", "") self.role_map[discord_id] = self.channel_map.pop(k) - migrated = True + migrated_state = True elif k.startswith("emoji_"): discord_id = k.replace("emoji_", "") self.emoji_map[discord_id] = self.channel_map.pop(k) - migrated = True + migrated_state = True elif k.startswith("sticker_"): discord_id = k.replace("sticker_", "") self.sticker_map[discord_id] = self.channel_map.pop(k) - migrated = True - - if migrated: - self.save() + migrated_state = True - def save(self): + # 2. Load separate messages.json (overrides legacy state.json) + if self.messages_file.exists(): + with open(self.messages_file, "r", encoding="utf-8") as f: + msg_data = json.load(f) + self.message_map = msg_data.get("messages", {}) + self.last_message_timestamps = msg_data.get("last_message_timestamps", {}) + migrated_messages = False # No need to force a migrating save since it already exists + + # 3. Save if we migrated any legacy data to separate maps/files + if migrated_state or migrated_messages: + self.save_state() + if migrated_messages: + self.save_messages() + + def save_state(self): + """Saves only the core server configuration (channels, roles, emojis).""" data = { "channels": self.channel_map, "categories": self.category_map, "roles": self.role_map, "emojis": self.emoji_map, - "stickers": self.sticker_map, - "last_message_timestamps": self.last_message_timestamps, - "messages": self.message_map + "stickers": self.sticker_map } with open(self.state_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=4) + def save_messages(self): + """Saves only the message tracking data.""" + data = { + "last_message_timestamps": self.last_message_timestamps, + "messages": self.message_map + } + with open(self.messages_file, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) + + # --- Type Specific Getters/Setters --- + def set_channel_mapping(self, discord_id: str, fluxer_id: str): self.channel_map[str(discord_id)] = str(fluxer_id) - self.save() + self.save_state() def get_fluxer_channel_id(self, discord_id: str) -> str | None: return self.channel_map.get(str(discord_id)) def remove_channel_mapping(self, discord_id: str): self.channel_map.pop(str(discord_id), None) - self.save() + self.save_state() def set_category_mapping(self, discord_id: str, fluxer_id: str): self.category_map[str(discord_id)] = str(fluxer_id) - self.save() + self.save_state() def get_fluxer_category_id(self, discord_id: str) -> str | None: return self.category_map.get(str(discord_id)) def remove_category_mapping(self, discord_id: str): self.category_map.pop(str(discord_id), None) - self.save() - - def set_message_mapping(self, discord_id: str, fluxer_id: str): - self.message_map[str(discord_id)] = str(fluxer_id) - self.save() - - def get_fluxer_message_id(self, discord_id: str) -> str | None: - return self.message_map.get(str(discord_id)) - - def update_last_message_timestamp(self, channel_id: str, timestamp: str): - self.last_message_timestamps[str(channel_id)] = timestamp - self.save() - - # --- Type Specific Getters/Setters --- + self.save_state() def set_role_mapping(self, discord_id: str, fluxer_id: str): self.role_map[str(discord_id)] = str(fluxer_id) - self.save() + self.save_state() def get_fluxer_role_id(self, discord_id: str) -> str | None: return self.role_map.get(str(discord_id)) def remove_role_mapping(self, discord_id: str): self.role_map.pop(str(discord_id), None) - self.save() + self.save_state() def set_emoji_mapping(self, discord_id: str, fluxer_id: str): self.emoji_map[str(discord_id)] = str(fluxer_id) - self.save() + self.save_state() def get_fluxer_emoji_id(self, discord_id: str) -> str | None: return self.emoji_map.get(str(discord_id)) def remove_emoji_mapping(self, discord_id: str): self.emoji_map.pop(str(discord_id), None) - self.save() + self.save_state() def set_sticker_mapping(self, discord_id: str, fluxer_id: str): self.sticker_map[str(discord_id)] = str(fluxer_id) - self.save() + self.save_state() def get_fluxer_sticker_id(self, discord_id: str) -> str | None: return self.sticker_map.get(str(discord_id)) def remove_sticker_mapping(self, discord_id: str): self.sticker_map.pop(str(discord_id), None) - self.save() + self.save_state() + + # --- Message Management --- + + def set_message_mapping(self, discord_id: str, fluxer_id: str): + self.message_map[str(discord_id)] = str(fluxer_id) + self.save_messages() + + def get_fluxer_message_id(self, discord_id: str) -> str | None: + return self.message_map.get(str(discord_id)) + + def update_last_message_timestamp(self, channel_id: str, timestamp: str): + self.last_message_timestamps[str(channel_id)] = timestamp + self.save_messages() # --- Danger Zone Clearing --- @@ -139,21 +169,21 @@ class MigrationState: """Clears all channel and category mappings.""" self.channel_map.clear() self.category_map.clear() - self.save() + self.save_state() def clear_role_mappings(self): """Clears all role mappings.""" self.role_map.clear() - self.save() + self.save_state() def clear_asset_mappings(self): """Clears all emoji and sticker mappings.""" self.emoji_map.clear() self.sticker_map.clear() - self.save() + self.save_state() def clear_message_history(self): """Clears all message mappings and timestamps.""" self.message_map.clear() self.last_message_timestamps.clear() - self.save() + self.save_messages() diff --git a/src/ui/app.py b/src/ui/app.py index d1ef000..245b3da 100644 --- a/src/ui/app.py +++ b/src/ui/app.py @@ -203,7 +203,7 @@ class MigrationCLI: console.print("(Q) Exit") - choice = Prompt.ask("\nSelect an option", choices=["1", "2", "3", "4", "5", "6", "7", "Q", "q"], default="Q").upper() + choice = Prompt.ask("\nSelect an option [1/2/3/4/5/6/7/Q]", choices=["1", "2", "3", "4", "5", "6", "7", "Q", "q"], default="Q", show_choices=False).upper() if choice == "1": await self.clone_server_template() @@ -381,7 +381,7 @@ class MigrationCLI: console.print("[bold red](F) Force re-clone, creates duplicate channels![/bold red]") console.print("[bold yellow](B) Back[/bold yellow]") - choice = Prompt.ask("Select an option", choices=["Y", "F", "B"], default="Y").upper() + choice = Prompt.ask("Select an option [Y/F/B]", choices=["Y", "y", "F", "f", "B", "b"], default="Y", show_choices=False).upper() if choice == "B": await self.engine.close_connections() @@ -430,7 +430,7 @@ class MigrationCLI: console.print("(2) Sync Category Permissions & Channel Permissions") console.print("(B) Back") - choice = Prompt.ask("Select an option", choices=["1", "2", "B", "b"], default="B").upper() + choice = Prompt.ask("Select an option [1/2/B]", choices=["1", "2", "B", "b"], default="B", show_choices=False).upper() if choice == "B": return @@ -469,7 +469,7 @@ class MigrationCLI: console.print("[bold red](F) Force Overwrite[/bold red]") console.print("[bold yellow](B) Back[/bold yellow]") - sub_choice = Prompt.ask("Select an option", choices=["Y", "F", "B"], default="Y").upper() + sub_choice = Prompt.ask("Select an option [Y/F/B]", choices=["Y", "y", "F", "f", "B", "b"], default="Y", show_choices=False).upper() if sub_choice == "B": return @@ -524,7 +524,7 @@ class MigrationCLI: console.print("[bold green](Y) Proceed with Permission synchronization[/bold green]") console.print("[bold yellow](B) Back[/bold yellow]") - sub_choice = Prompt.ask("Select an option", choices=["Y", "B"], default="Y").upper() + sub_choice = Prompt.ask("Select an option [Y/B]", choices=["Y", "y", "B", "b"], default="Y", show_choices=False).upper() if sub_choice == "B": return @@ -598,7 +598,7 @@ class MigrationCLI: console.print("(3) Sync Emojis and Stickers") console.print("(B) Back") - choice = Prompt.ask("Select an option", choices=["1", "2", "3", "B", "b"], default="B").upper() + choice = Prompt.ask("Select an option [1/2/3/B]", choices=["1", "2", "3", "B", "b"], default="B", show_choices=False).upper() if choice == "B": return @@ -625,7 +625,7 @@ class MigrationCLI: console.print("[bold red](F) Force Overwrite[/bold red]") console.print("[bold yellow](B) Back[/bold yellow]") - choice = Prompt.ask("Select an option", choices=["Y", "F", "B"], default="Y").upper() + choice = Prompt.ask("Select an option [Y/F/B]", choices=["Y", "y", "F", "f", "B", "b"], default="Y", show_choices=False).upper() if choice == "B": return @@ -692,7 +692,7 @@ class MigrationCLI: console.print("(4) Sync Everything") console.print("(B) Back") - choice = Prompt.ask("Select an option", choices=["1", "2", "3", "4", "B", "b"], default="B").upper() + choice = Prompt.ask("Select an option [1/2/3/4/B]", choices=["1", "2", "3", "4", "B", "b"], default="B", show_choices=False).upper() if choice == "B": return @@ -721,7 +721,7 @@ class MigrationCLI: await self.engine.close_connections() async def migrate_message_history(self): - console.print("\n[bold]Message History Migration (Manual Selection)[/bold]") + console.print("\n[bold]Message History Migration[/bold]") if not self.tokens_valid: console.print("[bold red]Error: You must have a valid configuration (Option 6) to migrate messages.[/bold red]") @@ -837,34 +837,51 @@ class MigrationCLI: if first_msg: # Link format: https://discord.com/channels/GUILD_ID/CHANNEL_ID/MESSAGE_ID first_msg_link = f"https://discord.com/channels/{self.config.discord_server_id}/{source_channel.id}/{first_msg.id}" - console.print(f"\n[bold green]Channel Found![/bold green]") - console.print(f"Oldest Message Link: {first_msg_link}") - console.print(f"Author: [blue]{first_msg.author.name}[/blue]") - console.print(f"Content Preview: {first_msg.content[:100]}...") + server_name = self.validation_results.get("discord_server_name", "server") - console.print("\n(Y) [green]Yes, start from oldest message[/green]") - console.print("(M) Provide Specific message link or ID") - console.print("(B) Back") - - start_mode = Prompt.ask("Start migration", choices=["Y", "M", "B"], default="Y").upper() - - if start_mode == "B": - return - elif start_mode == "M": - custom_link = Prompt.ask("Enter Discord Message Link") - try: - # Extract message ID from end of link - custom_id = int(custom_link.split("/")[-1]) - # Check if message exists to give feedback - msg = await self.engine.discord_reader.get_message(source_channel.id, custom_id) - if msg: - # To include this message, we start 'after' the ID before it - after_id = custom_id - 1 - console.print(f"[green]Confirmed: Starting from {msg.author.name}'s message.[/green]") - else: - console.print("[red]Warning: Message ID not found in this channel. Starting from beginning.[/red]") - except Exception as e: - console.print(f"[red]Error parsing link: {e}. Starting from beginning.[/red]") + while True: + console.print(f"\n[bold green]Channel Found![/bold green]") + console.print(f"Oldest Message Link: {first_msg_link}") + console.print(f"Author: [blue]{first_msg.author.name}[/blue]") + console.print(f"Content Preview: {first_msg.content[:100]}...") + + console.print("\n(Y) [green]Yes, start from oldest message[/green]") + console.print("(M) Provide Specific message link or ID") + console.print("(B) Back") + + start_mode = Prompt.ask("Start migration [Y/M/B]", choices=["Y", "y", "M", "m", "B", "b"], default="Y", show_choices=False).upper() + + if start_mode == "B": + return + elif start_mode == "Y": + break + elif start_mode == "M": + prompt_msg = "Enter Discord Message Link" + valid_message_found = False + while True: + custom_link = Prompt.ask(prompt_msg) + if str(custom_link).upper() in ["B", "BACK"]: + break + try: + # Extract message ID from end of link + custom_id = int(str(custom_link).strip().split("/")[-1]) + # Check if message exists to give feedback + msg = await self.engine.discord_reader.get_message(source_channel.id, custom_id) + if msg: + # To include this message, we start 'after' the ID before it + after_id = custom_id - 1 + console.print(f"\n[green]Confirmed: Starting from {msg.author.name}'s message.[/green]") + valid_message_found = True + break + else: + console.print("\n[red]Error parsing link: 404 Not Found (error code: 10008): Unknown Message.[/red]") + prompt_msg = f"[yellow]Provide a valid Message link or Message ID from \"{server_name}\" (or 'B' to go back)[/yellow]" + except Exception as e: + console.print(f"\n[red]Error parsing link: {e}[/red]") + prompt_msg = f"[yellow]Provide a valid Message link or Message ID from \"{server_name}\" (or 'B' to go back)[/yellow]" + + if valid_message_found: + break else: console.print("[yellow]Source channel appears to be empty. Nothing to migrate.[/yellow]") return @@ -968,9 +985,10 @@ class MigrationCLI: console.print("(B) Back") choice = Prompt.ask( - "Select a Danger Zone option", + "Select a Danger Zone option [1/2/3/4/B]", choices=["1", "2", "3", "4", "B", "b"], - default="B" + default="B", + show_choices=False ).upper() if choice == "B": @@ -979,7 +997,8 @@ class MigrationCLI: # ---- (1) Delete all Channels & Categories ---- if choice == "1": console.print("") - console.print("[bold red]This will DELETE every channel and category in the Fluxer community.[/bold red]") + community_name = self.validation_results.get("fluxer_community_name", "Unknown") + console.print(f"[bold red]This will DELETE every channel and category in the Fluxer community: \"{community_name}\"[/bold red]") if not Confirm.ask("[bold red]Are you absolutely sure?[/bold red]"): return if not Confirm.ask("[bold red]Last chance \u2013 this cannot be undone. Continue?[/bold red]"): @@ -1010,7 +1029,8 @@ class MigrationCLI: # ---- (2) Reset Channel & Category Permissions ---- elif choice == "2": console.print("") - console.print("[bold red]This will RESET all permission overwrites on every channel and category.[/bold red]") + community_name = self.validation_results.get("fluxer_community_name", "Unknown") + console.print(f"[bold red]This will RESET all permission overwrites on every channel and category in the Fluxer community: \"{community_name}\"[/bold red]") if not Confirm.ask("[bold red]Are you absolutely sure?[/bold red]"): return if not Confirm.ask("[bold red]Last chance \u2013 all custom permission overrides will be wiped. Continue?[/bold red]"): @@ -1041,7 +1061,8 @@ class MigrationCLI: # ---- (3) Delete all Roles ---- elif choice == "3": console.print("") - console.print("[bold red]This will DELETE all roles in the Fluxer community.[/bold red]") + community_name = self.validation_results.get("fluxer_community_name", "Unknown") + console.print(f"[bold red]This will DELETE all roles in the Fluxer community: \"{community_name}\"[/bold red]") console.print("[dim]Managed roles (including the bot's own role) and @everyone are automatically protected.[/dim]") if not Confirm.ask("[bold red]Are you absolutely sure?[/bold red]"): return @@ -1073,7 +1094,8 @@ class MigrationCLI: # ---- (4) Delete all Emojis & Stickers ---- elif choice == "4": console.print("") - console.print("[bold red]This will DELETE all custom emojis and stickers in the Fluxer community.[/bold red]") + community_name = self.validation_results.get("fluxer_community_name", "Unknown") + console.print(f"[bold red]This will DELETE all custom emojis and stickers in the Fluxer community: \"{community_name}\"[/bold red]") if not Confirm.ask("[bold red]Are you absolutely sure?[/bold red]"): return if not Confirm.ask("[bold red]Last chance \u2013 this cannot be undone. Continue?[/bold red]"):