diff --git a/data/icons/hicolor/symbolic/apps/export-symbolic.svg b/data/icons/hicolor/symbolic/apps/export-symbolic.svg new file mode 100644 index 0000000..19e0b42 --- /dev/null +++ b/data/icons/hicolor/symbolic/apps/export-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/icons/meson.build b/data/icons/meson.build index 2e13dd9..5f4186f 100644 --- a/data/icons/meson.build +++ b/data/icons/meson.build @@ -11,3 +11,9 @@ install_data( symbolic_dir / ('@0@-symbolic.svg').format(application_id), install_dir: get_option('datadir') / 'icons' / symbolic_dir ) + +# Install custom export icon +install_data( + symbolic_dir / 'export-symbolic.svg', + install_dir: get_option('datadir') / 'icons' / symbolic_dir +) diff --git a/src/export-dialog.ui b/src/export-dialog.ui new file mode 100644 index 0000000..4e19d84 --- /dev/null +++ b/src/export-dialog.ui @@ -0,0 +1,60 @@ + + + + + + + diff --git a/src/export_dialog.py b/src/export_dialog.py new file mode 100644 index 0000000..170bf41 --- /dev/null +++ b/src/export_dialog.py @@ -0,0 +1,90 @@ +# export_dialog.py +# +# Copyright 2025 Pavel Baksy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from gi.repository import Adw, Gtk, Gdk, GLib +import logging + +logger = logging.getLogger(__name__) + + +@Gtk.Template(resource_path='/cz/bugsy/roster/export-dialog.ui') +class ExportDialog(Adw.Dialog): + """Dialog for exporting HTTP requests in various formats.""" + + __gtype_name__ = 'ExportDialog' + + export_textview = Gtk.Template.Child() + copy_button = Gtk.Template.Child() + + def __init__(self, request, **kwargs): + """ + Initialize export dialog. + + Args: + request: HttpRequest to export (should have variables substituted) + """ + super().__init__(**kwargs) + self.request = request + self._populate_export() + + def _populate_export(self): + """Generate and display the export content.""" + try: + # Get cURL exporter + from .exporters import ExporterRegistry + exporter = ExporterRegistry.get_exporter('curl') + + # Generate cURL command + curl_command = exporter.export(self.request) + + # Display in textview + buffer = self.export_textview.get_buffer() + buffer.set_text(curl_command) + + except Exception as e: + logger.error(f"Export generation failed: {e}") + buffer = self.export_textview.get_buffer() + buffer.set_text(f"Error generating export: {str(e)}") + + @Gtk.Template.Callback() + def on_copy_clicked(self, button): + """Copy export content to clipboard.""" + # Get text from buffer + buffer = self.export_textview.get_buffer() + start = buffer.get_start_iter() + end = buffer.get_end_iter() + text = buffer.get_text(start, end, False) + + # Copy to clipboard using Gdk.Clipboard API + display = Gdk.Display.get_default() + clipboard = display.get_clipboard() + clipboard.set(text) + + # Visual feedback: temporarily change button label + original_label = button.get_label() + button.set_label("Copied!") + + # Reset after 2 seconds + def reset_label(): + button.set_label(original_label) + return False # Don't repeat + + GLib.timeout_add(2000, reset_label) + + logger.debug("Export copied to clipboard") diff --git a/src/exporters/__init__.py b/src/exporters/__init__.py new file mode 100644 index 0000000..75b41c7 --- /dev/null +++ b/src/exporters/__init__.py @@ -0,0 +1,24 @@ +# exporters package +# +# Copyright 2025 Pavel Baksy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from .registry import ExporterRegistry +from .base_exporter import BaseExporter +from .curl_exporter import CurlExporter + +__all__ = ['ExporterRegistry', 'BaseExporter', 'CurlExporter'] diff --git a/src/exporters/base_exporter.py b/src/exporters/base_exporter.py new file mode 100644 index 0000000..5c4b9f6 --- /dev/null +++ b/src/exporters/base_exporter.py @@ -0,0 +1,50 @@ +# base_exporter.py +# +# Copyright 2025 Pavel Baksy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from abc import ABC, abstractmethod +from ..models import HttpRequest + + +class BaseExporter(ABC): + """Abstract base class for request exporters.""" + + @abstractmethod + def export(self, request: HttpRequest) -> str: + """ + Export HTTP request to target format. + + Args: + request: HttpRequest with method, url, headers, body + + Returns: + Formatted export string + """ + pass + + @property + @abstractmethod + def format_name(self) -> str: + """Human-readable format name (e.g., 'cURL', 'Insomnia').""" + pass + + @property + @abstractmethod + def file_extension(self) -> str: + """File extension for export (e.g., 'sh', 'json').""" + pass diff --git a/src/exporters/curl_exporter.py b/src/exporters/curl_exporter.py new file mode 100644 index 0000000..ab50a63 --- /dev/null +++ b/src/exporters/curl_exporter.py @@ -0,0 +1,70 @@ +# curl_exporter.py +# +# Copyright 2025 Pavel Baksy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import shlex +from .base_exporter import BaseExporter +from ..models import HttpRequest + + +class CurlExporter(BaseExporter): + """Export HTTP requests as cURL commands.""" + + @property + def format_name(self) -> str: + return "cURL" + + @property + def file_extension(self) -> str: + return "sh" + + def export(self, request: HttpRequest) -> str: + """ + Generate cURL command with proper shell escaping. + + Format: + curl -X POST \ + 'https://api.example.com/endpoint' \ + -H 'Content-Type: application/json' \ + --data '{"key": "value"}' + """ + parts = ["curl"] + + # Add HTTP method (skip if GET - it's default) + if request.method != "GET": + parts.append(f"-X {request.method}") + + # Add URL (always quoted for safety) + parts.append(shlex.quote(request.url)) + + # Add headers + for key, value in request.headers.items(): + header_str = f"{key}: {value}" + parts.append(f"-H {shlex.quote(header_str)}") + + # Add body for POST/PUT/PATCH/DELETE methods + if request.body and request.method in ["POST", "PUT", "PATCH", "DELETE"]: + parts.append(f"--data {shlex.quote(request.body)}") + + # Format as multiline with backslash continuations + return " \\\n ".join(parts) + + +# Auto-register on import +from .registry import ExporterRegistry +ExporterRegistry.register('curl', CurlExporter) diff --git a/src/exporters/registry.py b/src/exporters/registry.py new file mode 100644 index 0000000..5829286 --- /dev/null +++ b/src/exporters/registry.py @@ -0,0 +1,46 @@ +# registry.py +# +# Copyright 2025 Pavel Baksy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Dict, List, Tuple, Type +from .base_exporter import BaseExporter + + +class ExporterRegistry: + """Registry for managing export formats.""" + + _exporters: Dict[str, Type[BaseExporter]] = {} + + @classmethod + def register(cls, format_id: str, exporter_class: Type[BaseExporter]): + """Register an exporter class.""" + cls._exporters[format_id] = exporter_class + + @classmethod + def get_exporter(cls, format_id: str) -> BaseExporter: + """Get exporter instance by format ID.""" + exporter_class = cls._exporters.get(format_id) + if not exporter_class: + raise ValueError(f"Unknown export format: {format_id}") + return exporter_class() + + @classmethod + def get_all_formats(cls) -> List[Tuple[str, str]]: + """Get all registered formats as [(id, name), ...].""" + return [(format_id, cls._exporters[format_id]().format_name) + for format_id in cls._exporters.keys()] diff --git a/src/main-window.ui b/src/main-window.ui index d258f3d..1ababe8 100644 --- a/src/main-window.ui +++ b/src/main-window.ui @@ -32,8 +32,9 @@ Projects + 6 @@ -96,6 +97,17 @@ + + + export-symbolic + Export as cURL + + + + + diff --git a/src/meson.build b/src/meson.build index 84aaee2..dc7ff9c 100644 --- a/src/meson.build +++ b/src/meson.build @@ -39,6 +39,7 @@ roster_sources = [ 'constants.py', 'icon_picker_dialog.py', 'environments_dialog.py', + 'export_dialog.py', 'preferences_dialog.py', 'request_tab_widget.py', 'script_executor.py', @@ -46,6 +47,16 @@ roster_sources = [ install_data(roster_sources, install_dir: moduledir) +# Install exporters submodule +exporters_sources = [ + 'exporters/__init__.py', + 'exporters/base_exporter.py', + 'exporters/curl_exporter.py', + 'exporters/registry.py', +] + +install_data(exporters_sources, install_dir: moduledir / 'exporters') + # Install widgets submodule widgets_sources = [ 'widgets/__init__.py', diff --git a/src/roster.gresource.xml b/src/roster.gresource.xml index 431cafd..d81a418 100644 --- a/src/roster.gresource.xml +++ b/src/roster.gresource.xml @@ -6,6 +6,8 @@ preferences-dialog.ui icon-picker-dialog.ui environments-dialog.ui + export-dialog.ui + ../data/icons/hicolor/symbolic/apps/export-symbolic.svg widgets/header-row.ui widgets/history-item.ui widgets/project-item.ui diff --git a/src/window.py b/src/window.py index 745eca3..2735daf 100644 --- a/src/window.py +++ b/src/window.py @@ -55,6 +55,7 @@ class RosterWindow(Adw.ApplicationWindow): # Top bar widgets save_request_button = Gtk.Template.Child() + export_request_button = Gtk.Template.Child() new_request_button = Gtk.Template.Child() tab_view = Gtk.Template.Child() tab_bar = Gtk.Template.Child() @@ -336,7 +337,7 @@ class RosterWindow(Adw.ApplicationWindow): """Create a new tab with RequestTabWidget.""" # Create tab in tab manager if not request: - request = HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW") + request = HttpRequest(method="GET", url="", headers=HttpRequest.default_headers(), body="", syntax="RAW") tab = self.tab_manager.create_tab(name, request, saved_request_id, response, project_id, selected_environment_id, scripts) @@ -1078,6 +1079,47 @@ class RosterWindow(Adw.ApplicationWindow): dialog.connect("response", on_response) dialog.present(self) + @Gtk.Template.Callback() + def on_export_request_clicked(self, button): + """Handle Export button click - export request as cURL command.""" + # Get current tab + page = self.tab_view.get_selected_page() + if not page: + self._show_toast("No active request") + return + + widget = self.page_to_widget.get(page) + if not widget: + self._show_toast("No active request") + return + + # Get request from widget (raw with {{variables}}) + request = widget.get_request() + + # Validate URL + if not request.url.strip(): + self._show_toast("Cannot export: URL is empty") + return + + # Apply variable substitution if environment is selected + substituted_request = request + if widget.selected_environment_id: + env = widget.get_selected_environment() + if env: + from .variable_substitution import VariableSubstitution + substituted_request, undefined = VariableSubstitution.substitute_request(request, env) + + # Warn about undefined variables + if undefined: + logger.warning(f"Undefined variables in export: {', '.join(undefined)}") + # Show warning toast but continue with export + self._show_toast(f"Warning: {len(undefined)} undefined variable(s)") + + # Show export dialog with substituted request + from .export_dialog import ExportDialog + dialog = ExportDialog(substituted_request) + dialog.present(self) + def _mark_tab_as_saved(self, saved_request_id, name, request, scripts=None): """Mark the current tab as saved (clear modified flag).""" page = self.tab_view.get_selected_page()