diff --git a/src/http_file_import_dialog.py b/src/http_file_import_dialog.py new file mode 100644 index 0000000..1763f25 --- /dev/null +++ b/src/http_file_import_dialog.py @@ -0,0 +1,360 @@ +# http_file_import_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, GObject, Gio +from typing import List + +from .http_file_importer import ( + parse_http_file, build_http_request, + HttpFileParseResult, HttpFileRequest, +) + +_METHOD_CLASS = { + 'GET': 'accent', + 'POST': 'success', + 'PUT': 'warning', + 'PATCH': 'warning', + 'DELETE': 'error', +} + + +class HttpFileImportDialog(Adw.Dialog): + """Two-step dialog: pick .http file → select requests → import.""" + + __gtype_name__ = 'HttpFileImportDialog' + + __gsignals__ = { + 'import-completed': (GObject.SIGNAL_RUN_FIRST, None, (str,)), + } + + def __init__(self, project_manager, existing_projects): + super().__init__() + self.set_title("Import from .http File") + self.set_content_width(580) + self.set_content_height(560) + + self._pm = project_manager + self._existing_projects = list(existing_projects) + self._parse_result: HttpFileParseResult | None = None + self._req_checkboxes: List[tuple[HttpFileRequest, Gtk.CheckButton]] = [] + + self._build_ui() + + # ------------------------------------------------------------------ build + + def _build_ui(self): + tv = Adw.ToolbarView() + tv.add_top_bar(Adw.HeaderBar()) + + self._stack = Gtk.Stack() + self._stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) + self._stack.set_transition_duration(200) + self._stack.add_named(self._make_file_page(), "file") + self._stack.add_named(self._make_reqs_page(), "reqs") + tv.set_content(self._stack) + + ab = Gtk.ActionBar() + + self._back_btn = Gtk.Button(label="Back") + self._back_btn.connect("clicked", lambda _: self._show_file_page()) + ab.pack_start(self._back_btn) + + self._browse_btn = Gtk.Button(label="Choose File…") + self._browse_btn.add_css_class("suggested-action") + self._browse_btn.connect("clicked", self._on_browse) + ab.pack_end(self._browse_btn) + + self._import_btn = Gtk.Button(label="Import") + self._import_btn.add_css_class("suggested-action") + self._import_btn.connect("clicked", self._on_import) + ab.pack_end(self._import_btn) + + tv.add_bottom_bar(ab) + self.set_child(tv) + self._show_file_page() + + def _make_file_page(self) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) + box.set_valign(Gtk.Align.CENTER) + box.set_vexpand(True) + box.set_margin_top(32) + box.set_margin_bottom(24) + box.set_margin_start(24) + box.set_margin_end(24) + + icon = Gtk.Image.new_from_icon_name("document-open-symbolic") + icon.set_pixel_size(64) + icon.add_css_class("dim-label") + box.append(icon) + + title = Gtk.Label(label="Import from .http File") + title.add_css_class("title-2") + box.append(title) + + subtitle = Gtk.Label( + label="Choose a .http or .rest file (IntelliJ HTTP Client format) to import its requests" + ) + subtitle.add_css_class("dim-label") + subtitle.set_wrap(True) + subtitle.set_justify(Gtk.Justification.CENTER) + box.append(subtitle) + + self._file_label = Gtk.Label() + self._file_label.add_css_class("monospace") + self._file_label.add_css_class("dim-label") + self._file_label.set_visible(False) + box.append(self._file_label) + + self._err_label = Gtk.Label() + self._err_label.add_css_class("error") + self._err_label.set_wrap(True) + self._err_label.set_visible(False) + box.append(self._err_label) + + return box + + def _make_reqs_page(self) -> Gtk.Widget: + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + outer.set_margin_top(14) + outer.set_margin_bottom(14) + outer.set_margin_start(16) + outer.set_margin_end(16) + + # File info row + file_grp = Adw.PreferencesGroup() + file_grp.set_title("File") + self._file_row = Adw.ActionRow() + self._file_row.set_title("Path") + file_grp.add(self._file_row) + outer.append(file_grp) + + # Requests header with select-all button + reqs_hdr = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + reqs_hdr.set_margin_top(2) + reqs_lbl = Gtk.Label(label="Requests") + reqs_lbl.add_css_class("title-4") + reqs_lbl.set_hexpand(True) + reqs_lbl.set_xalign(0) + reqs_hdr.append(reqs_lbl) + self._sel_all_btn = Gtk.Button(label="Deselect All") + self._sel_all_btn.add_css_class("flat") + self._sel_all_btn.connect("clicked", self._on_select_all) + reqs_hdr.append(self._sel_all_btn) + outer.append(reqs_hdr) + + scroll = Gtk.ScrolledWindow() + scroll.set_vexpand(True) + scroll.set_min_content_height(80) + self._reqs_list = Gtk.ListBox() + self._reqs_list.add_css_class("boxed-list") + self._reqs_list.set_selection_mode(Gtk.SelectionMode.NONE) + scroll.set_child(self._reqs_list) + outer.append(scroll) + + # Import target + tgt_grp = Adw.PreferencesGroup() + tgt_grp.set_title("Import to") + tgt_grp.set_margin_top(2) + + new_row = Adw.ActionRow() + new_row.set_title("Create new project") + self._new_radio = Gtk.CheckButton() + self._new_radio.set_active(True) + new_row.add_prefix(self._new_radio) + new_row.set_activatable_widget(self._new_radio) + self._new_name_entry = Gtk.Entry() + self._new_name_entry.set_placeholder_text("Project name") + self._new_name_entry.set_hexpand(True) + self._new_name_entry.set_valign(Gtk.Align.CENTER) + new_row.add_suffix(self._new_name_entry) + tgt_grp.add(new_row) + + if self._existing_projects: + exist_row = Adw.ActionRow() + exist_row.set_title("Add to existing project") + self._exist_radio = Gtk.CheckButton() + self._exist_radio.set_group(self._new_radio) + exist_row.add_prefix(self._exist_radio) + exist_row.set_activatable_widget(self._exist_radio) + proj_list = Gtk.StringList.new([p.name for p in self._existing_projects]) + self._proj_dd = Gtk.DropDown(model=proj_list) + self._proj_dd.set_valign(Gtk.Align.CENTER) + self._proj_dd.set_sensitive(False) + exist_row.add_suffix(self._proj_dd) + tgt_grp.add(exist_row) + self._new_radio.connect("toggled", self._on_target_toggled) + else: + self._exist_radio = None + self._proj_dd = None + + outer.append(tgt_grp) + return outer + + # ----------------------------------------------------------- page control + + def _show_file_page(self): + self._stack.set_visible_child_name("file") + self._back_btn.set_visible(False) + self._browse_btn.set_visible(True) + self._import_btn.set_visible(False) + + def _show_reqs_page(self): + self._stack.set_visible_child_name("reqs") + self._back_btn.set_visible(True) + self._browse_btn.set_visible(False) + self._import_btn.set_visible(True) + + # --------------------------------------------------------- event handlers + + def _on_target_toggled(self, _radio): + is_new = self._new_radio.get_active() + self._new_name_entry.set_sensitive(is_new) + if self._proj_dd: + self._proj_dd.set_sensitive(not is_new) + + def _on_select_all(self, _btn): + all_on = all(cb.get_active() for _, cb in self._req_checkboxes) + new_state = not all_on + for _, cb in self._req_checkboxes: + cb.set_active(new_state) + self._sel_all_btn.set_label("Deselect All" if new_state else "Select All") + + def _on_browse(self, _btn): + self._err_label.set_visible(False) + + chooser = Gtk.FileChooserNative( + title="Open HTTP File", + action=Gtk.FileChooserAction.OPEN, + accept_label="Open", + cancel_label="Cancel", + transient_for=self.get_root(), + ) + + http_filter = Gtk.FileFilter() + http_filter.set_name("HTTP files (*.http, *.rest)") + http_filter.add_pattern("*.http") + http_filter.add_pattern("*.rest") + chooser.add_filter(http_filter) + + all_filter = Gtk.FileFilter() + all_filter.set_name("All files") + all_filter.add_pattern("*") + chooser.add_filter(all_filter) + + chooser.connect("response", self._on_file_chosen) + chooser.show() + # Keep a reference so GC doesn't collect it + self._chooser = chooser + + def _on_file_chosen(self, chooser, response): + if response != Gtk.ResponseType.ACCEPT: + return + + gfile = chooser.get_file() + if not gfile: + return + + path = gfile.get_path() + + try: + content = gfile.load_contents(None)[1].decode('utf-8', errors='replace') + except Exception as e: + self._set_err(f"Could not read file: {e}") + return + + parsed = parse_http_file(content) + + if parsed.error: + self._set_err(parsed.error) + self._file_label.set_label(path or "") + self._file_label.set_visible(True) + return + + self._parse_result = parsed + self._current_path = path or "" + self._populate_reqs_page(parsed, path or "") + self._show_reqs_page() + + def _on_import(self, _btn): + if not self._parse_result: + return + + selected = [req for req, cb in self._req_checkboxes if cb.get_active()] + if not selected: + return + + if self._new_radio.get_active(): + name = self._new_name_entry.get_text().strip() + if not name: + return + project = self._pm.add_project(name) + project_id = project.id + elif self._exist_radio and self._exist_radio.get_active() and self._proj_dd: + idx = self._proj_dd.get_selected() + if idx >= len(self._existing_projects): + return + project_id = self._existing_projects[idx].id + else: + return + + for req in selected: + http_req = build_http_request(req) + self._pm.add_request(project_id, req.name, http_req) + + self.emit('import-completed', project_id) + self.close() + + # ----------------------------------------------------------------- helpers + + def _set_err(self, msg: str): + self._err_label.set_text(msg) + self._err_label.set_visible(True) + + def _populate_reqs_page(self, result: HttpFileParseResult, path: str): + self._file_row.set_subtitle(path) + + while child := self._reqs_list.get_first_child(): + self._reqs_list.remove(child) + self._req_checkboxes.clear() + + for req in result.requests: + row = Adw.ActionRow() + row.set_title(req.name) + row.set_subtitle(f"{req.method} {req.url}") + + badge = Gtk.Label(label=req.method) + badge.add_css_class("caption") + badge.add_css_class("monospace") + badge.add_css_class(_METHOD_CLASS.get(req.method, 'dim-label')) + badge.set_valign(Gtk.Align.CENTER) + row.add_suffix(badge) + + cb = Gtk.CheckButton() + cb.set_active(True) + row.add_prefix(cb) + row.set_activatable_widget(cb) + + self._reqs_list.append(row) + self._req_checkboxes.append((req, cb)) + + # Pre-fill project name from filename (stem of the path) + import os + stem = os.path.splitext(os.path.basename(path))[0] + self._new_name_entry.set_text(stem) + self._sel_all_btn.set_label("Deselect All") diff --git a/src/http_file_importer.py b/src/http_file_importer.py new file mode 100644 index 0000000..16bec76 --- /dev/null +++ b/src/http_file_importer.py @@ -0,0 +1,188 @@ +# http_file_importer.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 re +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +HTTP_METHODS = frozenset({ + 'GET', 'POST', 'PUT', 'DELETE', 'PATCH', + 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', +}) + + +@dataclass +class HttpFileRequest: + name: str + method: str + url: str + headers: Dict[str, str] + body: str + + +@dataclass +class HttpFileParseResult: + requests: List[HttpFileRequest] = field(default_factory=list) + variables: Dict[str, str] = field(default_factory=dict) + error: Optional[str] = None + + +def parse_http_file(content: str) -> HttpFileParseResult: + """Parse an IntelliJ .http / .rest file into a list of requests.""" + result = HttpFileParseResult() + + if not content or not content.strip(): + result.error = "File is empty" + return result + + # Split file into named blocks at each "###" separator line + blocks: list[tuple[str, list[str]]] = [] + current_name = "" + current_lines: list[str] = [] + + for line in content.splitlines(): + if re.match(r'^###', line): + blocks.append((current_name, current_lines)) + current_name = line[3:].strip() + current_lines = [] + else: + current_lines.append(line) + blocks.append((current_name, current_lines)) + + for block_name, block_lines in blocks: + _collect_variables(block_lines, result.variables) + req = _parse_block(block_name, block_lines) + if req is not None: + result.requests.append(req) + + if not result.requests: + result.error = "No HTTP requests found in this file" + + return result + + +def build_http_request(req: HttpFileRequest): + """Convert an HttpFileRequest into an HttpRequest model object.""" + from .models import HttpRequest + + body = req.body + syntax = 'RAW' + if body.lstrip().startswith('{'): + syntax = 'JSON' + elif body.lstrip().startswith('<') and not body.lstrip().startswith('< '): + syntax = 'XML' + + return HttpRequest( + method=req.method, + url=req.url, + headers=req.headers, + body=body, + syntax=syntax, + ) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _collect_variables(lines: list[str], variables: dict) -> None: + """Extract @name = value file-level variable definitions.""" + for line in lines: + m = re.match(r'^@(\w+)\s*=\s*(.+)', line.strip()) + if m: + variables[m.group(1)] = m.group(2).strip() + + +def _parse_block(name: str, lines: list[str]) -> Optional[HttpFileRequest]: + """Parse one request block. Returns None if no valid request line found.""" + # Find the request line (METHOD URL), skipping comments and @variables + req_idx: Optional[int] = None + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith('#') or stripped.startswith('//'): + continue + if stripped.startswith('@'): + continue + parts = stripped.split(None, 1) + if parts and parts[0].upper() in HTTP_METHODS: + req_idx = i + break + # Any other non-blank, non-comment line before METHOD — not a request block + break + + if req_idx is None: + return None + + method, url = _split_request_line(lines[req_idx].strip()) + + # Parse headers: lines after req_idx until blank line or response handler + headers: Dict[str, str] = {} + body_start = len(lines) + i = req_idx + 1 + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + if not stripped: + body_start = i + 1 + break + if stripped.startswith('#') or stripped.startswith('//'): + i += 1 + continue + if stripped.startswith('>'): + break + if ':' in stripped: + key, _, value = stripped.partition(':') + headers[key.strip()] = value.strip() + i += 1 + + # Parse body: everything until a response handler line or end + body_lines: list[str] = [] + for line in lines[body_start:]: + if line.strip().startswith('>'): + break + # Skip file-include lines (< ./file.json) — not supported + if re.match(r'^<\s+\S', line): + continue + body_lines.append(line) + + # Strip trailing blank lines from body + while body_lines and not body_lines[-1].strip(): + body_lines.pop() + + body = '\n'.join(body_lines) + + if not name: + name = f"{method} {_shorten(url)}" + + return HttpFileRequest(name=name, method=method, url=url, headers=headers, body=body) + + +def _split_request_line(line: str) -> tuple[str, str]: + parts = line.split(None, 1) + method = parts[0].upper() + url = parts[1].strip() if len(parts) > 1 else "" + return method, url + + +def _shorten(url: str, limit: int = 60) -> str: + return url if len(url) <= limit else url[:limit - 1] + '…' diff --git a/src/main-window.ui b/src/main-window.ui index e76e491..6007934 100644 --- a/src/main-window.ui +++ b/src/main-window.ui @@ -13,6 +13,10 @@ Import from WSDL win.import-wsdl + + Import from .http File + win.import-http-file + diff --git a/src/meson.build b/src/meson.build index 65f05e9..2c002ef 100644 --- a/src/meson.build +++ b/src/meson.build @@ -48,6 +48,8 @@ roster_sources = [ 'wsdl_import_dialog.py', 'openapi_importer.py', 'openapi_import_dialog.py', + 'http_file_importer.py', + 'http_file_import_dialog.py', ] install_data(roster_sources, install_dir: moduledir) diff --git a/src/window.py b/src/window.py index c03e415..bc0e7a2 100644 --- a/src/window.py +++ b/src/window.py @@ -38,6 +38,7 @@ from .icon_picker_dialog import IconPickerDialog from .environments_dialog import EnvironmentsDialog from .wsdl_import_dialog import WsdlImportDialog from .openapi_import_dialog import OpenApiImportDialog +from .http_file_import_dialog import HttpFileImportDialog from .request_tab_widget import RequestTabWidget from .widgets.history_item import HistoryItem from .widgets.project_item import ProjectItem @@ -503,6 +504,10 @@ class RosterWindow(Adw.ApplicationWindow): action.connect("activate", lambda a, p: self.on_import_wsdl_clicked(None)) self.add_action(action) + action = Gio.SimpleAction.new("import-http-file", None) + action.connect("activate", lambda a, p: self.on_import_http_file_clicked(None)) + self.add_action(action) + def _on_send_clicked(self, widget): """Handle Send button click from a tab widget.""" # Clear previous preprocessing results @@ -1422,6 +1427,22 @@ class RosterWindow(Adw.ApplicationWindow): widget.original_request = None widget.modified = True + def on_import_http_file_clicked(self, button): + """Open the .http file import dialog.""" + projects = self.project_manager.load_projects() + dialog = HttpFileImportDialog(self.project_manager, projects) + dialog.connect('import-completed', self._on_http_file_import_completed) + dialog.present(self) + + def _on_http_file_import_completed(self, dialog, project_id: str): + """Refresh sidebar after a successful .http file import.""" + self._load_projects() + projects = self.project_manager.load_projects() + for p in projects: + if p.id == project_id: + self._show_toast(f"Imported {len(p.requests)} request(s) into '{p.name}'") + break + def on_import_wsdl_clicked(self, button): """Open the WSDL import dialog.""" projects = self.project_manager.load_projects()