migration auto test in tui
This commit is contained in:
parent
2ef141776c
commit
bd80760667
6 changed files with 377 additions and 95 deletions
|
|
@ -12,7 +12,7 @@ def setup_logging():
|
||||||
except Exception:
|
except Exception:
|
||||||
level = logging.INFO
|
level = logging.INFO
|
||||||
|
|
||||||
handlers = [RotatingFileHandler('.reaper.log', mode='a', maxBytes=10*1024*1024, backupCount=3)]
|
handlers = [RotatingFileHandler('.reaper.log', mode='w', maxBytes=10*1024*1024, backupCount=3)]
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s',
|
format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s',
|
||||||
datefmt='%H:%M:%S',
|
datefmt='%H:%M:%S',
|
||||||
|
|
|
||||||
|
|
@ -15,41 +15,52 @@ async def sync_channel_state(context: MigrationContext):
|
||||||
channels = await context.discord_reader.get_channels()
|
channels = await context.discord_reader.get_channels()
|
||||||
fluxer_channels = await context.fluxer_writer.get_channels()
|
fluxer_channels = await context.fluxer_writer.get_channels()
|
||||||
|
|
||||||
# Build name -> id map and ID set for Fluxer for fast lookup
|
# Build maps for Fluxer lookup
|
||||||
fluxer_name_map = {c.get("name"): str(c.get("id")) for c in fluxer_channels if c.get("name")}
|
# {name: id} for categories
|
||||||
fluxer_id_set = {str(c.get("id")) for c in fluxer_channels}
|
fluxer_cats = {c.get("name"): str(c.get("id")) for c in fluxer_channels if c.get("type") == 4}
|
||||||
|
# {parent_id: {name: id}} for channels
|
||||||
|
fluxer_structure = {}
|
||||||
|
for c in fluxer_channels:
|
||||||
|
if c.get("type") == 4: continue
|
||||||
|
p_id = str(c.get("parent_id")) if c.get("parent_id") else "root"
|
||||||
|
if p_id not in fluxer_structure: fluxer_structure[p_id] = {}
|
||||||
|
fluxer_structure[p_id][c.get("name")] = str(c.get("id"))
|
||||||
|
|
||||||
|
fluxer_id_set = {str(c.get("id")) for c in fluxer_channels}
|
||||||
updates = 0
|
updates = 0
|
||||||
removals = 0
|
removals = 0
|
||||||
|
|
||||||
# 1. Verify and Sync Categories
|
# 1. Sync Categories
|
||||||
for cat in categories:
|
for cat in categories:
|
||||||
discord_id = str(cat.id)
|
discord_id = str(cat.id)
|
||||||
fluxer_id = context.state.get_fluxer_category_id(discord_id)
|
fluxer_id = context.state.get_fluxer_category_id(discord_id)
|
||||||
|
|
||||||
if fluxer_id:
|
if fluxer_id:
|
||||||
if fluxer_id not in fluxer_id_set:
|
if fluxer_id not in fluxer_id_set:
|
||||||
context.state.remove_category_mapping(discord_id)
|
context.state.remove_category_mapping(discord_id)
|
||||||
removals += 1
|
removals += 1
|
||||||
elif cat.name in fluxer_name_map:
|
elif cat.name in fluxer_cats:
|
||||||
context.state.set_category_mapping(discord_id, fluxer_name_map[cat.name])
|
context.state.set_category_mapping(discord_id, fluxer_cats[cat.name])
|
||||||
updates += 1
|
updates += 1
|
||||||
|
|
||||||
# 2. Verify and Sync Channels
|
# 2. Sync Channels (parent-aware)
|
||||||
for ch in channels:
|
for ch in channels:
|
||||||
discord_id = str(ch.id)
|
discord_id = str(ch.id)
|
||||||
fluxer_id = context.state.get_fluxer_channel_id(discord_id)
|
fluxer_id = context.state.get_fluxer_channel_id(discord_id)
|
||||||
|
|
||||||
if fluxer_id:
|
if fluxer_id:
|
||||||
if fluxer_id not in fluxer_id_set:
|
if fluxer_id not in fluxer_id_set:
|
||||||
context.state.remove_channel_mapping(discord_id)
|
context.state.remove_channel_mapping(discord_id)
|
||||||
removals += 1
|
removals += 1
|
||||||
elif ch.name in fluxer_name_map:
|
else:
|
||||||
context.state.set_channel_mapping(discord_id, fluxer_name_map[ch.name])
|
# Try to match by name within the mapped parent category
|
||||||
updates += 1
|
p_discord_id = str(ch.category_id) if ch.category_id else "root"
|
||||||
|
p_fluxer_id = context.state.get_fluxer_category_id(p_discord_id) if p_discord_id != "root" else "root"
|
||||||
|
|
||||||
|
if p_fluxer_id in fluxer_structure and ch.name in fluxer_structure[p_fluxer_id]:
|
||||||
|
context.state.set_channel_mapping(discord_id, fluxer_structure[p_fluxer_id][ch.name])
|
||||||
|
updates += 1
|
||||||
|
|
||||||
if updates > 0 or removals > 0:
|
if updates > 0 or removals > 0:
|
||||||
logger.info(f"Channel sync: {updates} mapped, {removals} stale mappings removed")
|
logger.info(f"Fluxer Channel sync: {updates} mapped, {removals} stale mappings removed")
|
||||||
|
|
||||||
|
|
||||||
async def migrate_channels(context: MigrationContext, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, force: bool = False) -> dict:
|
async def migrate_channels(context: MigrationContext, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, force: bool = False) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -343,14 +343,18 @@ class FluxerWriter:
|
||||||
|
|
||||||
logger.debug(f"Fluxer: Sending message via bot for user '{author_name}'")
|
logger.debug(f"Fluxer: Sending message via bot for user '{author_name}'")
|
||||||
try:
|
try:
|
||||||
|
kwargs = {
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"content": final_bot_content,
|
||||||
|
"embeds": normalized_embeds
|
||||||
|
}
|
||||||
|
if fluxer_files:
|
||||||
|
kwargs["files"] = fluxer_files
|
||||||
|
if message_reference:
|
||||||
|
kwargs["message_reference"] = message_reference
|
||||||
|
|
||||||
msg_data = await asyncio.wait_for(
|
msg_data = await asyncio.wait_for(
|
||||||
self.client.send_message(
|
self.client.send_message(**kwargs),
|
||||||
channel_id=channel_id,
|
|
||||||
content=final_bot_content,
|
|
||||||
files=fluxer_files,
|
|
||||||
embeds=normalized_embeds,
|
|
||||||
message_reference=message_reference
|
|
||||||
),
|
|
||||||
timeout=45.0
|
timeout=45.0
|
||||||
)
|
)
|
||||||
logger.debug(f"Fluxer: Bot send complete, msg_id={msg_data.get('id') if msg_data else 'None'}")
|
logger.debug(f"Fluxer: Bot send complete, msg_id={msg_data.get('id') if msg_data else 'None'}")
|
||||||
|
|
@ -374,19 +378,23 @@ class FluxerWriter:
|
||||||
|
|
||||||
fluxer_files = None
|
fluxer_files = None
|
||||||
if files:
|
if files:
|
||||||
fluxer_files = [File(io.BytesIO(f["data"]), filename=f["filename"]) for f in files]
|
fluxer_files = [f if hasattr(f, "filename") else File(io.BytesIO(f["data"]), filename=f["filename"]) for f in files]
|
||||||
|
|
||||||
message_reference = None
|
message_reference = None
|
||||||
if reply_to_message_id:
|
if reply_to_message_id:
|
||||||
message_reference = {"message_id": str(reply_to_message_id), "channel_id": str(channel_id)}
|
message_reference = {"message_id": str(reply_to_message_id), "channel_id": str(channel_id)}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg_data = await self.client.send_message(
|
kwargs = {
|
||||||
channel_id=channel_id,
|
"channel_id": channel_id,
|
||||||
content=content,
|
"content": content
|
||||||
files=fluxer_files,
|
}
|
||||||
message_reference=message_reference
|
if fluxer_files:
|
||||||
)
|
kwargs["files"] = fluxer_files
|
||||||
|
if message_reference:
|
||||||
|
kwargs["message_reference"] = message_reference
|
||||||
|
|
||||||
|
msg_data = await self.client.send_message(**kwargs)
|
||||||
return str(msg_data["id"]) if msg_data else None
|
return str(msg_data["id"]) if msg_data else None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to send marker: {e}")
|
print(f"Failed to send marker: {e}")
|
||||||
|
|
|
||||||
|
|
@ -16,41 +16,52 @@ async def sync_channel_state(context: MigrationContext):
|
||||||
channels = await context.discord_reader.get_channels()
|
channels = await context.discord_reader.get_channels()
|
||||||
target_channels = await context.writer.get_channels()
|
target_channels = await context.writer.get_channels()
|
||||||
|
|
||||||
# Build name -> id map and ID set for Stoat for fast lookup
|
# Build maps for Stoat lookup
|
||||||
target_name_map = {c.get("name"): str(c.get("id")) for c in target_channels if c.get("name")}
|
# {name: id} for categories (type 4)
|
||||||
|
target_cats = {c.get("name"): str(c.get("id")) for c in target_channels if c.get("type") == 4}
|
||||||
|
# {parent_id: {name: id}} for channels
|
||||||
|
target_structure = {}
|
||||||
|
for c in target_channels:
|
||||||
|
if c.get("type") == 4: continue
|
||||||
|
p_id = str(c.get("parent_id")) if c.get("parent_id") else "root"
|
||||||
|
if p_id not in target_structure: target_structure[p_id] = {}
|
||||||
|
target_structure[p_id][c.get("name")] = str(c.get("id"))
|
||||||
|
|
||||||
target_id_set = {str(c.get("id")) for c in target_channels}
|
target_id_set = {str(c.get("id")) for c in target_channels}
|
||||||
|
|
||||||
updates = 0
|
updates = 0
|
||||||
removals = 0
|
removals = 0
|
||||||
|
|
||||||
# 1. Verify and Sync Categories
|
# 1. Sync Categories
|
||||||
for cat in categories:
|
for cat in categories:
|
||||||
discord_id = str(cat.id)
|
discord_id = str(cat.id)
|
||||||
target_id = context.state.get_target_category_id(discord_id)
|
target_id = context.state.get_target_category_id(discord_id)
|
||||||
|
|
||||||
if target_id:
|
if target_id:
|
||||||
if target_id not in target_id_set:
|
if target_id not in target_id_set:
|
||||||
context.state.remove_category_mapping(discord_id)
|
context.state.remove_category_mapping(discord_id)
|
||||||
removals += 1
|
removals += 1
|
||||||
elif cat.name in target_name_map:
|
elif cat.name in target_cats:
|
||||||
context.state.set_target_category_mapping(discord_id, target_name_map[cat.name])
|
context.state.set_target_category_mapping(discord_id, target_cats[cat.name])
|
||||||
updates += 1
|
updates += 1
|
||||||
|
|
||||||
# 2. Verify and Sync Channels
|
# 2. Sync Channels (parent-aware)
|
||||||
for ch in channels:
|
for ch in channels:
|
||||||
discord_id = str(ch.id)
|
discord_id = str(ch.id)
|
||||||
target_id = context.state.get_target_channel_id(discord_id)
|
target_id = context.state.get_target_channel_id(discord_id)
|
||||||
|
|
||||||
if target_id:
|
if target_id:
|
||||||
if target_id not in target_id_set:
|
if target_id not in target_id_set:
|
||||||
context.state.remove_channel_mapping(discord_id)
|
context.state.remove_channel_mapping(discord_id)
|
||||||
removals += 1
|
removals += 1
|
||||||
elif ch.name in target_name_map:
|
else:
|
||||||
context.state.set_target_channel_mapping(discord_id, target_name_map[ch.name])
|
# Try to match by name within the mapped parent category
|
||||||
updates += 1
|
p_discord_id = str(ch.category_id) if ch.category_id else "root"
|
||||||
|
p_target_id = context.state.get_target_category_id(p_discord_id) if p_discord_id != "root" else "root"
|
||||||
|
|
||||||
|
if p_target_id in target_structure and ch.name in target_structure[p_target_id]:
|
||||||
|
context.state.set_target_channel_mapping(discord_id, target_structure[p_target_id][ch.name])
|
||||||
|
updates += 1
|
||||||
|
|
||||||
if updates > 0 or removals > 0:
|
if updates > 0 or removals > 0:
|
||||||
logger.info(f"Channel sync: {updates} mapped, {removals} stale mappings removed")
|
logger.info(f"Stoat Channel sync: {updates} mapped, {removals} stale mappings removed")
|
||||||
|
|
||||||
|
|
||||||
async def migrate_channels(context: MigrationContext, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, force: bool = False) -> dict:
|
async def migrate_channels(context: MigrationContext, progress_callback: Callable[[str, str, int, int], Awaitable[None]] | None = None, force: bool = False) -> dict:
|
||||||
|
|
@ -107,7 +118,22 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl
|
||||||
if total == 0:
|
if total == 0:
|
||||||
return cloned_info
|
return cloned_info
|
||||||
|
|
||||||
# 1. Create missing channels (unparented for now)
|
# 1. Create missing categories first
|
||||||
|
for cat in missing_categories:
|
||||||
|
if not context.is_running: break
|
||||||
|
|
||||||
|
state_key = str(cat.id)
|
||||||
|
target_id = await context.writer.create_channel(cat.name, type=4)
|
||||||
|
if target_id:
|
||||||
|
context.state.set_target_category_id(state_key, target_id)
|
||||||
|
cloned_info["categories_created"].append(cat.name)
|
||||||
|
if cat.name not in cloned_info["structure"]:
|
||||||
|
cloned_info["structure"][cat.name] = []
|
||||||
|
|
||||||
|
current_idx += 1
|
||||||
|
if progress_callback: await progress_callback(cat.name, "Copying", current_idx, total)
|
||||||
|
|
||||||
|
# 2. Create missing channels (now with parent_id available)
|
||||||
for channel in channels_to_create:
|
for channel in channels_to_create:
|
||||||
if not context.is_running: break
|
if not context.is_running: break
|
||||||
|
|
||||||
|
|
@ -119,7 +145,6 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl
|
||||||
logger.debug(f"Creating channel {channel.name}: topic={topic}, nsfw={nsfw}, slowmode={slowmode}")
|
logger.debug(f"Creating channel {channel.name}: topic={topic}, nsfw={nsfw}, slowmode={slowmode}")
|
||||||
|
|
||||||
# Map Discord-specific types to target-supported types
|
# Map Discord-specific types to target-supported types
|
||||||
# 5 (News) -> 0 (Text), and fallback any unknown non-voice types to text
|
|
||||||
raw_type = channel.type.value if hasattr(channel.type, 'value') else 0
|
raw_type = channel.type.value if hasattr(channel.type, 'value') else 0
|
||||||
if raw_type == context.discord_reader.CHANNEL_TYPE_VOICE.value:
|
if raw_type == context.discord_reader.CHANNEL_TYPE_VOICE.value:
|
||||||
ch_type = 2
|
ch_type = 2
|
||||||
|
|
@ -128,20 +153,22 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl
|
||||||
ch_type = 0
|
ch_type = 0
|
||||||
is_voice = False
|
is_voice = False
|
||||||
else:
|
else:
|
||||||
# Fallback for Stage channels (13) etc. to Text for safety
|
|
||||||
ch_type = 0
|
ch_type = 0
|
||||||
is_voice = False
|
is_voice = False
|
||||||
|
|
||||||
|
# Resolve parent category
|
||||||
|
parent_id = context.state.get_target_category_id(str(channel.category_id)) if channel.category_id else None
|
||||||
|
|
||||||
target_id = await context.writer.create_channel(
|
target_id = await context.writer.create_channel(
|
||||||
name=channel.name,
|
name=channel.name,
|
||||||
topic=topic if not is_voice else "",
|
topic=topic if not is_voice else "",
|
||||||
type=ch_type,
|
type=ch_type,
|
||||||
parent_id=None,
|
parent_id=parent_id,
|
||||||
nsfw=nsfw if not is_voice else False,
|
nsfw=nsfw if not is_voice else False,
|
||||||
slowmode_delay=slowmode if not is_voice else 0
|
slowmode_delay=slowmode if not is_voice else 0
|
||||||
)
|
)
|
||||||
if target_id:
|
if target_id:
|
||||||
context.state.set_target_channel_mapping(state_key, target_id)
|
context.state.set_target_channel_id(state_key, target_id)
|
||||||
cloned_info["channels_created"].append(channel.name)
|
cloned_info["channels_created"].append(channel.name)
|
||||||
|
|
||||||
parent_name = cat_name_map.get(str(channel.category_id), "No Category") if channel.category_id else "No Category"
|
parent_name = cat_name_map.get(str(channel.category_id), "No Category") if channel.category_id else "No Category"
|
||||||
|
|
@ -156,7 +183,8 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl
|
||||||
name=channel.name,
|
name=channel.name,
|
||||||
topic=topic,
|
topic=topic,
|
||||||
nsfw=nsfw,
|
nsfw=nsfw,
|
||||||
slowmode_delay=slowmode
|
slowmode_delay=slowmode,
|
||||||
|
parent_id=parent_id
|
||||||
)
|
)
|
||||||
|
|
||||||
current_idx += 1
|
current_idx += 1
|
||||||
|
|
@ -172,32 +200,21 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl
|
||||||
|
|
||||||
logger.debug(f"Syncing existing channel {channel.name} ({target_id}): topic={topic}, nsfw={nsfw}, slowmode={slowmode}")
|
logger.debug(f"Syncing existing channel {channel.name} ({target_id}): topic={topic}, nsfw={nsfw}, slowmode={slowmode}")
|
||||||
|
|
||||||
|
parent_id = context.state.get_target_category_id(str(channel.category_id)) if channel.category_id else None
|
||||||
|
|
||||||
await context.writer.modify_channel(
|
await context.writer.modify_channel(
|
||||||
channel_id=target_id,
|
channel_id=target_id,
|
||||||
name=channel.name,
|
name=channel.name,
|
||||||
topic=topic,
|
topic=topic,
|
||||||
nsfw=nsfw,
|
nsfw=nsfw,
|
||||||
slowmode_delay=slowmode
|
slowmode_delay=slowmode,
|
||||||
|
parent_id=parent_id
|
||||||
)
|
)
|
||||||
|
|
||||||
cloned_info["channels_synced"].append(channel.name)
|
cloned_info["cloned_info" if "cloned_info" in locals() else "channels_synced"].append(channel.name)
|
||||||
|
|
||||||
current_idx += 1
|
current_idx += 1
|
||||||
if progress_callback: await progress_callback(channel.name, "Syncing", current_idx, total)
|
if progress_callback: await progress_callback(channel.name, "Syncing", current_idx, total)
|
||||||
|
|
||||||
# 3. Create missing categories
|
|
||||||
for cat in missing_categories:
|
|
||||||
if not context.is_running: break
|
|
||||||
|
|
||||||
state_key = str(cat.id)
|
|
||||||
target_id = await context.writer.create_channel(cat.name, type=4)
|
|
||||||
if target_id:
|
|
||||||
context.state.set_target_category_mapping(state_key, target_id)
|
|
||||||
cloned_info["categories_created"].append(cat.name)
|
|
||||||
if cat.name not in cloned_info["structure"]:
|
|
||||||
cloned_info["structure"][cat.name] = []
|
|
||||||
|
|
||||||
current_idx += 1
|
|
||||||
if progress_callback: await progress_callback(f"Cat: {cat.name}", "Copying", current_idx, total)
|
if progress_callback: await progress_callback(f"Cat: {cat.name}", "Copying", current_idx, total)
|
||||||
|
|
||||||
# 4. Final step: Parent the channels into categories via mass server.edit()
|
# 4. Final step: Parent the channels into categories via mass server.edit()
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,9 @@ class OperationPane(Container):
|
||||||
yield Button("Waterfall Migration", id="op_waterfall", disabled=True, variant="primary", tooltip="Migrate all messages globally in chronological order to prevent broken links.\n(Available for Local Backups)")
|
yield Button("Waterfall Migration", id="op_waterfall", disabled=True, variant="primary", tooltip="Migrate all messages globally in chronological order to prevent broken links.\n(Available for Local Backups)")
|
||||||
yield Rule(id="footer_rule")
|
yield Rule(id="footer_rule")
|
||||||
yield Button("Danger Zone ⚠", id="op_danger", variant="error", disabled=True, flat=True, tooltip="Dangerous operations:\ndelete channels, roles, emojis on target\n(use with caution)")
|
yield Button("Danger Zone ⚠", id="op_danger", variant="error", disabled=True, flat=True, tooltip="Dangerous operations:\ndelete channels, roles, emojis on target\n(use with caution)")
|
||||||
|
|
||||||
|
if self.cfg_name == "AutoTest":
|
||||||
|
yield Button("RUN AUTO TEST", id="op_autotest", variant="warning", flat="false", disabled=True, tooltip="Execute automated test sequence for the AutoTest profile")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self._rebuild_engine()
|
self._rebuild_engine()
|
||||||
|
|
@ -395,7 +398,7 @@ class OperationPane(Container):
|
||||||
lbl.update(f"{t_status}")
|
lbl.update(f"{t_status}")
|
||||||
|
|
||||||
# Buttons
|
# Buttons
|
||||||
for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_waterfall", "#op_danger"):
|
for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_waterfall", "#op_danger", "#op_autotest"):
|
||||||
for btn in self.query(bid): btn.disabled = not self.tokens_valid
|
for btn in self.query(bid): btn.disabled = not self.tokens_valid
|
||||||
|
|
||||||
# ── validation ────────────────────────────────────────────────────────
|
# ── validation ────────────────────────────────────────────────────────
|
||||||
|
|
@ -416,10 +419,10 @@ class OperationPane(Container):
|
||||||
|
|
||||||
# Disable all operation buttons while validation is in progress
|
# Disable all operation buttons while validation is in progress
|
||||||
if self.view_mode == "shuttle":
|
if self.view_mode == "shuttle":
|
||||||
for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_waterfall", "#op_danger"):
|
for bid in ("#op_clone", "#op_sync", "#op_messages", "#op_waterfall", "#op_danger", "#op_autotest"):
|
||||||
for btn in self.query(bid): btn.disabled = True
|
for btn in self.query(bid): btn.disabled = True
|
||||||
elif self.view_mode == "backup":
|
elif self.view_mode == "backup":
|
||||||
for bid in ("#op_backup_msgs", "#op_backup_sync"):
|
for bid in ("#op_backup_msgs", "#op_backup_sync", "#op_autotest"):
|
||||||
for btn in self.query(bid): btn.disabled = True
|
for btn in self.query(bid): btn.disabled = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in run_validate setup: {e}")
|
logger.error(f"Error in run_validate setup: {e}")
|
||||||
|
|
@ -590,6 +593,136 @@ class OperationPane(Container):
|
||||||
from src.ui.backup_stats import BackupStatsScreen
|
from src.ui.backup_stats import BackupStatsScreen
|
||||||
target_dir = Path(self._base_dir()) / f"DISCORD_BACKUP-{self.config.discord_server_id}"
|
target_dir = Path(self._base_dir()) / f"DISCORD_BACKUP-{self.config.discord_server_id}"
|
||||||
self.app.push_screen(BackupStatsScreen(self.cfg_name, target_dir))
|
self.app.push_screen(BackupStatsScreen(self.cfg_name, target_dir))
|
||||||
|
elif bid == "op_autotest":
|
||||||
|
self.run_autotest_sequence()
|
||||||
|
|
||||||
|
@work(exclusive=True)
|
||||||
|
async def run_autotest_sequence(self) -> None:
|
||||||
|
"""Entry point for the AUTO TEST sequence."""
|
||||||
|
if not self.tokens_valid:
|
||||||
|
return
|
||||||
|
|
||||||
|
modal = ProgressScreen(log_level=self.config.log_level)
|
||||||
|
self.app.push_screen(modal)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.view_mode == "shuttle":
|
||||||
|
await self._run_migration_autotest_logic(modal)
|
||||||
|
elif self.view_mode == "backup":
|
||||||
|
await self._run_backup_autotest_logic(modal)
|
||||||
|
|
||||||
|
modal.phase_report("AUTO TEST Complete", show_back=False)
|
||||||
|
modal.write("[bold green]Full automated test sequence finished successfully![/bold green]")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Auto-Test Error: {e}\n{traceback.format_exc()}")
|
||||||
|
modal.write(f"[bold red]Error: {e}[/bold red]")
|
||||||
|
modal.phase_report("Auto-Test", "error", show_back=False)
|
||||||
|
finally:
|
||||||
|
self.engine.is_running = False
|
||||||
|
await self.engine.close_connections()
|
||||||
|
|
||||||
|
async def _run_migration_autotest_logic(self, modal: ProgressScreen) -> None:
|
||||||
|
"""Executes the full migration test sequence."""
|
||||||
|
modal.set_status("AUTO TEST: Launching Migration Sequence...")
|
||||||
|
modal.write("[bold yellow]Starting Migration Auto-Test...[/bold yellow]")
|
||||||
|
|
||||||
|
migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate
|
||||||
|
|
||||||
|
# 1. Connect and initialize state
|
||||||
|
modal.set_status("Connecting and Initializing State...")
|
||||||
|
await self.engine.start_connections()
|
||||||
|
self.engine.is_running = True
|
||||||
|
|
||||||
|
# Initialize state database early to avoid Warnings
|
||||||
|
tid = self.config.fluxer_server_id if self.target_platform == "fluxer" else self.config.stoat_server_id
|
||||||
|
tgt_info = await self.engine.writer.validate()
|
||||||
|
self.engine.ensure_state_initialized(str(tid or ""), tgt_info.get("community_name", "Target"))
|
||||||
|
|
||||||
|
# 2. Danger Zone Clean
|
||||||
|
modal.write("\n[bold red]Phase 1: Danger Zone Cleanup[/bold red]")
|
||||||
|
await self._logic_dz_delete_channels(modal)
|
||||||
|
await self._logic_dz_reset_perms(modal)
|
||||||
|
await self._logic_dz_delete_roles(modal)
|
||||||
|
await self._logic_dz_delete_assets(modal)
|
||||||
|
|
||||||
|
# 3. Clone Roles & Permissions
|
||||||
|
modal.write("\n[bold cyan]Phase 2: Cloning Roles & Permissions[/bold cyan]")
|
||||||
|
await self._logic_clone_roles(modal, force=True)
|
||||||
|
|
||||||
|
# 4. Clone Assets (Logo, Banner, Emojis, Stickers)
|
||||||
|
modal.write("\n[bold cyan]Phase 3: Syncing Metadata & Assets[/bold cyan]")
|
||||||
|
await self._logic_copy_assets(modal, ["Emoji", "Sticker"], force=True)
|
||||||
|
await self._logic_sync_metadata(modal, ["name", "icon", "banner"])
|
||||||
|
|
||||||
|
# 5. Clone Template (Structure)
|
||||||
|
modal.write("\n[bold cyan]Phase 4: Cloning Server Structure (1/2)[/bold cyan]")
|
||||||
|
await self._logic_clone_channels(modal, force=True)
|
||||||
|
await self._logic_sync_permissions(modal)
|
||||||
|
|
||||||
|
# 6. Waterfall Migration (Only for backups)
|
||||||
|
if self.engine.source_mode == "backup" :
|
||||||
|
modal.write("\n[bold cyan]Phase 5: Waterfall Message Migration[/bold cyan]")
|
||||||
|
await self._logic_waterfall_migration(modal=modal, is_autotest=True)
|
||||||
|
else:
|
||||||
|
modal.write("\n[bold yellow]Phase 5: Skipping Waterfall (Live mode selected)[/bold yellow]")
|
||||||
|
|
||||||
|
|
||||||
|
# 7. Individual Channel Migration (Automated)
|
||||||
|
modal.write("\n[bold cyan]Phase 7: Individual Channel Migration[/bold cyan]")
|
||||||
|
await self._logic_autotest_migrate_all_channels(modal=modal)
|
||||||
|
async def _run_backup_autotest_logic(self, modal: ProgressScreen) -> None:
|
||||||
|
"""Executes the full backup test sequence."""
|
||||||
|
modal.set_status("AUTO TEST: Launching Backup Sequence...")
|
||||||
|
modal.write("[bold yellow]Starting Backup Auto-Test...[/bold yellow]")
|
||||||
|
|
||||||
|
# 1. Clear old backup
|
||||||
|
modal.set_status("Clearing old backup database...")
|
||||||
|
db_path = Path(self._base_dir()) / f"DISCORD_BACKUP-{self.config.discord_server_id}" / "backup.db"
|
||||||
|
if db_path.exists():
|
||||||
|
modal.write(f"[yellow]Deleting existing database: {db_path.name}[/yellow]")
|
||||||
|
db_path.unlink()
|
||||||
|
|
||||||
|
# 2. Setup exporter
|
||||||
|
await self.engine.discord_reader.start()
|
||||||
|
await self.exporter.setup()
|
||||||
|
self.exporter.is_running = True
|
||||||
|
|
||||||
|
# 3. Full Backup
|
||||||
|
modal.write("\n[bold cyan]Phase 1: Full Server Backup[/bold cyan]")
|
||||||
|
modal.show_stats()
|
||||||
|
|
||||||
|
await self.exporter.export_metadata()
|
||||||
|
await self.exporter.download_server_assets()
|
||||||
|
await self.exporter.export_channels_structure()
|
||||||
|
await self.exporter.export_roles()
|
||||||
|
await self.exporter.export_assets()
|
||||||
|
|
||||||
|
eligible_channels = [
|
||||||
|
c for c in await self.engine.discord_reader.get_channels()
|
||||||
|
if c.type in [
|
||||||
|
self.engine.discord_reader.CHANNEL_TYPE_TEXT,
|
||||||
|
self.engine.discord_reader.CHANNEL_TYPE_NEWS,
|
||||||
|
self.engine.discord_reader.CHANNEL_TYPE_FORUM
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
total_chans = len(eligible_channels)
|
||||||
|
for i, chan in enumerate(eligible_channels):
|
||||||
|
if not self.exporter.is_running: break
|
||||||
|
|
||||||
|
modal.set_item_status(f"Backing up ({i+1}/{total_chans}): #{chan.name}")
|
||||||
|
modal.set_progress(i, total_chans)
|
||||||
|
|
||||||
|
async def update_backup(name, count, author=None, preview=None, threads=0, files=0):
|
||||||
|
modal.update_stats(messages=str(count), threads=str(threads), files=str(files))
|
||||||
|
|
||||||
|
await self.exporter.export_channel_messages(
|
||||||
|
chan.id, progress_callback=update_backup, force=True
|
||||||
|
)
|
||||||
|
modal.write(f"[green]Completed: #{chan.name}[/green]")
|
||||||
|
|
||||||
|
modal.set_progress(total_chans, total_chans)
|
||||||
|
|
||||||
# ── (1) clone server template (combined) ─────────────────────────────
|
# ── (1) clone server template (combined) ─────────────────────────────
|
||||||
|
|
||||||
|
|
@ -1012,16 +1145,67 @@ class OperationPane(Container):
|
||||||
# ── (5) message migration ─────────────────────────────────────────────
|
# ── (5) message migration ─────────────────────────────────────────────
|
||||||
|
|
||||||
@work(exclusive=True)
|
@work(exclusive=True)
|
||||||
async def run_migrate_messages(self) -> None:
|
async def run_migrate_messages(self, modal: ProgressScreen | None = None) -> None:
|
||||||
|
await self._logic_migrate_messages(modal)
|
||||||
|
|
||||||
|
async def _logic_autotest_migrate_all_channels(self, modal: ProgressScreen) -> None:
|
||||||
|
"""Automated name-based channel migration for Auto-Test."""
|
||||||
|
migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate
|
||||||
|
|
||||||
|
# 1. Matching
|
||||||
|
modal.set_status("Auto-matching channels by name...")
|
||||||
|
await self._perform_auto_matching()
|
||||||
|
|
||||||
|
# 2. Get channels
|
||||||
|
d_channels = await self.engine.discord_reader.get_channels()
|
||||||
|
text_channels = [c for c in d_channels if c.type in [
|
||||||
|
self.engine.discord_reader.CHANNEL_TYPE_TEXT,
|
||||||
|
self.engine.discord_reader.CHANNEL_TYPE_NEWS
|
||||||
|
]]
|
||||||
|
|
||||||
|
modal.write(f"[bold cyan]Auto-Test: Found {len(text_channels)} channels to migrate.[/bold cyan]")
|
||||||
|
|
||||||
|
# 3. Migrate loop
|
||||||
|
for i, ch in enumerate(text_channels):
|
||||||
|
if not self.engine.is_running: break
|
||||||
|
|
||||||
|
tgt_id = self.engine.state.get_target_channel_id(str(ch.id))
|
||||||
|
if not tgt_id:
|
||||||
|
modal.write(f"[yellow]Skipping #{ch.name} (no target mapping found)[/yellow]")
|
||||||
|
continue
|
||||||
|
|
||||||
|
modal.write(f"\n[bold]Migrating #{ch.name} ({i+1}/{len(text_channels)}) -> Target ID {tgt_id}[/bold]")
|
||||||
|
|
||||||
|
# Analyze (silent)
|
||||||
|
stats = await migrate_mod.analyze_migration(self.engine, source_channel_id=ch.id, after_message_id=None)
|
||||||
|
ch_total = stats["messages"]
|
||||||
|
|
||||||
|
async def update_indiv(curr):
|
||||||
|
c = curr["messages"]
|
||||||
|
modal.set_progress(c, ch_total or 100)
|
||||||
|
modal.set_item_status(f"#{ch.name}: {c}/{ch_total} messages")
|
||||||
|
|
||||||
|
await migrate_mod.migrate_messages(
|
||||||
|
self.engine,
|
||||||
|
source_channel_id=ch.id,
|
||||||
|
target_channel_id=tgt_id,
|
||||||
|
after_message_id=None,
|
||||||
|
progress_callback=update_indiv
|
||||||
|
)
|
||||||
|
|
||||||
|
modal.write("\n[bold green]Automated channel migration complete.[/bold green]")
|
||||||
|
|
||||||
|
async def _logic_migrate_messages(self, modal: ProgressScreen | None = None, is_autotest: bool = False) -> None:
|
||||||
if not self.tokens_valid:
|
if not self.tokens_valid:
|
||||||
return
|
return
|
||||||
|
|
||||||
migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate
|
migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate
|
||||||
platform_name = self.target_platform.capitalize()
|
platform_name = self.target_platform.capitalize()
|
||||||
|
|
||||||
modal = ProgressScreen(log_level=self.config.log_level)
|
if not modal:
|
||||||
self.app.push_screen(modal)
|
modal = ProgressScreen(log_level=self.config.log_level)
|
||||||
await asyncio.sleep(0.1)
|
self.app.push_screen(modal)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Show info container
|
# Show info container
|
||||||
|
|
@ -1418,22 +1602,29 @@ class OperationPane(Container):
|
||||||
await self.engine.close_connections()
|
await self.engine.close_connections()
|
||||||
|
|
||||||
@work(exclusive=True)
|
@work(exclusive=True)
|
||||||
async def run_waterfall_migration(self) -> None:
|
async def run_waterfall_migration(self, modal: ProgressScreen | None = None) -> None:
|
||||||
|
await self._logic_waterfall_migration(modal)
|
||||||
|
|
||||||
|
async def _logic_waterfall_migration(self, modal: ProgressScreen | None = None, is_autotest: bool = False) -> None:
|
||||||
if not self.tokens_valid:
|
if not self.tokens_valid:
|
||||||
return
|
return
|
||||||
|
|
||||||
migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate
|
migrate_mod = fluxer_migrate if self.target_platform == "fluxer" else stoat_migrate
|
||||||
platform_name = self.target_platform.capitalize()
|
platform_name = self.target_platform.capitalize()
|
||||||
|
|
||||||
modal = ProgressScreen(log_level=self.config.log_level)
|
if not modal:
|
||||||
self.app.push_screen(modal)
|
modal = ProgressScreen(log_level=self.config.log_level)
|
||||||
await asyncio.sleep(0.1)
|
self.app.push_screen(modal)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
modal.show_info("[bold cyan]Waterfall Migration Ready[/bold cyan]", "Checking mapping and missing channels...")
|
modal.show_info("[bold cyan]Waterfall Migration Ready[/bold cyan]", "Checking mapping and missing channels...")
|
||||||
modal.set_status("Connecting to Servers...")
|
modal.set_status("Connecting to Servers...")
|
||||||
await self.engine.start_connections()
|
await self.engine.start_connections()
|
||||||
|
|
||||||
|
# Ensure writer is validated before auto-matching (fixes NoneType role fetch error)
|
||||||
|
await self.engine.writer.validate()
|
||||||
|
|
||||||
modal.set_status("Synchronizing entity mappings...")
|
modal.set_status("Synchronizing entity mappings...")
|
||||||
await self._perform_auto_matching()
|
await self._perform_auto_matching()
|
||||||
|
|
||||||
|
|
@ -1460,15 +1651,19 @@ class OperationPane(Container):
|
||||||
prefix = "[bold cyan]📁[/bold cyan] " if mc.type == 4 else "[bold white]#[/bold white] "
|
prefix = "[bold cyan]📁[/bold cyan] " if mc.type == 4 else "[bold white]#[/bold white] "
|
||||||
modal.write(f" {prefix}{mc.name}")
|
modal.write(f" {prefix}{mc.name}")
|
||||||
|
|
||||||
choice = await modal.phase_wait_confirm(
|
if is_autotest:
|
||||||
show_continue=False,
|
choice = "btn_start_first"
|
||||||
show_id=True,
|
modal.write("[bold cyan]Auto-Test: Automatically cloning missing channels.[/bold cyan]")
|
||||||
btn_start_label="Clone missing channels",
|
else:
|
||||||
btn_id_label="Skip missing channels",
|
choice = await modal.phase_wait_confirm(
|
||||||
btn_start_variant="primary",
|
show_continue=False,
|
||||||
btn_start_tooltip=f"Automatically create {len(missing_channels)} entities on target",
|
show_id=True,
|
||||||
btn_id_tooltip="Start migration without these channels"
|
btn_start_label="Clone missing channels",
|
||||||
)
|
btn_id_label="Skip missing channels",
|
||||||
|
btn_start_variant="primary",
|
||||||
|
btn_start_tooltip=f"Automatically create {len(missing_channels)} entities on target",
|
||||||
|
btn_id_tooltip="Start migration without these channels"
|
||||||
|
)
|
||||||
|
|
||||||
if choice == "btn_back":
|
if choice == "btn_back":
|
||||||
modal.dismiss()
|
modal.dismiss()
|
||||||
|
|
@ -1550,7 +1745,7 @@ class OperationPane(Container):
|
||||||
|
|
||||||
if filtered_tgt_ids:
|
if filtered_tgt_ids:
|
||||||
all_mapped_tgt_ids = filtered_tgt_ids
|
all_mapped_tgt_ids = filtered_tgt_ids
|
||||||
|
|
||||||
# 2.6 Resume Point: Calculate from global channel minimums
|
# 2.6 Resume Point: Calculate from global channel minimums
|
||||||
min_last_id = self.engine.state.get_global_min_last_message_id(all_mapped_tgt_ids)
|
min_last_id = self.engine.state.get_global_min_last_message_id(all_mapped_tgt_ids)
|
||||||
|
|
||||||
|
|
@ -1560,15 +1755,19 @@ class OperationPane(Container):
|
||||||
else:
|
else:
|
||||||
modal.write("No previous migration state found. Starting from the beginning.")
|
modal.write("No previous migration state found. Starting from the beginning.")
|
||||||
|
|
||||||
choice = await modal.phase_wait_confirm(
|
if is_autotest:
|
||||||
show_continue=min_last_id is not None,
|
choice = "btn_continue" if min_last_id is not None else "btn_start_first"
|
||||||
show_id=False,
|
modal.write(f"[bold cyan]Auto-Test: Automatically choosing {choice.replace('btn_', '').replace('_', ' ')}.[/bold cyan]")
|
||||||
btn_start_label="Start From Beginning",
|
else:
|
||||||
btn_start_tooltip="Wipes migration progress and restarts from the beginning; may create duplicates",
|
choice = await modal.phase_wait_confirm(
|
||||||
btn_start_variant="default" if min_last_id is not None else "primary",
|
show_continue=min_last_id is not None,
|
||||||
btn_continue_label=f"Continue from ID {min_last_id if min_last_id is not None else 0}" if min_last_id is not None else "Continue Migration",
|
show_id=False,
|
||||||
btn_continue_tooltip="Fastest"
|
btn_start_label="Start From Beginning",
|
||||||
)
|
btn_start_tooltip="Wipes migration progress and restarts from the beginning; may create duplicates",
|
||||||
|
btn_start_variant="default" if min_last_id is not None else "primary",
|
||||||
|
btn_continue_label=f"Continue from ID {min_last_id if min_last_id is not None else 0}" if min_last_id is not None else "Continue Migration",
|
||||||
|
btn_continue_tooltip="Fastest"
|
||||||
|
)
|
||||||
|
|
||||||
if choice == "btn_back":
|
if choice == "btn_back":
|
||||||
modal.dismiss()
|
modal.dismiss()
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from src.ui.mode_screen import ModeScreen
|
||||||
from src.core.configuration import AppConfig
|
from src.core.configuration import AppConfig
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from textual.widgets import ListItem, ListView, Input, Button
|
from textual.widgets import ListItem, ListView, Input, Button, Label
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,6 +18,10 @@ def mock_configs(tmp_path, log):
|
||||||
reaper_dir.mkdir()
|
reaper_dir.mkdir()
|
||||||
(reaper_dir / "reaper_config.yaml").write_text("discord_bot_token: 'fake'\ndiscord_server_id: '123'\ntool_mode: 'backup_only'")
|
(reaper_dir / "reaper_config.yaml").write_text("discord_bot_token: 'fake'\ndiscord_server_id: '123'\ntool_mode: 'backup_only'")
|
||||||
|
|
||||||
|
autotest_dir = tmp_path / "ReaperFiles-AutoTest"
|
||||||
|
autotest_dir.mkdir()
|
||||||
|
(autotest_dir / "reaper_config.yaml").write_text("discord_bot_token: 'fake'\ndiscord_server_id: '123'\ntool_mode: 'backup_transfer'")
|
||||||
|
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
os.chdir(tmp_path)
|
os.chdir(tmp_path)
|
||||||
log(f"CWD changed to: {tmp_path}")
|
log(f"CWD changed to: {tmp_path}")
|
||||||
|
|
@ -115,3 +119,46 @@ async def test_ui_operation_trigger(mock_configs, log):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"test_ui_operation_trigger FAILED: {e}")
|
log(f"test_ui_operation_trigger FAILED: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ui_autotest_button(mock_configs, log):
|
||||||
|
"""Verify visibility and trigger of the AUTO TEST button."""
|
||||||
|
log("Running test_ui_autotest_button")
|
||||||
|
from src.ui.shuttle_ops import OperationPane
|
||||||
|
try:
|
||||||
|
with patch("src.ui.main_app.ConfigSelectionScreen.check_updates", AsyncMock()):
|
||||||
|
with patch.object(OperationPane, "run_validate", AsyncMock()):
|
||||||
|
app = ReaperApp()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await wait_for_screen(app, ConfigSelectionScreen)
|
||||||
|
# Find the AutoTest item in the list
|
||||||
|
lv = app.screen.query_one(ListView)
|
||||||
|
autotest_index = -1
|
||||||
|
for idx, item in enumerate(lv.children):
|
||||||
|
if item.name == "AutoTest":
|
||||||
|
autotest_index = idx
|
||||||
|
break
|
||||||
|
|
||||||
|
assert autotest_index != -1, "AutoTest profile not found in ListView"
|
||||||
|
target_item = lv.children[autotest_index]
|
||||||
|
await pilot.click(target_item)
|
||||||
|
assert await wait_for_screen(app, ModeScreen), "Timed out waiting for ModeScreen"
|
||||||
|
|
||||||
|
# Verify button is present
|
||||||
|
pane = app.screen.query_one(OperationPane)
|
||||||
|
btn = pane.query_one("#op_autotest", Button)
|
||||||
|
assert btn.display is True
|
||||||
|
assert "AUTO TEST" in str(btn.label)
|
||||||
|
|
||||||
|
# Mock the sequence and trigger it
|
||||||
|
with patch.object(OperationPane, "run_autotest_sequence", AsyncMock()) as mock_seq:
|
||||||
|
btn.disabled = False
|
||||||
|
await pilot.pause(0.1)
|
||||||
|
btn.focus()
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause(0.1)
|
||||||
|
assert mock_seq.called
|
||||||
|
log("test_ui_autotest_button PASSED")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"test_ui_autotest_button FAILED: {e}")
|
||||||
|
raise
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue