fix back button behavior & improve logging info

This commit is contained in:
rambros 2026-03-03 16:44:36 +05:30
parent c7adc61a4a
commit eb029ac656
14 changed files with 283 additions and 242 deletions

View file

@ -31,11 +31,15 @@ class MigrationContext:
messages_file: str | Path = ""
if server_id != "unconfigured":
logger.info(f"Probing for existing migration folder for Server ID: {server_id}")
for d in Path(".").iterdir():
if d.is_dir() and d.name.endswith(f"-{server_id}"):
logger.info(f"Targeting existing folder: {d.name}")
state_file = d / "state-migration.json"
messages_file = d / "message-tracker.json"
break
else:
logger.info(f"No existing folder found for {server_id}. A new one will be created upon first state change.")
self.state = MigrationState(
state_file=state_file,

View file

@ -179,6 +179,7 @@ class DiscordReader:
channel = await self.get_channel(channel_id)
if isinstance(channel, discord.TextChannel) or isinstance(channel, discord.Thread):
after = discord.Object(id=after_id) if after_id else None
logger.info(f"Fetching message history for {channel.name} ({channel.id}) oldest_first=True after={after_id}")
# To avoid exploding RAM, we yield items one by one
async for message in channel.history(limit=limit, oldest_first=True, after=after):
yield message

View file

@ -45,27 +45,33 @@ class MigrationState:
# 2. Load separate messages file
if self.messages_file and self.messages_file.exists():
with open(self.messages_file, "r", encoding="utf-8") as f:
msg_data = json.load(f)
# Check for new schema (nested under 'channels')
if "channels" in msg_data:
self.channel_messages = msg_data.get("channels", {})
else:
# Legacy schema detection & conversion to a default 'unknown_channel' just in case,
# though new migrations shouldn't hit this based on previous removals.
legacy_map = msg_data.get("messages", {})
legacy_ids = msg_data.get("last_message_ids", {})
legacy_times = msg_data.get("last_message_timestamps", {})
logger.info(f"Loading messages from {self.messages_file.name}")
try:
with open(self.messages_file, "r", encoding="utf-8") as f:
msg_data = json.load(f)
if legacy_map or legacy_ids or legacy_times:
self.channel_messages = {
"legacy_migrated_channel": {
"message_map": legacy_map,
"last_message_id": list(legacy_ids.values())[-1] if legacy_ids else "",
"last_message_timestamp": list(legacy_times.values())[-1] if legacy_times else ""
# Check for new schema (nested under 'channels')
if "channels" in msg_data:
self.channel_messages = msg_data.get("channels", {})
logger.debug(f"Loaded {len(self.channel_messages)} tracked channels.")
else:
logger.warning("Legacy schema or empty tracker detected in messages file.")
# Legacy schema detection & conversion to a default 'unknown_channel' just in case,
# though new migrations shouldn't hit this based on previous removals.
legacy_map = msg_data.get("messages", {})
legacy_ids = msg_data.get("last_message_ids", {})
legacy_times = msg_data.get("last_message_timestamps", {})
if legacy_map or legacy_ids or legacy_times:
self.channel_messages = {
"legacy_migrated_channel": {
"message_map": legacy_map,
"last_message_id": list(legacy_ids.values())[-1] if legacy_ids else "",
"last_message_timestamp": list(legacy_times.values())[-1] if legacy_times else ""
}
}
}
except Exception as e:
logger.error(f"Failed to load messages file: {e}")
@ -73,6 +79,8 @@ class MigrationState:
"""Saves only the core server configuration (channels, roles, emojis)."""
if not self.state_file:
return
logger.debug(f"Saving state to {self.state_file.name}")
data = {
"channels": self.channel_map,
"categories": self.category_map,
@ -81,18 +89,26 @@ class MigrationState:
"stickers": self.sticker_map,
"audit_log_channel": self.audit_log_channel
}
with open(self.state_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
try:
with open(self.state_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
except Exception as e:
logger.error(f"Failed to save state file: {e}")
def save_messages(self):
"""Saves only the message tracking data."""
if not self.messages_file:
return
logger.debug(f"Saving messages to {self.messages_file.name}")
data = {
"channels": self.channel_messages
}
with open(self.messages_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
try:
with open(self.messages_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
except Exception as e:
logger.error(f"Failed to save messages file: {e}")
# --- Type Specific Getters/Setters ---
@ -260,11 +276,13 @@ class MigrationState:
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}"
logger.info(f"Setting active migration folder: {new_folder}")
# 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:
# Check if it's actually in a server-specific folder (not roots)
if self.state_file.parent.name.endswith(f"-{server_id}"):
logger.info(f"Renaming active folder from {self.state_file.parent.name} to {new_folder.name}")
try:
self.state_file.parent.rename(new_folder)
except Exception as e:
@ -275,4 +293,5 @@ class MigrationState:
self.state_file = new_folder / "state-migration.json"
self.messages_file = new_folder / "message-tracker.json"
logger.debug("Re-loading data from new folder location.")
self.load()

View file

@ -10,6 +10,7 @@ async def sync_assets_state(context: MigrationContext):
"""
Scans Fluxer for emojis and stickers matching Discord names and updates state file mappings.
"""
logger.info("Synchronizing asset mappings (emojis/stickers) with Fluxer...")
discord_emojis = await context.discord_reader.get_emojis()
discord_stickers = await context.discord_reader.get_stickers()
@ -80,14 +81,16 @@ async def migrate_emojis(context: MigrationContext, progress_callback: Callable[
total = len(objs)
cloned_assets: dict[str, dict[str, str]] = {"Emoji": {}, "Sticker": {}}
if total == 0:
return cloned_assets
logger.info(f"Migrating {total} assets to Fluxer (Types: {', '.join(types_to_include)})...")
for idx, (obj, obj_type) in enumerate(objs):
if not context.is_running: break
if not context.is_running:
logger.warning("Asset migration interrupted.")
break
try:
if obj_type == "Emoji":
logger.debug(f"Migrating emoji: {obj.name}")
img_data = await context.discord_reader.download_emoji(obj)
fluxer_id = await context.fluxer_writer.create_emoji(
name=obj.name,
@ -97,6 +100,7 @@ async def migrate_emojis(context: MigrationContext, progress_callback: Callable[
context.state.set_emoji_mapping(str(obj.id), fluxer_id)
cloned_assets["Emoji"][obj.name] = fluxer_id
else:
logger.debug(f"Migrating sticker: {obj.name}")
img_data = await context.discord_reader.download_sticker(obj)
fluxer_id = await context.fluxer_writer.create_sticker(
name=obj.name,

View file

@ -99,16 +99,16 @@ async def analyze_migration(context: MigrationContext, source_channel_id: int, a
async def migrate_messages(context: MigrationContext, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, Any]:
"""Migrate messages for a specific channel and returns detailed statistics."""
stats = {
"messages": 0,
"attachments": 0,
"threads": 0,
"first_message_url": None,
"last_message_url": None
}
stats = {"messages": 0, "threads": 0, "attachments": 0}
logger.info(f"Starting message migration: Discord #{source_channel_id} -> Fluxer #{target_channel_id}")
if after_message_id:
logger.info(f"Resuming migration from after message ID: {after_message_id}")
try:
async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id):
if not context.is_running:
logger.warning("Migration interrupted by user (is_running=False)")
break
@ -190,6 +190,10 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
reply_to_fluxer_id = None
if msg.reference and msg.reference.message_id:
reply_to_fluxer_id = context.state.get_fluxer_message_id(target_channel_id, str(msg.reference.message_id))
if reply_to_fluxer_id:
logger.debug(f"Detected reply to Discord ID {msg.reference.message_id} -> Fluxer ID {reply_to_fluxer_id}")
else:
logger.debug(f"Reply target Discord ID {msg.reference.message_id} not found in current session map.")
fluxer_msg_id = await context.fluxer_writer.send_message(
channel_id=target_channel_id,
@ -210,6 +214,10 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
stats["messages"] += 1
context.state.increment_stats(target_channel_id, messages=1, files=len(files) if files else 0)
# Periodic log
if stats["messages"] % 50 == 0:
logger.info(f"Progress: Migrated {stats['messages']}/{total_to_process} messages in this channel.")
# Check for associated thread (Normal case: parent message is migrated)
if hasattr(msg, 'thread') and msg.thread:
thread = msg.thread

View file

@ -10,6 +10,7 @@ async def sync_roles_state(context: MigrationContext):
"""
Scans Fluxer for roles matching Discord names and updates state file mappings.
"""
logger.info("Synchronizing role mappings with Fluxer...")
discord_roles = await context.discord_reader.get_roles()
fluxer_roles = await context.fluxer_writer.client.get_guild_roles(context.config.fluxer_community_id)
@ -39,6 +40,7 @@ async def sync_roles_state(context: MigrationContext):
async def sync_permissions(context: MigrationContext, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None) -> dict:
"""Syncs category and channel role overrides/permissions."""
logger.info("Starting permissions synchronization...")
categories = await context.discord_reader.get_categories()
channels = await context.discord_reader.get_channels()
@ -56,7 +58,10 @@ async def sync_permissions(context: MigrationContext, progress_callback: Callabl
current_idx = 0
if total == 0:
logger.info("No mapped categories or channels found for permission sync.")
return synced_info
logger.info(f"Syncing permissions for {len(categories)} categories and {len(channels)} channels.")
async def _sync_overwrites(discord_item, fluxer_id):
"""Helper to sync role overwrites for a given channel or category."""
@ -120,6 +125,7 @@ async def sync_permissions(context: MigrationContext, progress_callback: Callabl
current_idx += 1
if progress_callback: await progress_callback(channel.name, current_idx, total)
logger.info(f"Permissions sync complete: {len(synced_info['categories_synced'])} categories, {len(synced_info['channels_synced'])} channels.")
return synced_info
@ -133,12 +139,14 @@ async def migrate_roles(context: MigrationContext, progress_callback: Callable[[
total = len(roles)
cloned_role_names = []
if total == 0:
return cloned_role_names
logger.info(f"Migrating {total} roles to Fluxer...")
for idx, role in enumerate(roles):
if not context.is_running: break
if not context.is_running:
logger.warning("Role migration interrupted.")
break
logger.debug(f"Creating role: {role.name}")
fluxer_id = await context.fluxer_writer.create_role(
name=role.name,
color=role.color.value,

View file

@ -14,10 +14,12 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal
if "name" in components:
try:
name = metadata.get("name")
logger.info(f"Syncing server name: {name}")
await context.writer.update_guild_metadata(name=name)
cloned_data["name"] = name
await progress_callback("Server Name", "DONE")
except Exception:
except Exception as e:
logger.error(f"Failed to sync server name: {e}")
await progress_callback("Server Name", "ERROR")
# 2. Sync Icon
@ -28,12 +30,15 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal
icon_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.icon)
if icon_bytes:
logger.info(f"Uploading server icon ({len(icon_bytes)} bytes)...")
await context.writer.update_guild_metadata(icon=icon_bytes)
cloned_data["icon"] = icon_bytes
await progress_callback("Server Icon", "DONE")
else:
logger.info("No server icon found to sync.")
await progress_callback("Server Icon", "SKIP")
except Exception:
except Exception as e:
logger.error(f"Failed to sync server icon: {e}")
await progress_callback("Server Icon", "ERROR")
# 3. Sync Banner
@ -44,12 +49,15 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal
banner_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.banner)
if banner_bytes:
logger.info(f"Uploading server banner ({len(banner_bytes)} bytes)...")
await context.writer.update_guild_metadata(banner=banner_bytes)
cloned_data["banner"] = banner_bytes
await progress_callback("Server Banner", "DONE")
else:
logger.info("No server banner found to sync.")
await progress_callback("Server Banner", "SKIP")
except Exception:
except Exception as e:
logger.error(f"Failed to sync server banner: {e}")
await progress_callback("Server Banner", "ERROR")
return cloned_data

View file

@ -10,6 +10,7 @@ async def sync_assets_state(context: MigrationContext):
"""
Scans Stoat for emojis matching Discord names and updates state file mappings.
"""
logger.info("Synchronizing asset mappings (emojis) with Stoat...")
discord_emojis = await context.discord_reader.get_emojis()
# Stickers not supported on Stoat based on current library investigation
@ -74,11 +75,16 @@ async def migrate_emojis(context: MigrationContext, progress_callback: Callable[
if total == 0:
return cloned_assets
logger.info(f"Migrating {total} assets to Stoat (Types: {', '.join(types_to_include)})...")
for idx, (obj, obj_type) in enumerate(objs):
if not context.is_running: break
if not context.is_running:
logger.warning("Stoat asset migration interrupted.")
break
try:
if obj_type == "Emoji":
logger.debug(f"Migrating emoji to Stoat: {obj.name}")
img_data = await context.discord_reader.download_emoji(obj)
stoat_id = await context.writer.create_emoji(
name=obj.name,

View file

@ -98,16 +98,16 @@ async def analyze_migration(context: MigrationContext, source_channel_id: int, a
async def migrate_messages(context: MigrationContext, source_channel_id: int, target_channel_id: str, after_message_id: int | None = None, progress_callback: Callable[[Dict[str, Any]], Awaitable[None]] | None = None) -> Dict[str, Any]:
"""Migrate messages for a specific channel using Stoat masquerade for author impersonation."""
stats = {
"messages": 0,
"attachments": 0,
"threads": 0,
"first_message_url": None,
"last_message_url": None
}
stats = {"messages": 0, "threads": 0, "attachments": 0}
logger.info(f"Starting message migration: Discord #{source_channel_id} -> Stoat #{target_channel_id}")
if after_message_id:
logger.info(f"Resuming migration from after message ID: {after_message_id}")
try:
async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id):
if not context.is_running:
logger.warning("Migration interrupted by user (is_running=False)")
break
@ -187,6 +187,10 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
reply_to_stoat_id = None
if msg.reference and msg.reference.message_id:
reply_to_stoat_id = context.state.get_target_message_id(target_channel_id, str(msg.reference.message_id))
if reply_to_stoat_id:
logger.debug(f"Detected reply to Discord ID {msg.reference.message_id} -> Stoat ID {reply_to_stoat_id}")
else:
logger.debug(f"Reply target Discord ID {msg.reference.message_id} not found in current session map.")
stoat_msg_id = await context.stoat_writer.send_message(
channel_id=target_channel_id,
@ -206,6 +210,10 @@ async def migrate_messages(context: MigrationContext, source_channel_id: int, ta
context.state.update_last_message_id(target_channel_id, str(msg.id))
stats["messages"] += 1
context.state.increment_stats(target_channel_id, messages=1, files=len(files) if files else 0)
# Periodic log
if stats["messages"] % 50 == 0:
logger.info(f"Progress: Migrated {stats['messages']} messages in this channel.")
# Check for associated thread (Normal case: parent message is migrated)
if hasattr(msg, 'thread') and msg.thread:

View file

@ -11,6 +11,7 @@ async def sync_roles_state(context: MigrationContext):
"""
Scans Stoat for roles matching Discord names and updates state file mappings.
"""
logger.info("Synchronizing role mappings with Stoat...")
discord_roles = await context.discord_reader.get_roles()
server = await context.stoat_writer._get_server()
stoat_roles = list(server.roles.values())
@ -41,6 +42,7 @@ async def sync_roles_state(context: MigrationContext):
async def sync_permissions(context: MigrationContext, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None) -> dict:
"""Syncs category and channel role overrides/permissions."""
logger.info("Starting permissions synchronization for Stoat...")
discord_categories = await context.discord_reader.get_categories()
discord_channels = await context.discord_reader.get_channels()
@ -58,7 +60,10 @@ async def sync_permissions(context: MigrationContext, progress_callback: Callabl
current_idx = 0
if total == 0:
logger.info("No mapped categories or channels found for Stoat permission sync.")
return synced_info
logger.info(f"Syncing permissions for {len(mapped_categories)} categories and {len(mapped_channels)} channels to Stoat.")
cat_map = {cat.id: cat for cat in discord_categories}
cat_names = {cat.id: cat.name for cat in discord_categories}
@ -160,6 +165,7 @@ async def sync_permissions(context: MigrationContext, progress_callback: Callabl
await asyncio.sleep(context.config.migration.rate_limit_delay_seconds)
logger.info(f"Stoat permissions sync complete: {len(synced_info['categories_synced'])} categories, {len(synced_info['channels_synced'])} channels.")
return synced_info
@ -176,20 +182,17 @@ async def migrate_roles(context: MigrationContext, progress_callback: Callable[[
except Exception as e:
logger.error(f"Failed to sync default permissions: {e}")
roles = await context.discord_reader.get_roles()
if not force:
roles = [r for r in roles if not context.state.get_target_role_id(str(r.id))]
total = len(roles)
cloned_role_names = []
if total == 0:
return cloned_role_names
logger.info(f"Migrating {total} roles to Stoat...")
for idx, role in enumerate(roles):
if not context.is_running: break
if not context.is_running:
logger.warning("Stoat role migration interrupted.")
break
logger.debug(f"Creating role in Stoat: {role.name}")
stoat_id = await context.stoat_writer.create_role(
name=role.name,
color=role.color.value,

View file

@ -14,10 +14,12 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal
if "name" in components:
try:
name = metadata.get("name")
logger.info(f"Syncing server name to Stoat: {name}")
await context.writer.update_guild_metadata(name=name)
cloned_data["name"] = name
await progress_callback("Server Name", "DONE")
except Exception:
except Exception as e:
logger.error(f"Failed to sync server name to Stoat: {e}")
await progress_callback("Server Name", "ERROR")
# 2. Sync Icon
@ -28,12 +30,15 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal
icon_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.icon)
if icon_bytes:
logger.info(f"Uploading server icon to Stoat ({len(icon_bytes)} bytes)...")
await context.writer.update_guild_metadata(icon=icon_bytes)
cloned_data["icon"] = icon_bytes
await progress_callback("Server Icon", "DONE")
else:
logger.debug("No server icon found to sync to Stoat.")
await progress_callback("Server Icon", "SKIP")
except Exception:
except Exception as e:
logger.error(f"Failed to sync server icon to Stoat: {e}")
await progress_callback("Server Icon", "ERROR")
# 3. Sync Banner
@ -44,12 +49,15 @@ async def sync_server_metadata(context: MigrationContext, progress_callback: Cal
banner_bytes = await context.discord_reader.download_asset(context.discord_reader.guild.banner)
if banner_bytes:
logger.info(f"Uploading server banner to Stoat ({len(banner_bytes)} bytes)...")
await context.writer.update_guild_metadata(banner=banner_bytes)
cloned_data["banner"] = banner_bytes
await progress_callback("Server Banner", "DONE")
else:
logger.debug("No server banner found to sync to Stoat.")
await progress_callback("Server Banner", "SKIP")
except Exception:
except Exception as e:
logger.error(f"Failed to sync server banner to Stoat: {e}")
await progress_callback("Server Banner", "ERROR")
return cloned_data

View file

@ -63,12 +63,12 @@ class BackupPane(Container):
# ── validation ────────────────────────────────────────────────────────
@work(exclusive=True, thread=True)
@work(exclusive=True)
async def _validate(self) -> None:
fillers = ["DISCORD_BOT_TOKEN", "000000000000000000", "DISCORD_SERVER_ID", "", None]
d_token = self.config.discord_bot_token
if d_token in fillers or self.config.discord_server_id in fillers:
self.app.call_from_thread(self._update_ui, "[red]NOT CONFIGURED[/red]", "", "", False)
self._update_ui("[red]NOT CONFIGURED[/red]", "", "", False)
return
try:
res = await self.engine.discord_reader.validate()
@ -83,9 +83,9 @@ class BackupPane(Container):
if info:
backup_text = f"Last backup: [cyan]{info}[/cyan]"
self.app.call_from_thread(self._update_ui, s_text, b_text, backup_text, valid)
self._update_ui(s_text, b_text, backup_text, valid)
except Exception as e:
self.app.call_from_thread(self._update_ui, f"[red]Error: {e}[/red]", "", "", False)
self._update_ui(f"[red]Error: {e}[/red]", "", "", False)
def _get_backup_info(self) -> str | None:
profile_file = Path(f"Reaper-{self.cfg_name}") / "server_profile.json"
@ -121,49 +121,49 @@ class BackupPane(Container):
# ── workers ───────────────────────────────────────────────────────────
@work(exclusive=True, thread=True)
@work(exclusive=True)
async def run_backup_profile(self) -> None:
modal = ProgressScreen()
self.app.call_from_thread(self.app.push_screen, modal)
self.app.push_screen(modal)
await asyncio.sleep(0.1)
self.app.call_from_thread(modal.phase_progress)
modal.phase_progress()
try:
self.app.call_from_thread(modal.set_status, "Starting readers...")
modal.set_status("Starting readers...")
await self.engine.discord_reader.start()
await self.exporter.setup()
self.app.call_from_thread(modal.write, "[yellow]Backing up server profile & skeleton...[/yellow]")
modal.write("[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, "Exporting structure...")
modal.write("Exporting structure...")
_, cat_count, chan_count = await self.exporter.export_channels_structure()
self.app.call_from_thread(modal.write, "Exporting assets...")
modal.write("Exporting assets...")
e_count, s_count = await self.exporter.export_assets()
self.app.call_from_thread(modal.write, f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]")
self.app.call_from_thread(modal.write, f"- {e_count} emojis, {s_count} stickers.")
self.app.call_from_thread(modal.phase_report, "Profile Backup")
modal.write(f"[bold green]Server Profile backed up to: {self.exporter.export_path}[/bold green]")
modal.write(f"- {e_count} emojis, {s_count} stickers.")
modal.phase_report("Profile Backup")
except discord.Forbidden as e:
self.app.call_from_thread(modal.write, f"[bold red]Backup failed: {e}[/bold red]")
self.app.call_from_thread(modal.phase_report, "Profile Backup", "error")
modal.write(f"[bold red]Backup failed: {e}[/bold red]")
modal.phase_report("Profile Backup", "error")
except Exception as e:
self.app.call_from_thread(modal.write, f"[bold red]Error: {e}[/bold red]")
self.app.call_from_thread(modal.phase_report, "Profile Backup", "error")
modal.write(f"[bold red]Error: {e}[/bold red]")
modal.phase_report("Profile Backup", "error")
finally:
await self.engine.close_connections()
@work(exclusive=True, thread=True)
@work(exclusive=True)
async def run_backup_messages(self) -> None:
modal_prog = ProgressScreen()
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()
@ -178,8 +178,8 @@ class BackupPane(Container):
]
if not eligible_channels:
self.app.call_from_thread(modal_prog.write, "[yellow]No text/news channels found to backup.[/yellow]")
self.app.call_from_thread(modal_prog.allow_close)
modal_prog.write("[yellow]No text/news channels found to backup.[/yellow]")
modal_prog.allow_close()
return
any_found = False
@ -189,7 +189,7 @@ class BackupPane(Container):
any_found = True
backed_up_ids.add(chan.id)
self.app.call_from_thread(self.app.pop_screen)
self.app.pop_screen()
while True:
loop = asyncio.get_running_loop()
@ -199,8 +199,7 @@ class BackupPane(Container):
if not future.done():
future.set_result(reply)
self.app.call_from_thread(
self.app.push_screen,
self.app.push_screen(
ChannelSelectScreen(eligible_channels, cat_map, backed_up_ids, any_found),
check_channels,
)
@ -214,69 +213,69 @@ class BackupPane(Container):
selected_channels = [c for c in eligible_channels if c.id in selected_ids]
# Phase 2: Confirmation
self.app.call_from_thread(self.app.push_screen, modal_prog)
self.app.push_screen(modal_prog)
await asyncio.sleep(0.1)
msg = "Sync existing backups" if not force_overwrite else "Overwriting existing backups"
self.app.call_from_thread(modal_prog.set_status, f"Awaiting Confirmation to backup [bold]{len(selected_channels)}[/bold] channels...")
self.app.call_from_thread(modal_prog.show_info, f"[cyan]{msg}[/cyan]", f"Targets: {', '.join([c.name for c in selected_channels[:3]])}{'...' if len(selected_channels) > 3 else ''}")
modal_prog.set_status(f"Awaiting Confirmation to backup [bold]{len(selected_channels)}[/bold] channels...")
modal_prog.show_info(f"[cyan]{msg}[/cyan]", f"Targets: {', '.join([c.name for c in selected_channels[:3]])}{'...' if len(selected_channels) > 3 else ''}")
choice = await modal_prog.phase_wait_confirm()
if choice == "btn_back":
self.app.call_from_thread(modal_prog.dismiss)
modal_prog.dismiss()
continue
elif choice == "btn_main_menu":
self.app.call_from_thread(modal_prog.dismiss)
self.app.call_from_thread(self.app.switch_screen, "config_selection")
modal_prog.dismiss()
self.app.switch_screen("config_selection")
return
# Proceed to progress
break
self.app.call_from_thread(modal_prog.phase_progress)
modal_prog.phase_progress()
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, f"[yellow]Starting backup for {total_chans} channels...[/yellow]")
modal_prog.set_status("Backing up messages...")
modal_prog.write(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, f"[cyan]{label}: {chan.name}[/cyan]")
modal_prog.write(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, f"[green]Completed: {chan.name}[/green]")
modal_prog.write(f"[green]Completed: {chan.name}[/green]")
await self.exporter.export_metadata()
self.app.call_from_thread(modal_prog.write, "[bold green]Message backup complete![/bold green]")
self.app.call_from_thread(modal_prog.phase_report, "Message Backup")
modal_prog.write("[bold green]Message backup complete![/bold green]")
modal_prog.phase_report("Message Backup")
except Exception as e:
self.app.call_from_thread(modal_prog.write, f"[bold red]Message backup failed: {e}[/bold red]")
self.app.call_from_thread(modal_prog.phase_report, "Message Backup", "error")
modal_prog.write(f"[bold red]Message backup failed: {e}[/bold red]")
modal_prog.phase_report("Message Backup", "error")
finally:
await self.engine.close_connections()
@work(exclusive=True, thread=True)
@work(exclusive=True)
async def run_backup_sync(self) -> None:
modal_prog = ProgressScreen()
self.app.call_from_thread(self.app.push_screen, modal_prog)
self.app.push_screen(modal_prog)
await asyncio.sleep(0.1)
self.app.call_from_thread(modal_prog.phase_progress)
modal_prog.phase_progress()
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, "Updating structure...")
modal_prog.write("Updating structure...")
await self.exporter.export_metadata()
await self.exporter.download_server_assets()
await self.exporter.export_channels_structure()
@ -294,25 +293,25 @@ class BackupPane(Container):
]
if not selected_channels:
self.app.call_from_thread(modal_prog.write, "[yellow]No existing backups found to sync.[/yellow]")
modal_prog.write("[yellow]No existing backups found to sync.[/yellow]")
else:
self.app.call_from_thread(modal_prog.write, f"[yellow]Syncing {len(selected_channels)} channels...[/yellow]")
modal_prog.write(f"[yellow]Syncing {len(selected_channels)} channels...[/yellow]")
for chan in selected_channels:
self.app.call_from_thread(modal_prog.write, f"[cyan]Syncing: {chan.name}[/cyan]")
modal_prog.write(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, f"[green]Synced: {chan.name}[/green]")
modal_prog.write(f"[green]Synced: {chan.name}[/green]")
await self.exporter.export_metadata()
self.app.call_from_thread(modal_prog.write, "[bold green]Sync operation complete![/bold green]")
self.app.call_from_thread(modal_prog.phase_report, "Backup Sync")
modal_prog.write("[bold green]Sync operation complete![/bold green]")
modal_prog.phase_report("Backup Sync")
except Exception as e:
self.app.call_from_thread(modal_prog.write, f"[bold red]Sync failed: {e}[/bold red]")
self.app.call_from_thread(modal_prog.phase_report, "Backup Sync", "error")
modal_prog.write(f"[bold red]Sync failed: {e}[/bold red]")
modal_prog.phase_report("Backup Sync", "error")
finally:
await self.engine.close_connections()

View file

@ -365,17 +365,20 @@ class OptionSelectModal(ModalScreen[list[str]]):
OptionSelectModal { align: center middle; }
#opt_dialog {
width: 60;
height: auto;
height: 80%;
border: solid green;
background: $surface;
padding: 1 2;
}
#opt_title { text-style: bold; margin-bottom: 1; text-align: center; width: 100%; }
#opt_scroll { margin-bottom: 1; border: solid $primary; max-height: 12; padding: 1; }
#opt_scroll { margin-bottom: 1; height: 1fr; overflow-y: auto; }
#opt_buttons { height: auto; }
#opt_buttons Button { width: 1fr; margin: 0 1; }
#opt_batch_buttons { height: auto; margin-bottom: 1; }
#opt_batch_buttons Button { width: 1fr; margin: 0 1; }
RadioButton { background: transparent; }
RadioButton:focus { background: $accent 20%; }
"""
def __init__(self, title: str, options: list[tuple[str, str]]):
@ -391,7 +394,7 @@ class OptionSelectModal(ModalScreen[list[str]]):
yield Button("Select All", id="btn_opt_all")
yield Button("Deselect All", id="btn_opt_none")
with VerticalScroll(id="opt_scroll"):
with Vertical(id="opt_scroll"):
for opt_id, label in self._options:
yield RadioButton(label, id=f"opt_{opt_id}")

View file

@ -319,6 +319,7 @@ class ShuttlePane(Container):
choice = await modal.phase_wait_confirm()
if choice == "btn_back":
modal.dismiss()
self._open_clone_menu()
return
elif choice == "btn_main_menu":
modal.dismiss()
@ -358,6 +359,7 @@ class ShuttlePane(Container):
choice = await modal.phase_wait_confirm()
if choice == "btn_back":
modal.dismiss()
self._open_sync_menu()
return
elif choice == "btn_main_menu":
modal.dismiss()
@ -575,12 +577,14 @@ class ShuttlePane(Container):
async def update_scan(current_stats):
modal.set_status(f"[cyan]Scanned {current_stats['messages']} items...")
logger.info(f"Analyzing message history for Discord #{source_channel.name}...")
stats_analysis = await migrate_mod.analyze_migration(
self.engine,
source_channel_id=source_channel.id,
after_message_id=int(last_migrated) if last_migrated else None,
progress_callback=update_scan,
)
logger.info(f"Analysis complete: {stats_analysis['messages']} new messages found.")
self.engine.is_running = False
# Set initial total stats for the confirmation block
@ -599,6 +603,7 @@ class ShuttlePane(Container):
# Phase 2: Confirmation
choice = await modal.phase_wait_confirm(show_continue=has_previous)
logger.info(f"User confirmation choice: {choice}")
if choice == "btn_back":
modal.dismiss()
continue # Return to channel picker
@ -611,11 +616,15 @@ class ShuttlePane(Container):
after_id = None
if choice == "btn_continue" and last_migrated:
logger.info("Proceeding with 'Continue Migration' (incremental sink).")
after_id = int(last_migrated)
elif choice == "btn_start_id":
# Fallback to full for now since we don't have an ID input dialog yet
modal.write("[yellow]Custom Message ID start not fully implemented, starting from beginning.[/yellow]")
logger.info("Proceeding with 'Start from ID' (fallback to begin).")
after_id = None
else:
logger.info("Proceeding with 'Start from First' (clean sink).")
# If we are here, we are proceeding with migration
break
@ -629,6 +638,7 @@ class ShuttlePane(Container):
total_threads = stats_analysis["threads"]
total_attachments = stats_analysis["attachments"]
logger.info(f"Execution started for #{source_channel.name} -> {platform_name} @ {target_channel.get('name')}")
self.engine.is_running = True
async def update_msg(current_stats):
@ -684,164 +694,116 @@ class ShuttlePane(Container):
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"),
("dz_del_channels", "Delete ALL Channels & Categories"),
("dz_reset_perms", "Reset ALL Channel Permissions"),
("dz_del_roles", "Delete ALL Roles"),
("dz_del_assets", "Delete ALL Emojis & Stickers"),
]
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 on_result(selections: list[str] | None):
if selections:
self.run_batch_danger(selections)
self.app.push_screen(OptionSelectModal("⚠ DANGER ZONE ⚠", options), on_result)
def _confirm_danger(self, message: str, callback):
async def do_confirm():
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.set_status(f"[bold red]DANGER:[/bold red] {message}")
@work(exclusive=True)
async def run_batch_danger(self, selections: list[str]) -> None:
modal = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
try:
modal.set_status(f"Awaiting Confirmation for {len(selections)} Destructive Operations...")
modal.write("[bold red]WARNING: THIS WILL DELETE DATA PERMANENTLY! MUST PROCEED TO CONTINUE.[/bold red]")
choice = await modal.phase_wait_confirm()
modal.dismiss()
if choice == "btn_start_first":
callback()
if choice == "btn_back":
modal.dismiss()
self._open_danger_menu()
return
elif choice == "btn_main_menu":
modal.dismiss()
self.app.switch_screen("config_selection")
return
asyncio.create_task(do_confirm())
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
modal.phase_progress()
self.engine.is_running = True
await self.engine.start_target_only()
@work(exclusive=True)
async def run_dz_delete_channels(self) -> None:
for sel in selections:
if not self.engine.is_running: break
if sel == "dz_del_channels":
await self._logic_dz_delete_channels(modal)
elif sel == "dz_reset_perms":
await self._logic_dz_reset_perms(modal)
elif sel == "dz_del_roles":
await self._logic_dz_delete_roles(modal)
elif sel == "dz_del_assets":
await self._logic_dz_delete_assets(modal)
modal.phase_report("Danger Zone Operations Complete")
modal.write("[bold green]All selected destructive operations finished.[/bold green]")
except Exception as e:
modal.write(f"[bold red]Error: {e}[/bold red]")
modal.phase_report("Danger Zone Batch", "error")
finally:
self.engine.is_running = False
await self.engine.close_target_only()
async def _logic_dz_delete_channels(self, modal: ProgressScreen) -> 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 = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
modal.phase_progress()
modal.set_status("[red]Deleting channels...")
async def on_deleted(name, current, total):
modal.set_status(f"[red]Deleting: {name}")
modal.set_progress(current, total)
try:
modal.set_status("[red]Deleting channels...")
await self.engine.start_target_only()
count = await danger_delete_all_channels(self.engine, progress_callback=on_deleted)
modal.write(f"[bold green]Success! {count} channels/categories deleted.[/bold green]")
await log_audit_event(self.engine, "Danger Zone: Channels Wiped", f"Deleted {count} channels and categories.")
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.phase_report("Danger Zone: Channels Wiped")
modal.write(f"[bold green]Success! {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]")
modal.phase_report("Delete Channels", "error")
finally:
await self.engine.close_target_only()
@work(exclusive=True)
async def run_dz_reset_perms(self) -> None:
async def _logic_dz_reset_perms(self, modal: ProgressScreen) -> 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 = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
modal.phase_progress()
modal.set_status("[red]Resetting permissions...")
async def on_reset(name, current, total):
modal.set_status(f"[red]Resetting: {name}")
modal.set_progress(current, total)
try:
modal.set_status("[red]Resetting permissions...")
await self.engine.start_target_only()
count = await danger_reset_channel_permissions(self.engine, progress_callback=on_reset)
modal.write(f"[bold green]Success! Permissions reset on {count} items.[/bold green]")
await log_audit_event(self.engine, "Danger Zone: Permissions Wiped", f"Reset permissions on {count} items.")
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.phase_report("Danger Zone: Permissions Wiped")
modal.write(f"[bold green]Success! 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]")
modal.phase_report("Wipe Permissions", "error")
finally:
await self.engine.close_target_only()
@work(exclusive=True)
async def run_dz_delete_roles(self) -> None:
async def _logic_dz_delete_roles(self, modal: ProgressScreen) -> 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 = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
modal.phase_progress()
modal.set_status("[red]Deleting roles...")
async def on_deleted(name, current, total):
modal.set_status(f"[red]Deleting role: {name}")
modal.set_progress(current, total)
try:
modal.set_status("[red]Deleting roles...")
await self.engine.start_target_only()
count = await danger_delete_all_roles(self.engine, progress_callback=on_deleted)
modal.write(f"[bold green]Success! {count} roles deleted.[/bold green]")
await log_audit_event(self.engine, "Danger Zone: Roles Wiped", f"Deleted {count} roles.")
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.phase_report("Danger Zone: Roles Wiped")
modal.write(f"[bold green]Success! {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]")
modal.phase_report("Wipe Roles", "error")
finally:
await self.engine.close_target_only()
@work(exclusive=True)
async def run_dz_delete_assets(self) -> None:
async def _logic_dz_delete_assets(self, modal: ProgressScreen) -> 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 = ProgressScreen()
self.app.push_screen(modal)
await asyncio.sleep(0.1)
modal.cancel_callback = lambda: setattr(self.engine, "is_running", False)
modal.phase_progress()
modal.set_status("[red]Deleting assets...")
async def on_deleted(name, asset_type, current, total):
modal.set_status(f"[red]Deleting {asset_type}: {name}")
modal.set_progress(current, total)
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.phase_report("Danger Zone: Assets Wiped")
modal.write(f"[bold green]Success! {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]")
modal.phase_report("Wipe Assets", "error")
finally:
await self.engine.close_target_only()
counts = await danger_delete_all_emojis_and_stickers(self.engine, progress_callback=on_deleted)
modal.write(f"[bold green]Success! {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.")