use local cache to avoid redundant api calls
This commit is contained in:
parent
7cf905f16f
commit
0aa32217b1
7 changed files with 234 additions and 53 deletions
|
|
@ -7,7 +7,7 @@
|
||||||

|

|
||||||
|
|
||||||
### Modern Terminal Interface
|
### Modern Terminal Interface
|
||||||
The tool now features a unified, intuitive TUI (Terminal User Interface) - no more commands
|
The tool now features a unified, intuitive TUI (Terminal User Interface) - no more text commands
|
||||||
|
|
||||||
| Features | Fluxer | Stoat |
|
| Features | Fluxer | Stoat |
|
||||||
| :--- | :---: | :---: |
|
| :--- | :---: | :---: |
|
||||||
|
|
@ -21,7 +21,7 @@ The tool now features a unified, intuitive TUI (Terminal User Interface) - no mo
|
||||||
| - Roles Cloning | 🟩 | 🟩 |
|
| - Roles Cloning | 🟩 | 🟩 |
|
||||||
| - Roles Permissions | 🟩 | 🟩 |
|
| - Roles Permissions | 🟩 | 🟩 |
|
||||||
| - Category Permissions | 🟩 | ⚠️ |
|
| - Category Permissions | 🟩 | ⚠️ |
|
||||||
| - Channel Permissions | 🟩 | ⏳to be done |
|
| - Channel Permissions | 🟩 | ⏳ |
|
||||||
| **Emojis & Stickers** | | |
|
| **Emojis & Stickers** | | |
|
||||||
| - Copy Emojis | 🟩 | 🟩 |
|
| - Copy Emojis | 🟩 | 🟩 |
|
||||||
| - Copy Stickers | 🟩 | ⚠️ |
|
| - Copy Stickers | 🟩 | ⚠️ |
|
||||||
|
|
@ -39,7 +39,7 @@ The tool now features a unified, intuitive TUI (Terminal User Interface) - no mo
|
||||||
|
|
||||||
- ⚠️**Fluxer/Stoat**: Threads & Forums type channels are not yet natively available. As a workaround, threads are migrated in their parent channels as normal messages.
|
- ⚠️**Fluxer/Stoat**: Threads & Forums type channels are not yet natively available. As a workaround, threads are migrated in their parent channels as normal messages.
|
||||||
- ⚠️**Stoat**: doesn't have features like Category Permissions or Slowmode settings for channels.
|
- ⚠️**Stoat**: doesn't have features like Category Permissions or Slowmode settings for channels.
|
||||||
- ⏳**Stoat**: permission sync for channels is pending due to architectural differences.
|
- ⏳**Stoat**: permission sync for channels was not implemented due to architectural differences.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,12 @@ class DiscordReader:
|
||||||
self.client: discord.Client | None = None
|
self.client: discord.Client | None = None
|
||||||
self.role_map: Dict[int, str] = {}
|
self.role_map: Dict[int, str] = {}
|
||||||
self.channel_name_map: Dict[int, str] = {}
|
self.channel_name_map: Dict[int, str] = {}
|
||||||
|
# Session-level caches to avoid redundant fetch calls
|
||||||
|
self._roles_cache: list[discord.Role] | None = None
|
||||||
|
self._channels_cache: list[discord.abc.GuildChannel] | None = None
|
||||||
|
self._categories_cache: list[discord.CategoryChannel] | None = None
|
||||||
|
self._emojis_cache: list[discord.Emoji] | None = None
|
||||||
|
self._stickers_cache: list[discord.GuildSticker] | None = None
|
||||||
|
|
||||||
def _create_client(self):
|
def _create_client(self):
|
||||||
intents = discord.Intents.default()
|
intents = discord.Intents.default()
|
||||||
|
|
@ -92,6 +98,7 @@ class DiscordReader:
|
||||||
try:
|
try:
|
||||||
roles = await self.guild.fetch_roles()
|
roles = await self.guild.fetch_roles()
|
||||||
self.role_map = {r.id: r.name for r in roles}
|
self.role_map = {r.id: r.name for r in roles}
|
||||||
|
self._roles_cache = [r for r in roles if not r.is_default()]
|
||||||
except discord.Forbidden:
|
except discord.Forbidden:
|
||||||
logger.warning("403 Forbidden: Missing Access to fetch roles. Continuing without role mapping.")
|
logger.warning("403 Forbidden: Missing Access to fetch roles. Continuing without role mapping.")
|
||||||
self.role_map = {}
|
self.role_map = {}
|
||||||
|
|
@ -103,6 +110,8 @@ class DiscordReader:
|
||||||
try:
|
try:
|
||||||
channels = await self.guild.fetch_channels()
|
channels = await self.guild.fetch_channels()
|
||||||
self.channel_name_map = {c.id: c.name for c in channels}
|
self.channel_name_map = {c.id: c.name for c in channels}
|
||||||
|
self._channels_cache = [c for c in channels if not isinstance(c, discord.CategoryChannel)]
|
||||||
|
self._categories_cache = [c for c in channels if isinstance(c, discord.CategoryChannel)]
|
||||||
logger.debug(f"Pre-fetched {len(self.channel_name_map)} channels")
|
logger.debug(f"Pre-fetched {len(self.channel_name_map)} channels")
|
||||||
except discord.Forbidden:
|
except discord.Forbidden:
|
||||||
logger.warning("403 Forbidden: Missing Access to fetch channels. Continuing without channel name mapping.")
|
logger.warning("403 Forbidden: Missing Access to fetch channels. Continuing without channel name mapping.")
|
||||||
|
|
@ -171,28 +180,40 @@ class DiscordReader:
|
||||||
async def get_categories(self):
|
async def get_categories(self):
|
||||||
if not self.guild:
|
if not self.guild:
|
||||||
return []
|
return []
|
||||||
|
if self._categories_cache is not None:
|
||||||
|
return self._categories_cache
|
||||||
categories = await self.guild.fetch_channels()
|
categories = await self.guild.fetch_channels()
|
||||||
return [c for c in categories if isinstance(c, discord.CategoryChannel)]
|
self._categories_cache = [c for c in categories if isinstance(c, discord.CategoryChannel)]
|
||||||
|
return self._categories_cache
|
||||||
|
|
||||||
async def get_roles(self):
|
async def get_roles(self):
|
||||||
"""Returns all roles in the server (excluding @everyone)."""
|
"""Returns all roles in the server (excluding @everyone)."""
|
||||||
if not self.guild:
|
if not self.guild:
|
||||||
return []
|
return []
|
||||||
|
if self._roles_cache is not None:
|
||||||
|
return self._roles_cache
|
||||||
roles = await self.guild.fetch_roles()
|
roles = await self.guild.fetch_roles()
|
||||||
# Filter out default @everyone role which cannot typically be created
|
# Filter out default @everyone role which cannot typically be created
|
||||||
return [r for r in roles if not r.is_default()]
|
self._roles_cache = [r for r in roles if not r.is_default()]
|
||||||
|
return self._roles_cache
|
||||||
|
|
||||||
async def get_emojis(self):
|
async def get_emojis(self):
|
||||||
"""Returns all custom emojis in the server."""
|
"""Returns all custom emojis in the server."""
|
||||||
if not self.guild:
|
if not self.guild:
|
||||||
return []
|
return []
|
||||||
return await self.guild.fetch_emojis()
|
if self._emojis_cache is not None:
|
||||||
|
return self._emojis_cache
|
||||||
|
self._emojis_cache = await self.guild.fetch_emojis()
|
||||||
|
return self._emojis_cache
|
||||||
|
|
||||||
async def get_stickers(self):
|
async def get_stickers(self):
|
||||||
"""Returns all custom stickers in the server."""
|
"""Returns all custom stickers in the server."""
|
||||||
if not self.guild:
|
if not self.guild:
|
||||||
return []
|
return []
|
||||||
return await self.guild.fetch_stickers()
|
if self._stickers_cache is not None:
|
||||||
|
return self._stickers_cache
|
||||||
|
self._stickers_cache = await self.guild.fetch_stickers()
|
||||||
|
return self._stickers_cache
|
||||||
|
|
||||||
async def get_members(self):
|
async def get_members(self):
|
||||||
"""Returns all members in the server."""
|
"""Returns all members in the server."""
|
||||||
|
|
@ -208,8 +229,12 @@ class DiscordReader:
|
||||||
"""Yields all non-category channels."""
|
"""Yields all non-category channels."""
|
||||||
if not self.guild:
|
if not self.guild:
|
||||||
return []
|
return []
|
||||||
channels = await self.guild.fetch_channels()
|
|
||||||
all_channels = [c for c in channels if not isinstance(c, discord.CategoryChannel)]
|
if self._channels_cache is None:
|
||||||
|
channels = await self.guild.fetch_channels()
|
||||||
|
self._channels_cache = [c for c in channels if not isinstance(c, discord.CategoryChannel)]
|
||||||
|
|
||||||
|
all_channels = self._channels_cache
|
||||||
if category_id:
|
if category_id:
|
||||||
all_channels = [c for c in all_channels if c.category_id == category_id]
|
all_channels = [c for c in all_channels if c.category_id == category_id]
|
||||||
return all_channels
|
return all_channels
|
||||||
|
|
@ -256,5 +281,16 @@ class DiscordReader:
|
||||||
return await attachment.read()
|
return await attachment.read()
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
if self.client:
|
client = self.client
|
||||||
await self.client.close()
|
self.client = None # Atomic clear
|
||||||
|
self.guild = None
|
||||||
|
self._roles_cache = None
|
||||||
|
self._channels_cache = None
|
||||||
|
self._categories_cache = None
|
||||||
|
self._emojis_cache = None
|
||||||
|
self._stickers_cache = None
|
||||||
|
if client:
|
||||||
|
try:
|
||||||
|
await client.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error closing Discord client: {e}")
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class FluxerWriter:
|
||||||
self._bot_task: Optional[asyncio.Task] = None
|
self._bot_task: Optional[asyncio.Task] = None
|
||||||
self._ready_event = asyncio.Event()
|
self._ready_event = asyncio.Event()
|
||||||
self._webhooks: Dict[str, Webhook] = {} # channel_id -> Webhook
|
self._webhooks: Dict[str, Webhook] = {} # channel_id -> Webhook
|
||||||
|
self._channels_cache: List[Dict[str, Any]] | None = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def fetch_guilds(token: str, api_url: str = "default") -> list[tuple[str, str]]:
|
async def fetch_guilds(token: str, api_url: str = "default") -> list[tuple[str, str]]:
|
||||||
|
|
@ -243,8 +244,11 @@ class FluxerWriter:
|
||||||
|
|
||||||
async def get_channels(self) -> List[Dict[str, Any]]:
|
async def get_channels(self) -> List[Dict[str, Any]]:
|
||||||
"""Returns all channels in the community."""
|
"""Returns all channels in the community."""
|
||||||
|
if self._channels_cache is not None:
|
||||||
|
return self._channels_cache
|
||||||
assert self.client is not None
|
assert self.client is not None
|
||||||
return await self.client.get_guild_channels(self.community_id)
|
self._channels_cache = await self.client.get_guild_channels(self.community_id)
|
||||||
|
return self._channels_cache
|
||||||
|
|
||||||
async def send_message(self, channel_id: str, author_name: str, content: str, timestamp: int, author_avatar_url: Optional[str] = None, files: Optional[List[Dict[str, Any]]] = None, reply_to_message_id: Optional[str] = None, is_forwarded: bool = False, embeds: Optional[List[Dict[str, Any]]] = None) -> Optional[str]:
|
async def send_message(self, channel_id: str, author_name: str, content: str, timestamp: int, author_avatar_url: Optional[str] = None, files: Optional[List[Dict[str, Any]]] = None, reply_to_message_id: Optional[str] = None, is_forwarded: bool = False, embeds: Optional[List[Dict[str, Any]]] = None) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -660,16 +664,23 @@ class FluxerWriter:
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""Cleanly close connection and stop bot task."""
|
"""Cleanly close connection and stop bot task."""
|
||||||
if self.bot:
|
bot = self.bot
|
||||||
|
self.bot = None # Atomic clear
|
||||||
|
self._channels_cache = None
|
||||||
|
self._webhooks.clear()
|
||||||
|
|
||||||
|
if bot:
|
||||||
try:
|
try:
|
||||||
await self.bot.close()
|
await bot.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error closing Fluxer bot: {e}")
|
logger.debug(f"Error closing Fluxer bot: {e}")
|
||||||
|
|
||||||
if self._bot_task:
|
if self._bot_task:
|
||||||
self._bot_task.cancel()
|
task = self._bot_task
|
||||||
|
self._bot_task = None
|
||||||
|
task.cancel()
|
||||||
try:
|
try:
|
||||||
await self._bot_task
|
await task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
self._ready_event.clear()
|
self._ready_event.clear()
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,8 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl
|
||||||
|
|
||||||
# 4. Final step: Parent the channels into categories via mass server.edit()
|
# 4. Final step: Parent the channels into categories via mass server.edit()
|
||||||
logger.info("Parenting all channels into their respective categories...")
|
logger.info("Parenting all channels into their respective categories...")
|
||||||
server = await context.writer._get_server(populate_channels=True)
|
# Force refetch to ensure we see all newly created categories from the loop above
|
||||||
|
server = await context.writer._get_server(populate_channels=True, force=True)
|
||||||
cats = list(server.categories) if hasattr(server, "categories") and server.categories else []
|
cats = list(server.categories) if hasattr(server, "categories") and server.categories else []
|
||||||
|
|
||||||
# Workaround: Ensure default properties are set for all categories
|
# Workaround: Ensure default properties are set for all categories
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ class StoatWriter:
|
||||||
self.token = token
|
self.token = token
|
||||||
self.community_id = str(community_id)
|
self.community_id = str(community_id)
|
||||||
self.api_url = api_url
|
self.api_url = api_url
|
||||||
|
self.client: Optional[stoat.Client] = None
|
||||||
|
self._server = None
|
||||||
|
self._me = None
|
||||||
|
self._validation_cache = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def fetch_guilds(token: str, api_url: str = "default") -> list[tuple[str, str]]:
|
async def fetch_guilds(token: str, api_url: str = "default") -> list[tuple[str, str]]:
|
||||||
|
|
@ -67,30 +71,44 @@ class StoatWriter:
|
||||||
return guilds_list
|
return guilds_list
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
|
if self.client:
|
||||||
|
# Check if client is actually usable (not half-closed)
|
||||||
|
try:
|
||||||
|
if self.client and not self.client.is_closed:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.client = None
|
||||||
|
|
||||||
client_kwargs = {"token": self.token, "bot": True}
|
client_kwargs = {"token": self.token, "bot": True}
|
||||||
if self.api_url and self.api_url != "default":
|
if self.api_url and self.api_url != "default":
|
||||||
client_kwargs["http_base"] = self.api_url
|
client_kwargs["http_base"] = self.api_url
|
||||||
|
|
||||||
self.client = stoat.Client(**client_kwargs)
|
self.client = stoat.Client(**client_kwargs)
|
||||||
self._server = None
|
|
||||||
self._me = None
|
|
||||||
try:
|
try:
|
||||||
self._me = await self.client.fetch_user("@me")
|
self._me = await self.client.fetch_user("@me")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch bot user in StoatWriter: {e}")
|
logger.error(f"Failed to fetch bot user in StoatWriter: {e}")
|
||||||
|
self.client = None # Reset if we can't even fetch @me
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def my_id(self):
|
def my_id(self):
|
||||||
return str(self._me.id) if self._me else None
|
return str(self._me.id) if self._me else None
|
||||||
|
|
||||||
async def _get_server(self, populate_channels=False):
|
async def _get_server(self, populate_channels=False, force=False):
|
||||||
# Always refetch if channels are requested to ensure we have them
|
# Always refetch if channels are requested AND we don't already have them
|
||||||
|
# Or if force is True (e.g. after category creation/mutation)
|
||||||
# Stoat Server objects use __slots__, so we can't easily add our own tracking attributes.
|
# Stoat Server objects use __slots__, so we can't easily add our own tracking attributes.
|
||||||
if not self._server or populate_channels:
|
if force or (populate_channels and (not self._server or not hasattr(self._server, "channels") or not self._server.channels)):
|
||||||
self._server = await self.client.fetch_server(self.community_id, populate_channels=populate_channels)
|
self._server = await self.client.fetch_server(self.community_id, populate_channels=True)
|
||||||
|
elif not self._server:
|
||||||
|
self._server = await self.client.fetch_server(self.community_id, populate_channels=False)
|
||||||
return self._server
|
return self._server
|
||||||
|
|
||||||
async def validate(self) -> dict:
|
async def validate(self) -> dict:
|
||||||
|
if self._validation_cache:
|
||||||
|
return self._validation_cache
|
||||||
|
|
||||||
results = {
|
results = {
|
||||||
"token": False,
|
"token": False,
|
||||||
"community": False,
|
"community": False,
|
||||||
|
|
@ -105,16 +123,21 @@ class StoatWriter:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Use a temporary client for validation
|
# Ensure client is started
|
||||||
client_kwargs = {"token": self.token, "bot": True}
|
if not self.client:
|
||||||
if self.api_url and self.api_url != "default":
|
await self.start()
|
||||||
client_kwargs["http_base"] = self.api_url
|
|
||||||
|
client = self.client
|
||||||
|
assert client is not None
|
||||||
|
|
||||||
client = stoat.Client(**client_kwargs)
|
|
||||||
try:
|
try:
|
||||||
# Validate token by fetching current user
|
# Validate token by fetching current user
|
||||||
try:
|
try:
|
||||||
current_user = await client.fetch_user("@me")
|
# Reuse self._me if already fetched during start()
|
||||||
|
if not self._me:
|
||||||
|
self._me = await client.fetch_user("@me")
|
||||||
|
|
||||||
|
current_user = self._me
|
||||||
results["token"] = True
|
results["token"] = True
|
||||||
results["bot_name"] = current_user.display_name or current_user.name
|
results["bot_name"] = current_user.display_name or current_user.name
|
||||||
except stoat.Unauthorized:
|
except stoat.Unauthorized:
|
||||||
|
|
@ -128,12 +151,8 @@ class StoatWriter:
|
||||||
results["community_name"] = server.name
|
results["community_name"] = server.name
|
||||||
|
|
||||||
# Check permissions using effective server permissions for the bot
|
# Check permissions using effective server permissions for the bot
|
||||||
# Use current_user.id since @me might not be supported in all member endpoints
|
|
||||||
try:
|
try:
|
||||||
me = await server.fetch_member(current_user.id)
|
me = await server.fetch_member(current_user.id)
|
||||||
# We use server.permissions_for(me) instead of me.server_permissions
|
|
||||||
# to avoid cache-related NoData exceptions.
|
|
||||||
# safe=False allows calculating even if some roles aren't in local cache.
|
|
||||||
perms = server.permissions_for(me, safe=False)
|
perms = server.permissions_for(me, safe=False)
|
||||||
|
|
||||||
results["permissions"] = {
|
results["permissions"] = {
|
||||||
|
|
@ -166,9 +185,8 @@ class StoatWriter:
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Stoat validation failed: {str(e)}")
|
logger.error(f"Stoat validation failed: {str(e)}")
|
||||||
finally:
|
|
||||||
await client.close()
|
|
||||||
|
|
||||||
|
self._validation_cache = results
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def get_channels(self) -> List[Dict[str, Any]]:
|
async def get_channels(self) -> List[Dict[str, Any]]:
|
||||||
|
|
@ -217,6 +235,9 @@ class StoatWriter:
|
||||||
try:
|
try:
|
||||||
if type == 4: # Category
|
if type == 4: # Category
|
||||||
# The POST /categories endpoint throws 404 on some server versions, so we use server.edit(categories)
|
# The POST /categories endpoint throws 404 on some server versions, so we use server.edit(categories)
|
||||||
|
# Force refetch to ensure we have the absolute latest state before editing categories array
|
||||||
|
server = await self._get_server(populate_channels=True, force=True)
|
||||||
|
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
chars = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
chars = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||||
|
|
@ -234,7 +255,8 @@ class StoatWriter:
|
||||||
if not hasattr(new_cat, "role_permissions"): new_cat.role_permissions = {}
|
if not hasattr(new_cat, "role_permissions"): new_cat.role_permissions = {}
|
||||||
categories.append(new_cat)
|
categories.append(new_cat)
|
||||||
|
|
||||||
await server.edit(categories=categories)
|
# server.edit returns a new Server object on some versions/implementations; maintain local reference
|
||||||
|
self._server = await server.edit(categories=categories)
|
||||||
return new_id
|
return new_id
|
||||||
else: # Text Channel
|
else: # Text Channel
|
||||||
ch = await server.create_text_channel(name=name, description=topic)
|
ch = await server.create_text_channel(name=name, description=topic)
|
||||||
|
|
@ -676,4 +698,13 @@ class StoatWriter:
|
||||||
return {"emojis": count, "stickers": 0}
|
return {"emojis": count, "stickers": 0}
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
pass
|
client = self.client
|
||||||
|
self.client = None # Atomic clear to prevent new usage
|
||||||
|
self._me = None
|
||||||
|
self._server = None
|
||||||
|
if client:
|
||||||
|
try:
|
||||||
|
await client.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error closing Stoat client: {e}")
|
||||||
|
self._validation_cache = None
|
||||||
|
|
|
||||||
|
|
@ -473,8 +473,12 @@ class ConfigScreen(Screen):
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ReaperApp(App):
|
class ReaperApp(App):
|
||||||
|
SCREENS = {
|
||||||
|
"config_selection": ConfigSelectionScreen,
|
||||||
|
}
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.push_screen(ConfigSelectionScreen())
|
self.push_screen("config_selection")
|
||||||
self.theme = "dracula"
|
self.theme = "dracula"
|
||||||
|
|
||||||
def action_screenshot(self, filename: str | None = None, path: str | None = None) -> None:
|
def action_screenshot(self, filename: str | None = None, path: str | None = None) -> None:
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ class ShuttlePane(Container):
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self._rebuild_engine()
|
self._rebuild_engine()
|
||||||
|
# run_validate is handled by the writer's internal caching now to prevent log flooding
|
||||||
self.run_validate()
|
self.run_validate()
|
||||||
|
|
||||||
def reload_config(self) -> None:
|
def reload_config(self) -> None:
|
||||||
|
|
@ -386,6 +387,9 @@ class ShuttlePane(Container):
|
||||||
try:
|
try:
|
||||||
await self.engine.start_connections()
|
await self.engine.start_connections()
|
||||||
connections_started = True
|
connections_started = True
|
||||||
|
# Sync all entities before preview/confirmation
|
||||||
|
modal.set_status("Synchronizing entity mappings...")
|
||||||
|
await self._perform_auto_matching()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not pre-connect for Clone preview: {e}")
|
logger.warning(f"Could not pre-connect for Clone preview: {e}")
|
||||||
|
|
||||||
|
|
@ -394,7 +398,7 @@ class ShuttlePane(Container):
|
||||||
|
|
||||||
modal.set_status(f"Awaiting Confirmation for {len(selections)} Operations...")
|
modal.set_status(f"Awaiting Confirmation for {len(selections)} Operations...")
|
||||||
|
|
||||||
# Fetch and display live preview with presence highlighting
|
# Fetch and display live preview auto-matching already ran above
|
||||||
preview = await self._fetch_clone_preview(selections) if connections_started else {}
|
preview = await self._fetch_clone_preview(selections) if connections_started else {}
|
||||||
|
|
||||||
if connections_started:
|
if connections_started:
|
||||||
|
|
@ -503,6 +507,7 @@ class ShuttlePane(Container):
|
||||||
modal.phase_report("Batch Operation", "error", show_back=False)
|
modal.phase_report("Batch Operation", "error", show_back=False)
|
||||||
finally:
|
finally:
|
||||||
self.engine.is_running = False
|
self.engine.is_running = False
|
||||||
|
# Ensure we only close if we actually started them and no other task is inheriting
|
||||||
await self.engine.close_connections()
|
await self.engine.close_connections()
|
||||||
|
|
||||||
@work(exclusive=True)
|
@work(exclusive=True)
|
||||||
|
|
@ -775,8 +780,12 @@ class ShuttlePane(Container):
|
||||||
# Show info container
|
# Show info container
|
||||||
modal.show_info("[bold cyan]Message Migration Ready[/bold cyan]", "Checking channel permissions...")
|
modal.show_info("[bold cyan]Message Migration Ready[/bold cyan]", "Checking channel permissions...")
|
||||||
|
|
||||||
modal.set_status("Fetching channels...")
|
modal.set_status("Connecting to Servers...")
|
||||||
await self.engine.start_connections()
|
await self.engine.start_connections()
|
||||||
|
|
||||||
|
# Sync all entities before confirmation
|
||||||
|
modal.set_status("Synchronizing entity mappings...")
|
||||||
|
await self._perform_auto_matching()
|
||||||
|
|
||||||
full_d = await self.engine.discord_reader.get_channels()
|
full_d = await self.engine.discord_reader.get_channels()
|
||||||
|
|
||||||
|
|
@ -1052,6 +1061,10 @@ class ShuttlePane(Container):
|
||||||
try:
|
try:
|
||||||
await self.engine.start_target_only()
|
await self.engine.start_target_only()
|
||||||
target_started = True
|
target_started = True
|
||||||
|
|
||||||
|
# Sync all entities before confirmation (even in danger zone)
|
||||||
|
modal.set_status("Synchronizing entity mappings...")
|
||||||
|
await self._perform_auto_matching()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not pre-connect for DZ preview: {e}")
|
logger.warning(f"Could not pre-connect for DZ preview: {e}")
|
||||||
|
|
||||||
|
|
@ -1207,48 +1220,133 @@ class ShuttlePane(Container):
|
||||||
async def _fetch_clone_preview(self, selections: list[str]) -> dict[str, Any]:
|
async def _fetch_clone_preview(self, selections: list[str]) -> dict[str, Any]:
|
||||||
"""Fetches preview data from Discord (source server) for cloning confirmation,
|
"""Fetches preview data from Discord (source server) for cloning confirmation,
|
||||||
comparing with existing entities on the target server for presence highlighting."""
|
comparing with existing entities on the target server for presence highlighting."""
|
||||||
preview = {}
|
async def _perform_auto_matching(self):
|
||||||
|
"""Matches Discord entities (roles, channels, emojis, stickers) with target platform items by name."""
|
||||||
|
if not self.engine:
|
||||||
|
return
|
||||||
|
|
||||||
reader = self.engine.discord_reader
|
reader = self.engine.discord_reader
|
||||||
writer = self.engine.writer
|
writer = self.engine.writer
|
||||||
is_fluxer = self.target_platform == "fluxer"
|
is_fluxer = self.target_platform == "fluxer"
|
||||||
|
|
||||||
# Fetch target data for comparison
|
# 1. Fetch target data for comparison
|
||||||
target_roles = []
|
target_roles_map = {}
|
||||||
target_channels = []
|
target_chans_map = {}
|
||||||
|
target_cats_map = {}
|
||||||
|
target_emojis_map = {}
|
||||||
|
target_stickers_map = {}
|
||||||
try:
|
try:
|
||||||
if is_fluxer:
|
if is_fluxer:
|
||||||
target_roles_raw = await writer.client.get_guild_roles(self.engine.config.target_server_id)
|
target_roles_raw = await writer.client.get_guild_roles(self.engine.config.target_server_id)
|
||||||
target_roles = [r.get("name", "").lower() for r in target_roles_raw]
|
target_roles_map = {r.get("name", "").lower(): str(r.get("id")) for r in target_roles_raw}
|
||||||
|
|
||||||
|
target_emojis_raw = await writer.client.get_guild_emojis(self.engine.config.target_server_id)
|
||||||
|
target_emojis_map = {e.get("name", "").lower(): str(e.get("id")) for e in target_emojis_raw}
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_stickers_raw = await writer.client.get_guild_stickers(self.engine.config.target_server_id)
|
||||||
|
target_stickers_map = {s.get("name", "").lower(): str(s.get("id")) for s in target_stickers_raw}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
server = await writer._get_server()
|
server = await writer._get_server()
|
||||||
target_roles = [r.name.lower() for r in server.roles.values()]
|
target_roles_map = {r.name.lower(): str(r.id) for r in server.roles.values()}
|
||||||
|
|
||||||
|
target_emojis_raw = await server.fetch_emojis()
|
||||||
|
target_emojis_map = {e.name.lower(): str(e.id) for e in target_emojis_raw}
|
||||||
|
|
||||||
target_chans_raw = await writer.get_channels()
|
target_chans_raw = await writer.get_channels()
|
||||||
target_channels = [c.get("name", "").lower() for c in target_chans_raw]
|
target_chans_map = {c.get("name", "").lower(): str(c.get("id")) for c in target_chans_raw if c.get("type") != 4}
|
||||||
|
target_cats_map = {c.get("name", "").lower(): str(c.get("id")) for c in target_chans_raw if c.get("type") == 4}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Clone Preview: failed to fetch target data for comparison: {e}")
|
logger.warning(f"Auto-matching: failed to fetch target data: {e}")
|
||||||
|
return # Cannot match without target data
|
||||||
|
|
||||||
|
# 2. Match entities
|
||||||
|
try:
|
||||||
|
# Roles
|
||||||
|
src_roles = await reader.get_roles()
|
||||||
|
for r in src_roles:
|
||||||
|
name_l = r.name.lower()
|
||||||
|
if name_l in target_roles_map and not self.engine.state.get_target_role_id(r.id):
|
||||||
|
logger.info(f"Auto-matched Role: {r.name} -> {target_roles_map[name_l]}")
|
||||||
|
self.engine.state.set_target_role_mapping(r.id, target_roles_map[name_l])
|
||||||
|
|
||||||
|
# Categories
|
||||||
|
src_cats = await reader.get_categories()
|
||||||
|
for cat in src_cats:
|
||||||
|
name_l = cat.name.lower()
|
||||||
|
if name_l in target_cats_map and not self.engine.state.get_target_category_id(cat.id):
|
||||||
|
logger.info(f"Auto-matched Category: {cat.name} -> {target_cats_map[name_l]}")
|
||||||
|
self.engine.state.set_target_category_mapping(cat.id, target_cats_map[name_l])
|
||||||
|
|
||||||
|
# Channels
|
||||||
|
src_channels = await reader.get_channels()
|
||||||
|
for ch in src_channels:
|
||||||
|
name_l = ch.name.lower()
|
||||||
|
if name_l in target_chans_map and not self.engine.state.get_target_channel_id(ch.id):
|
||||||
|
logger.info(f"Auto-matched Channel: {ch.name} -> {target_chans_map[name_l]}")
|
||||||
|
self.engine.state.set_target_channel_mapping(ch.id, target_chans_map[name_l])
|
||||||
|
|
||||||
|
# Emojis
|
||||||
|
src_emojis = await reader.get_emojis()
|
||||||
|
for e in src_emojis:
|
||||||
|
name_l = e.name.lower()
|
||||||
|
if name_l in target_emojis_map and not self.engine.state.get_target_emoji_id(e.id):
|
||||||
|
logger.info(f"Auto-matched Emoji: {e.name} -> {target_emojis_map[name_l]}")
|
||||||
|
self.engine.state.set_target_emoji_mapping(e.id, target_emojis_map[name_l])
|
||||||
|
|
||||||
|
# Stickers
|
||||||
|
if is_fluxer:
|
||||||
|
src_stickers = await reader.get_stickers()
|
||||||
|
for s in src_stickers:
|
||||||
|
name_l = s.name.lower()
|
||||||
|
if name_l in target_stickers_map and not self.engine.state.get_target_sticker_id(s.id):
|
||||||
|
logger.info(f"Auto-matched Sticker: {s.name} -> {target_stickers_map[name_l]}")
|
||||||
|
self.engine.state.set_target_sticker_mapping(s.id, target_stickers_map[name_l])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Auto-matching error: {e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"target_roles": target_roles_map,
|
||||||
|
"target_channels": target_chans_map,
|
||||||
|
"target_categories": target_cats_map,
|
||||||
|
"target_emojis": target_emojis_map,
|
||||||
|
"target_stickers": target_stickers_map
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _fetch_clone_preview(self, selections: list[str]) -> dict[str, Any]:
|
||||||
|
"""Fetches preview data from Discord (source server) for cloning confirmation,
|
||||||
|
comparing with existing mappings in state-migration.json for presence highlighting."""
|
||||||
|
preview = {}
|
||||||
|
reader = self.engine.discord_reader
|
||||||
|
|
||||||
|
# We rely on the global auto-match that ran during connection
|
||||||
|
mapping_ch = self.engine.state.channel_map
|
||||||
|
mapping_cat = self.engine.state.category_map
|
||||||
|
mapping_role = self.engine.state.role_map
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if "sub_clone_roles" in selections:
|
if "sub_clone_roles" in selections:
|
||||||
roles = await reader.get_roles()
|
roles = await reader.get_roles()
|
||||||
preview["roles"] = [(r.name, r.name.lower() in target_roles) for r in roles]
|
# Highlight if existing in mapping
|
||||||
|
preview["roles"] = [(r.name, str(r.id) in mapping_role) for r in roles]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Clone Preview: failed to fetch roles: {e}")
|
logger.warning(f"Clone Preview: failed to fetch roles: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if "sub_clone_channels" in selections:
|
if "sub_clone_channels" in selections:
|
||||||
# Build hierarchy
|
|
||||||
src_categories = await reader.get_categories()
|
src_categories = await reader.get_categories()
|
||||||
src_channels = await reader.get_channels()
|
src_channels = await reader.get_channels()
|
||||||
|
|
||||||
# structure[cat_id] = (cat_name, cat_exists, [(ch_name, ch_exists), ...])
|
# Build hierarchy for preview
|
||||||
structure = {}
|
structure = {}
|
||||||
for cat in src_categories:
|
for cat in src_categories:
|
||||||
cat_exists = cat.name.lower() in target_channels
|
cat_exists = str(cat.id) in mapping_cat
|
||||||
structure[cat.id] = (cat.name, cat_exists, [])
|
structure[cat.id] = (cat.name, cat_exists, [])
|
||||||
|
|
||||||
for ch in src_channels:
|
for ch in src_channels:
|
||||||
ch_exists = ch.name.lower() in target_channels
|
ch_exists = str(ch.id) in mapping_ch
|
||||||
if ch.category_id in structure:
|
if ch.category_id in structure:
|
||||||
structure[ch.category_id][2].append((ch.name, ch_exists))
|
structure[ch.category_id][2].append((ch.name, ch_exists))
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue