implement table view in cli

This commit is contained in:
rambros 2026-02-21 23:29:36 +05:30
parent 667cb4e902
commit 27ace55242
2 changed files with 227 additions and 166 deletions

View file

@ -213,26 +213,28 @@ class MigrationEngine:
categories = await self.discord_reader.get_categories()
channels = await self.discord_reader.get_channels()
# Filter items if not forcing
if not force:
categories = [cat for cat in categories if not self.state.get_fluxer_category_id(str(cat.id))]
channels = [ch for ch in channels if not self.state.get_fluxer_channel_id(str(ch.id))]
total = len(categories) + len(channels)
current_idx = 0
if total == 0:
return
# Migrate Categories first
for cat in categories:
if not self.is_running: break
state_key = str(cat.id)
fluxer_id = None if force else self.state.get_fluxer_category_id(state_key)
status = "Copying"
if not fluxer_id:
# 4 corresponds to Category type in Discord/Fluxer typically
fluxer_id = await self.fluxer_writer.create_channel(cat.name, type=4)
self.state.set_category_mapping(state_key, fluxer_id)
else:
status = "Skipping"
# 4 corresponds to Category type in Discord/Fluxer typically
fluxer_id = await self.fluxer_writer.create_channel(cat.name, type=4)
self.state.set_category_mapping(state_key, fluxer_id)
current_idx += 1
if progress_callback: await progress_callback(f"Cat: {cat.name}", status, current_idx, total)
if progress_callback: await progress_callback(f"Cat: {cat.name}", "Copying", current_idx, total)
await asyncio.sleep(self.config.migration.rate_limit_delay_seconds)
# Migrate Text Channels
@ -240,25 +242,19 @@ class MigrationEngine:
if not self.is_running: break
state_key = str(channel.id)
fluxer_id = None if force else self.state.get_fluxer_channel_id(state_key)
status = "Copying"
topic = channel.topic if channel.topic else ""
parent_id = self.state.get_fluxer_category_id(str(channel.category_id)) if channel.category_id else None
if not fluxer_id:
topic = channel.topic if channel.topic else ""
parent_id = self.state.get_fluxer_category_id(str(channel.category_id)) if channel.category_id else None
fluxer_id = await self.fluxer_writer.create_channel(
name=channel.name,
topic=topic,
type=0,
parent_id=parent_id
)
self.state.set_channel_mapping(state_key, fluxer_id)
else:
status = "Skipping"
fluxer_id = await self.fluxer_writer.create_channel(
name=channel.name,
topic=topic,
type=0,
parent_id=parent_id
)
self.state.set_channel_mapping(state_key, fluxer_id)
current_idx += 1
if progress_callback: await progress_callback(channel.name, status, current_idx, total)
if progress_callback: await progress_callback(channel.name, "Copying", current_idx, total)
await asyncio.sleep(self.config.migration.rate_limit_delay_seconds)
async def sync_permissions(self, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None):
@ -266,32 +262,34 @@ class MigrationEngine:
categories = await self.discord_reader.get_categories()
channels = await self.discord_reader.get_channels()
# Only sync for items that are already mapped
categories = [c for c in categories if self.state.get_fluxer_category_id(str(c.id))]
channels = [c for c in channels if self.state.get_fluxer_channel_id(str(c.id))]
total = len(categories) + len(channels)
current_idx = 0
if total == 0:
return
# Sync Category Permissions (Role Overwrites)
for cat in categories:
if not self.is_running: break
fluxer_id = self.state.get_fluxer_channel_id(str(cat.id))
if fluxer_id:
# In a real implementation, we would diff discord perms
# and apply them to fluxer_id using client methods.
pass
# In a real implementation, we would diff discord perms
# and apply them to fluxer_id using client methods.
current_idx += 1
if progress_callback: await progress_callback(f"Cat: {cat.name}", current_idx, total)
await asyncio.sleep(self.config.migration.rate_limit_delay_seconds)
# Sync Channel Permissions
for channel in channels:
if not self.is_running: break
fluxer_id = self.state.get_fluxer_channel_id(str(channel.id))
if fluxer_id:
pass
# apply perms
current_idx += 1
if progress_callback: await progress_callback(channel.name, current_idx, total)
await asyncio.sleep(self.config.migration.rate_limit_delay_seconds)
async def analyze_migration(self, source_channel_id: int, after_message_id: int | None = None, progress_callback: Callable[[int], Awaitable[None]] | None = None) -> Dict[str, int]:
"""
@ -416,24 +414,29 @@ class MigrationEngine:
return message_count
async def migrate_roles(self, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None):
async def migrate_roles(self, progress_callback: Callable[[str, int, int], Awaitable[None]] | None = None, force: bool = False):
"""Copies roles and their baseline permissions."""
roles = await self.discord_reader.get_roles()
if not force:
roles = [r for r in roles if not self.state.get_fluxer_role_id(str(r.id))]
total = len(roles)
if total == 0:
return
for idx, role in enumerate(roles):
if not self.is_running: break
fluxer_id = self.state.get_fluxer_role_id(str(role.id))
if not fluxer_id:
fluxer_id = await self.fluxer_writer.create_role(
name=role.name,
color=role.color.value,
hoist=role.hoist,
mentionable=role.mentionable
)
if fluxer_id:
self.state.set_role_mapping(str(role.id), fluxer_id)
fluxer_id = await self.fluxer_writer.create_role(
name=role.name,
color=role.color.value,
hoist=role.hoist,
mentionable=role.mentionable
)
if fluxer_id:
self.state.set_role_mapping(str(role.id), fluxer_id)
if progress_callback: await progress_callback(role.name, idx + 1, total)
await asyncio.sleep(self.config.migration.rate_limit_delay_seconds)
@ -452,36 +455,38 @@ class MigrationEngine:
stickers = await self.discord_reader.get_stickers()
objs.extend([(s, "Sticker") for s in stickers])
if not force:
objs = [(obj, obj_type) for obj, obj_type in objs if not (
self.state.get_fluxer_emoji_id(str(obj.id)) if obj_type == "Emoji" else self.state.get_fluxer_sticker_id(str(obj.id))
)]
total = len(objs)
if total == 0:
return
for idx, (obj, obj_type) in enumerate(objs):
if not self.is_running: break
if obj_type == "Emoji":
fluxer_id = None if force else self.state.get_fluxer_emoji_id(str(obj.id))
else:
fluxer_id = None if force else self.state.get_fluxer_sticker_id(str(obj.id))
if not fluxer_id:
try:
if obj_type == "Emoji":
img_data = await self.discord_reader.download_emoji(obj)
fluxer_id = await self.fluxer_writer.create_emoji(
name=obj.name,
image_bytes=img_data
)
if fluxer_id:
self.state.set_emoji_mapping(str(obj.id), fluxer_id)
else:
img_data = await self.discord_reader.download_sticker(obj)
fluxer_id = await self.fluxer_writer.create_sticker(
name=obj.name,
image_bytes=img_data
)
if fluxer_id:
self.state.set_sticker_mapping(str(obj.id), fluxer_id)
except Exception as e:
logger.error(f"Error downloading/uploading {obj_type.lower()} {obj.name}: {e}")
try:
if obj_type == "Emoji":
img_data = await self.discord_reader.download_emoji(obj)
fluxer_id = await self.fluxer_writer.create_emoji(
name=obj.name,
image_bytes=img_data
)
if fluxer_id:
self.state.set_emoji_mapping(str(obj.id), fluxer_id)
else:
img_data = await self.discord_reader.download_sticker(obj)
fluxer_id = await self.fluxer_writer.create_sticker(
name=obj.name,
image_bytes=img_data
)
if fluxer_id:
self.state.set_sticker_mapping(str(obj.id), fluxer_id)
except Exception as e:
logger.error(f"Error downloading/uploading {obj_type.lower()} {obj.name}: {e}")
if progress_callback: await progress_callback(obj.name, obj_type, idx + 1, total)
await asyncio.sleep(self.config.migration.rate_limit_delay_seconds)

View file

@ -4,6 +4,7 @@ import logging
import re
from rich.console import Console
from rich.prompt import Prompt, Confirm
from rich.table import Table
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
from src.config import load_config, save_config
@ -248,43 +249,48 @@ class MigrationCLI:
await self.engine.close_connections()
return
console.print("\n[bold]Server Template Preview:[/bold]")
# Group channels by category
import discord
channels_by_cat = {}
uncategorized = []
for ch in channels:
if ch.category_id:
if ch.category_id not in channels_by_cat:
channels_by_cat[ch.category_id] = []
channels_by_cat[ch.category_id].append(ch)
else:
uncategorized.append(ch)
def print_channel(ch):
if isinstance(ch, discord.TextChannel):
color = "cyan"
elif isinstance(ch, discord.VoiceChannel):
color = "green"
elif isinstance(ch, discord.ForumChannel):
color = "magenta"
else:
color = "white"
console.print(f" [{color}]- {ch.name}[/{color}]")
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Type", width=12)
table.add_column("Discord Name")
table.add_column("Status", justify="right")
# Group channels by category for status check
missing_by_cat = {}
missing_uncategorized = []
for ch in channels:
cat_id = str(ch.category_id) if ch.category_id else None
if not self.engine.state.get_fluxer_channel_id(str(ch.id)):
if cat_id:
if cat_id not in missing_by_cat: missing_by_cat[cat_id] = []
missing_by_cat[cat_id].append(ch)
else:
missing_uncategorized.append(ch)
# Build the unified preview table
for cat in categories:
console.print(f"[bold yellow]{cat.name}[/bold yellow]")
cat_channels = channels_by_cat.get(cat.id, [])
cat_id_str = str(cat.id)
fluxer_cat_id = self.engine.state.get_fluxer_category_id(cat_id_str)
status = f"[cyan]{fluxer_cat_id}[/cyan]" if fluxer_cat_id else "[bold red]Missing[/bold red]"
table.add_row("Category", f"[bold yellow]{cat.name}[/bold yellow]", status)
# Show ALL channels under this category
cat_channels = [ch for ch in channels if str(ch.category_id) == cat_id_str]
for ch in cat_channels:
print_channel(ch)
fluxer_ch_id = self.engine.state.get_fluxer_channel_id(str(ch.id))
ch_status = f"[cyan]{fluxer_ch_id}[/cyan]" if fluxer_ch_id else "[red]Missing[/red]"
table.add_row(" Channel", ch.name, ch_status)
# Show Uncategorized
uncategorized = [ch for ch in channels if not ch.category_id]
if uncategorized:
console.print(f"[bold yellow]Uncategorized[/bold yellow]")
table.add_row("Uncategorized", "", "")
for ch in uncategorized:
print_channel(ch)
fluxer_ch_id = self.engine.state.get_fluxer_channel_id(str(ch.id))
ch_status = f"[cyan]{fluxer_ch_id}[/cyan]" if fluxer_ch_id else "[red]Missing[/red]"
table.add_row(" Channel", ch.name, ch_status)
console.print(table)
console.print("")
# Check for existing mappings to determine if we should suggest a force re-copy
@ -295,42 +301,7 @@ class MigrationCLI:
force = False
if cached_count > 0:
console.print(f"[yellow]\u26a0 {cached_count}/{all_ids_len} item(s) already in state.json cache.[/yellow]")
# List missing items
missing_categories = [cat for cat in categories if not self.engine.state.get_fluxer_category_id(str(cat.id))]
missing_channels = [ch for ch in channels if not self.engine.state.get_fluxer_channel_id(str(ch.id))]
if missing_categories or missing_channels:
console.print("\n[bold red]The following channels/categories are missing in your fluxer server:[/bold red]")
# Group missing channels by their categories
missing_by_cat = {}
missing_uncategorized = []
for ch in missing_channels:
cat_id = str(ch.category_id) if ch.category_id else None
if cat_id:
if cat_id not in missing_by_cat: missing_by_cat[cat_id] = []
missing_by_cat[cat_id].append(ch)
else:
missing_uncategorized.append(ch)
# Iterate through all categories to print missing ones or categories with missing children
for cat in categories:
cat_id_str = str(cat.id)
is_cat_missing = not self.engine.state.get_fluxer_category_id(cat_id_str)
child_missing_channels = missing_by_cat.get(cat_id_str, [])
if is_cat_missing or child_missing_channels:
footer = " [dim](Category itself is missing)[/dim]" if is_cat_missing else ""
console.print(f"[bold yellow]{cat.name}[/bold yellow]{footer}")
for ch in child_missing_channels:
print_channel(ch)
if missing_uncategorized:
console.print(f"[bold yellow]Uncategorized[/bold yellow]")
for ch in missing_uncategorized:
print_channel(ch)
console.print("")
# End of table consolidated section, back to standard flow logic.
console.print("[bold green](Y) Continue with only missing items[/bold green]")
console.print("[bold red](F) Force re-clone, creates duplicate channels![/bold red]")
@ -381,7 +352,7 @@ class MigrationCLI:
async def copy_roles(self):
console.print("\n[bold]Role & Permission Options[/bold]")
console.print("(1) Copy Roles & Role Permissions")
console.print("(1) Clone Roles & Role Permissions")
console.print("(2) Sync Category Permissions & Channel Permissions")
console.print("(B) Back")
@ -391,8 +362,47 @@ class MigrationCLI:
return
if choice == "1":
console.print("\n[bold green]Starting Role Migration...[/bold green]")
try:
await self.engine.start_connections()
with console.status("[yellow]Checking Fluxer for existing roles...[/yellow]"):
await self.engine.sync_roles_state()
roles = await self.engine.discord_reader.get_roles()
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Discord Role")
table.add_column("Status", justify="center")
table.add_column("Fluxer ID", justify="right")
cached_count = 0
for r in roles:
fluxer_id = self.engine.state.get_fluxer_role_id(str(r.id))
status = "[bold green]NEW[/bold green]"
fid_str = "[dim]N/A[/dim]"
if fluxer_id:
status = "[dim]already copied[/dim]"
fid_str = f"[cyan]{fluxer_id}[/cyan]"
cached_count += 1
table.add_row(r.name, status, fid_str)
console.print(table)
force = False
if cached_count > 0:
console.print(f"\n[yellow]{cached_count} role(s) already in state cache.[/yellow]")
console.print("[bold green](Y) Sync missing roles only[/bold green]")
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()
if sub_choice == "B":
return
elif sub_choice == "F":
force = True
console.print("\n[bold green]Starting Role Migration...[/bold green]")
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
@ -401,14 +411,13 @@ class MigrationCLI:
console=console
) as progress:
role_task = progress.add_task("[cyan]Copying Roles...", total=100)
role_task = progress.add_task("[cyan]Syncing Roles...", total=100)
async def update_progress(item_name: str, current: int, total: int):
progress.update(role_task, total=total, completed=current, description=f"[cyan]Copying Role: {item_name}")
progress.update(role_task, total=total, completed=current, description=f"[cyan]Syncing Role: {item_name}")
await self.engine.start_connections()
self.engine.is_running = True
await self.engine.migrate_roles(progress_callback=update_progress)
await self.engine.migrate_roles(progress_callback=update_progress, force=force)
console.print("[bold green]Role migration complete![/bold green]")
@ -419,8 +428,27 @@ class MigrationCLI:
self.engine.is_running = False
elif choice == "2":
console.print("\n[bold green]Syncing Category & Channel Permissions...[/bold green]")
try:
await self.engine.start_connections()
with console.status("[yellow]Analyzing categories and channels...[/yellow]"):
categories = await self.engine.discord_reader.get_categories()
channels = await self.engine.discord_reader.get_channels()
mapped_cats = sum(1 for c in categories if self.engine.state.get_fluxer_category_id(str(c.id)))
mapped_chs = sum(1 for c in channels if self.engine.state.get_fluxer_channel_id(str(c.id)))
total_mapped = mapped_cats + mapped_chs
console.print(f"\n[yellow]Ready to sync permissions for {total_mapped} items ({mapped_cats} categories, {mapped_chs} channels).[/yellow]")
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()
if sub_choice == "B":
return
console.print("\n[bold green]Syncing Category & Channel Permissions...[/bold green]")
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
@ -434,7 +462,6 @@ class MigrationCLI:
async def update_progress(item_name: str, current: int, total: int):
progress.update(perm_task, total=total, completed=current, description=f"[cyan]Syncing: {item_name}")
await self.engine.start_connections()
self.engine.is_running = True
await self.engine.sync_permissions(progress_callback=update_progress)
@ -457,22 +484,29 @@ class MigrationCLI:
emojis = await self.engine.discord_reader.get_emojis()
stickers = await self.engine.discord_reader.get_stickers()
console.print(f"\n[bold]Custom emojis found: {len(emojis)}[/bold]")
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Type", width=10)
table.add_column("Name")
table.add_column("Status", justify="right")
cached_emojis = 0
for e in emojis:
already = self.engine.state.get_fluxer_emoji_id(str(e.id))
tag = " [dim](already copied)[/dim]" if already else ""
console.print(f" - Emoji: {e.name}{tag}")
status = "[dim]already copied[/dim]" if already else "[bold green]NEW[/bold green]"
if already: cached_emojis += 1
table.add_row("Emoji", e.name, status)
console.print(f"[bold]Custom stickers found: {len(stickers)}[/bold]")
cached_stickers = 0
for s in stickers:
already = self.engine.state.get_fluxer_sticker_id(str(s.id))
tag = " [dim](already copied)[/dim]" if already else ""
console.print(f" - Sticker: {s.name}{tag}")
status = "[dim]already copied[/dim]" if already else "[bold green]NEW[/bold green]"
if already: cached_stickers += 1
table.add_row("Sticker", s.name, status)
console.print(table)
# Warn if everything is already in the state cache
force = False
cached_emojis = sum(1 for e in emojis if self.engine.state.get_fluxer_emoji_id(str(e.id)))
cached_stickers = sum(1 for s in stickers if self.engine.state.get_fluxer_sticker_id(str(s.id)))
total_items = len(emojis) + len(stickers)
cached_count = cached_emojis + cached_stickers
@ -558,9 +592,17 @@ class MigrationCLI:
icon_status = "[bold green]FOUND[/bold green]" if metadata.get("icon_url") else "[yellow]NOT FOUND[/yellow]"
banner_status = "[bold green]FOUND[/bold green]" if metadata.get("banner_url") else "[yellow]NOT FOUND[/yellow]"
console.print(f"\n[bold]Server Name:[/bold] {name}")
console.print(f"[bold]Server Icon:[/bold] {icon_status}")
console.print(f"[bold]Server Banner:[/bold] {banner_status}")
banner_status = "[bold green]FOUND[/bold green]" if metadata.get("banner_url") else "[yellow]NOT FOUND[/yellow]"
table = Table(show_header=False, box=None)
table.add_column("Property", style="bold")
table.add_column("Value")
table.add_row("Server Name:", name)
table.add_row("Server Icon:", icon_status)
table.add_row("Server Banner:", banner_status)
console.print(Panel(table, title="[bold]Discord Server Metadata[/bold]", expand=False))
console.print("\n(1) Sync Name only")
console.print("(2) Sync Icon only")
@ -762,16 +804,30 @@ class MigrationCLI:
finally:
self.engine.is_running = False
console.print(f"\n[bold]Migration Summary:[/bold]")
console.print(f"Number of messages: [bold cyan]{stats['messages']}[/bold cyan]")
console.print(f"Number of threads: [bold cyan]{stats['threads']}[/bold cyan]")
console.print(f"Number of attachments: [bold cyan]{stats['attachments']}[/bold cyan]")
console.print("\n[bold yellow]Estimated Overhead:[/bold yellow]")
table = Table(show_header=True, header_style="bold magenta", title="Migration Summary & Estimates")
table.add_column("Item", width=15)
table.add_column("Count", justify="right", width=10)
table.add_column("Overhead/Details")
msg_time = stats['messages'] * self.config.migration.rate_limit_delay_seconds
console.print(f"- [bold]Messages:[/bold] ~{msg_time}s delay (rate limiting), {stats['messages']} API writes.")
console.print(f"- [bold]Threads:[/bold] {stats['threads'] * 2} extra marker messages, {stats['threads']} extra history fetches.")
console.print(f"- [bold]Attachments:[/bold] {stats['attachments']} downloads and uploads (bandwidth & API calls).")
table.add_row(
"Messages",
f"[bold cyan]{stats['messages']}[/bold cyan]",
f"~{msg_time}s delay (rate limiting), {stats['messages']} API writes"
)
table.add_row(
"Threads",
f"[bold cyan]{stats['threads']}[/bold cyan]",
f"{stats['threads'] * 2} extra marker messages, {stats['threads']} extra history fetches"
)
table.add_row(
"Attachments",
f"[bold cyan]{stats['attachments']}[/bold cyan]",
f"{stats['attachments']} downloads and uploads (bandwidth & API calls)"
)
console.print("")
console.print(table)
if not Confirm.ask(f"\nMigrate messages from Discord [cyan]#{source_channel.name}[/cyan] to Fluxer [magenta]#{target_channel.get('name')}[/magenta]?"):
return