diff --git a/src/fluxer/writer.py b/src/fluxer/writer.py index b1c5864..0690034 100644 --- a/src/fluxer/writer.py +++ b/src/fluxer/writer.py @@ -46,16 +46,16 @@ class FluxerWriter: assert self.client is not None try: - # 1. Try to find existing webhook named "Stoat-Migrator" + # 1. Try to find existing webhook named "ReapersWebhook" webhooks_data = await self.client.get_channel_webhooks(channel_id) for w_data in webhooks_data: - if w_data.get("name") == "Stoat-Migrator": + if w_data.get("name") == "ReapersWebhook": w = Webhook.from_data(w_data, self.client) self._webhooks[channel_id] = w return w # 2. Create new one if not found - w_data = await self.client.create_webhook(channel_id, name="Stoat-Migrator") + w_data = await self.client.create_webhook(channel_id, name="ReapersWebhook") w = Webhook.from_data(w_data, self.client) self._webhooks[channel_id] = w return w diff --git a/src/ui/modals.py b/src/ui/modals.py index 7b7b1a5..0af68d8 100644 --- a/src/ui/modals.py +++ b/src/ui/modals.py @@ -552,13 +552,14 @@ class ChannelPickerScreen(Screen[tuple]): #chanpick_buttons Button { width: 1fr; margin: 0 1; } """ - def __init__(self, src_channels: list, src_cat_map: dict, tgt_channels: list, tgt_cat_map: dict, tgt_name: str = "Fluxer"): + def __init__(self, src_channels: list, src_cat_map: dict, tgt_channels: list, tgt_cat_map: dict, tgt_name: str = "Fluxer", all_tgt_channels: list | None = None): super().__init__() self.src_channels = src_channels self.src_cat_map = src_cat_map self.tgt_channels = tgt_channels self.tgt_cat_map = tgt_cat_map self.tgt_name = tgt_name + self.all_tgt_channels = all_tgt_channels or tgt_channels def _render_pane(self, channels, categories, pane_id, prefix): cat_grouped: dict[int | None, list] = {} @@ -586,6 +587,12 @@ class ChannelPickerScreen(Screen[tuple]): cid = c.id options.append(Option(name, id=f"{prefix}_{cid}")) + # Add "Extras" category to the target pane only + if prefix == "tgt": + options.append(Option(f"[bold yellow]── Extras ──[/bold yellow]", id="header_extras", disabled=True)) + options.append(Option("✨ Create New channel+", id="tgt_create_new")) + options.append(Option("🔗 Enter Channel ID", id="tgt_enter_id")) + yield OptionList(*options, id=f"{prefix}_list", classes="split_pane") def compose(self) -> ComposeResult: @@ -654,11 +661,37 @@ class ChannelPickerScreen(Screen[tuple]): opt = tgt_list.get_option_at_index(tgt_list.highlighted) if opt.id and opt.id.startswith("tgt_"): tgt_val = opt.id.split("_", 1)[1] - - if src_val and tgt_val: - self.dismiss((int(src_val), tgt_val)) # Source is guaranteed Discord ID (int), Target could be UUID/string - else: - self.notify("Please select one channel from both lists.", severity="warning") + + if not src_val or not tgt_val: + self.notify("Please select one channel from both lists.", severity="warning") + return + + # Handle "Extras" options by pushing sub-modals on top of this screen + if tgt_val == "create_new": + # Get source channel name for default + src_name = "" + for c in self.src_channels: + cid = c.get("id") if isinstance(c, dict) else c.id + if str(cid) == src_val: + src_name = c.get("name") if isinstance(c, dict) else c.name + break + + def on_name_result(name): + if name: + self.dismiss((int(src_val), "create_new", name)) + + self.app.push_screen(ChannelNameInputModal(default_name=src_name), on_name_result) + return + + elif tgt_val == "enter_id": + def on_chan_result(chan_dict): + if chan_dict: + self.dismiss((int(src_val), str(chan_dict.get("id")), chan_dict)) + + self.app.push_screen(ChannelIDInputModal(known_channels=self.all_tgt_channels), on_chan_result) + return + + self.dismiss((int(src_val), tgt_val)) # Source is guaranteed Discord ID (int), Target could be UUID/string # --------------------------------------------------------------------------- @@ -894,3 +927,148 @@ class MessageIDInputModal(ModalScreen[int | None]): preview.update(f"[bold red]Error fetching message:[/bold red] {e}") finally: btn.disabled = False + + +# --------------------------------------------------------------------------- +# ChannelNameInputModal – Input a channel name for creation +# --------------------------------------------------------------------------- + +class ChannelNameInputModal(ModalScreen[str | None]): + """Modal to input a channel name for creation on the target platform.""" + + DEFAULT_CSS = """ + ChannelNameInputModal { align: center middle; } + #chan_name_dialog { + width: 70%; + height: auto; + border: solid green; + padding: 1 2; + background: $surface; + } + #chan_name_buttons { + height: auto; + dock: bottom; + margin-top: 1; + } + #chan_name_buttons Button { width: 1fr; margin: 0 1; } + """ + + def __init__(self, default_name: str = ""): + super().__init__() + self.default_name = default_name + + def compose(self) -> ComposeResult: + with Container(id="chan_name_dialog"): + yield Label("[bold green]Create New Channel[/bold green]") + yield Label("Enter a name for the new channel:") + yield Input(value=self.default_name, placeholder="channel-name", id="input_chan_name") + + with Horizontal(id="chan_name_buttons"): + yield Button("Create", variant="success", id="btn_create_chan") + yield Button("Back", id="btn_cancel_chan_name") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_cancel_chan_name": + self.dismiss(None) + elif event.button.id == "btn_create_chan": + name = self.query_one("#input_chan_name", Input).value.strip() + if name: + self.dismiss(name) + else: + self.notify("Please enter a channel name.", severity="warning") + + +# --------------------------------------------------------------------------- +# ChannelIDInputModal – Input and verify a channel ID +# --------------------------------------------------------------------------- + +class ChannelIDInputModal(ModalScreen[dict | None]): + """Modal to input a target channel ID, verify it exists, and select it.""" + + DEFAULT_CSS = """ + ChannelIDInputModal { align: center middle; } + #chan_id_dialog { + width: 80%; + height: auto; + border: solid yellow; + padding: 1 2; + background: $surface; + } + #chan_id_preview { + border: solid $primary; + padding: 1 2; + margin: 1 0; + height: auto; + min-height: 3; + } + #chan_id_buttons { + height: auto; + dock: bottom; + margin-top: 0; + } + #chan_id_buttons Button { width: 1fr; margin: 0 1; } + """ + + def __init__(self, known_channels: list[dict]): + super().__init__() + self.known_channels = known_channels # list of channel dicts from writer.get_channels() + self.verified_channel: dict | None = None + + def compose(self) -> ComposeResult: + with Container(id="chan_id_dialog"): + yield Label("[bold yellow]Enter Target Channel ID[/bold yellow]") + yield Input(placeholder="Enter Channel ID", id="input_chan_id", type="text") + with Container(id="chan_id_preview"): + yield Label("Enter an ID and click Verify.", id="lbl_chan_preview") + + with Horizontal(id="chan_id_buttons"): + yield Button("Verify", variant="primary", id="btn_verify_chan", disabled=True) + yield Button("Back", variant="warning", id="btn_cancel_chan_id") + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "input_chan_id": + btn = self.query_one("#btn_verify_chan", Button) + self.verified_channel = None + btn.label = "Verify" + btn.variant = "primary" + + val = event.input.value.strip() + btn.disabled = not bool(val) + + preview = self.query_one("#lbl_chan_preview", Label) + preview.update("Click Verify to check channel.") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_cancel_chan_id": + self.dismiss(None) + elif event.button.id == "btn_verify_chan": + if self.verified_channel is not None: + self.dismiss(self.verified_channel) + return + + inp = self.query_one("#input_chan_id", Input) + preview = self.query_one("#lbl_chan_preview", Label) + btn = event.button + + chan_id_str = inp.value.strip() + if not chan_id_str: + preview.update("[bold red]Please enter a Channel ID.[/bold red]") + return + + # Search in known channels + found = None + for ch in self.known_channels: + if str(ch.get("id")) == chan_id_str: + found = ch + break + + if found: + self.verified_channel = found + ch_name = found.get("name", "Unknown") + ch_type = found.get("type", 0) + type_label = {0: "Text", 2: "Voice", 4: "Category"}.get(ch_type, f"Type {ch_type}") + preview.update(f"[bold green]Channel Found![/bold green]\n\n Name: [cyan]{ch_name}[/cyan]\n Type: {type_label}\n ID: {chan_id_str}") + btn.label = "Select This Channel" + btn.variant = "success" + else: + preview.update(f"[bold red]No channel found with ID: {chan_id_str}[/bold red]\n[dim]Make sure the ID belongs to a channel in the target community.[/dim]") diff --git a/src/ui/shuttle_ops.py b/src/ui/shuttle_ops.py index d9d59dc..9086866 100644 --- a/src/ui/shuttle_ops.py +++ b/src/ui/shuttle_ops.py @@ -827,20 +827,44 @@ class ShuttlePane(Container): if not pick_future.done(): pick_future.set_result(result) - self.app.push_screen(ChannelPickerScreen(d_channels, d_cat_map, f_channels, target_cat_names, platform_name), on_pick) + self.app.push_screen(ChannelPickerScreen(d_channels, d_cat_map, f_channels, target_cat_names, platform_name, all_tgt_channels=full_f), on_pick) res = await pick_future if res is None: await self.engine.close_connections() return - src_id, tgt_id = res - source_channel = next(c for c in d_channels if c.id == src_id) - target_channel = next(c for c in f_channels if c.get("id") == tgt_id) + # Handle result from channel picker + # Normal: (src_id, tgt_id) - 2-tuple + # Create new: (src_id, "create_new", channel_name) - 3-tuple + # Enter ID: (src_id, tgt_id, channel_dict) - 3-tuple + + pending_create_name = None # Deferred channel creation + + if len(res) == 3 and res[1] == "create_new": + src_id, _, chan_name = res + source_channel = next(c for c in d_channels if c.id == src_id) + # Don't create yet — defer until user confirms migration + pending_create_name = chan_name + target_channel = {"id": "__pending__", "name": chan_name, "type": 0} + + elif len(res) == 3: + src_id, _, chan_dict = res + source_channel = next(c for c in d_channels if c.id == src_id) + target_channel = chan_dict + + else: + src_id, tgt_id = res + source_channel = next(c for c in d_channels if c.id == src_id) + target_channel = next(c for c in f_channels if c.get("id") == tgt_id) - # Determine after_id status - last_migrated = self.engine.state.get_last_message_id(str(target_channel.get('id'))) - has_previous = bool(last_migrated) + # Determine after_id status (skip for pending channels) + if pending_create_name: + last_migrated = None + has_previous = False + else: + last_migrated = self.engine.state.get_last_message_id(str(target_channel.get('id'))) + has_previous = bool(last_migrated) # Analyze modal = ProgressScreen(log_level=self.config.log_level) @@ -963,6 +987,20 @@ class ShuttlePane(Container): # If we are here, we are proceeding with migration break + # Create the channel now if it was deferred + if pending_create_name: + modal.set_status(f"Creating channel [green]#{pending_create_name}[/green]...") + try: + new_id = await self.engine.writer.create_channel(name=pending_create_name) + logger.info(f"Created new channel '{pending_create_name}' with ID: {new_id}") + target_channel = {"id": new_id, "name": pending_create_name, "type": 0} + f_channels.append(target_channel) + except Exception as e: + logger.error(f"Failed to create channel '{pending_create_name}': {e}") + modal.write(f"[bold red]Failed to create channel: {e}[/bold red]") + modal.phase_report("Channel Creation", status="error") + return + # Phase 3: Progress modal.cancel_callback = lambda: setattr(self.engine, "is_running", False) modal.phase_progress()