Compare commits
No commits in common. "master" and "feature/secrets-portal-migration" have entirely different histories.
master
...
feature/se
1
.gitignore
vendored
@ -25,4 +25,3 @@ __pycache__/
|
||||
# Flatpak
|
||||
.flatpak/
|
||||
repo/
|
||||
*.local.json
|
||||
|
||||
@ -105,11 +105,6 @@ po/
|
||||
|
||||
5. **Module Installation**: Python modules are installed to `{datadir}/roster/roster/` and the launcher script is configured with the correct Python interpreter path.
|
||||
|
||||
## Communication
|
||||
|
||||
- Communicate with the user in **English**
|
||||
- Git commit messages: **concise, single-line, in English**
|
||||
|
||||
## Adding New Python Modules
|
||||
|
||||
When adding new `.py` files to `src/`:
|
||||
|
||||
@ -17,8 +17,4 @@ These are dynamically linked system libraries available on https://gitlab.gnome.
|
||||
|
||||
Icons are loaded at runtime from the system and are not distributed with this application.
|
||||
|
||||
## GNOME Icon Development Kit (CC0-1.0)
|
||||
- `papyrus-vertical-symbolic.svg` — from https://gitlab.gnome.org/Teams/Design/icon-development-kit
|
||||
- License: Creative Commons Zero v1.0 Universal (public domain dedication)
|
||||
|
||||
All licenses are compatible with GPL-3.0-or-later.
|
||||
|
||||
17
README.md
@ -12,7 +12,6 @@ A modern HTTP client for GNOME, built with GTK 4 and libadwaita.
|
||||
- Environment variables with secure credential storage
|
||||
- JavaScript preprocessing and postprocessing scripts
|
||||
- Export requests (cURL and more)
|
||||
- **Git-based collaboration** — projects are stored as plain JSON files, one per request, making it easy to share and version-control your API collections with a team
|
||||
- GNOME-native UI
|
||||
|
||||
## Screenshots
|
||||
@ -69,22 +68,6 @@ Store API keys, passwords, and tokens securely in GNOME Keyring with one-click e
|
||||
|
||||
[Learn more about Sensitive Variables](https://git.bugsy.cz/beval/roster/wiki/Sensitive-Variables)
|
||||
|
||||
### Git-Based Collaboration
|
||||
|
||||
Every project and request is stored as a plain JSON file under `~/.local/share/cz.bugsy.roster/projects/`. This makes it straightforward to share your API collections with a team:
|
||||
|
||||
```bash
|
||||
# Put your Roster data directory under version control
|
||||
cd ~/.local/share/cz.bugsy.roster/projects
|
||||
git init
|
||||
git remote add origin git@example.com:your-team/api-collections.git
|
||||
git add .
|
||||
git commit -m "Add API collections"
|
||||
git push
|
||||
```
|
||||
|
||||
Team members can then clone the repository into their own data directory and get the full set of projects and requests instantly. Changes to requests show up as clean, reviewable diffs — each request is a separate file, so there are no merge conflicts from unrelated edits.
|
||||
|
||||
### JavaScript Automation
|
||||
|
||||
Use preprocessing and postprocessing scripts to:
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://git.bugsy.cz/beval/roster.git",
|
||||
"tag": "v0.8.3"
|
||||
"tag": "v0.8.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"id" : "cz.bugsy.roster",
|
||||
"runtime" : "org.gnome.Platform",
|
||||
"runtime-version" : "50",
|
||||
"runtime-version" : "49",
|
||||
"sdk" : "org.gnome.Sdk",
|
||||
"command" : "roster",
|
||||
"finish-args" : [
|
||||
@ -28,20 +28,6 @@
|
||||
]
|
||||
},
|
||||
"modules" : [
|
||||
{
|
||||
"name" : "python3-pyyaml",
|
||||
"buildsystem" : "simple",
|
||||
"build-commands" : [
|
||||
"pip3 install --no-deps --prefix=/app pyyaml-6.0.3.tar.gz"
|
||||
],
|
||||
"sources" : [
|
||||
{
|
||||
"type" : "file",
|
||||
"url" : "https://files.pythonhosted.org/packages/source/P/PyYAML/pyyaml-6.0.3.tar.gz",
|
||||
"sha256" : "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name" : "roster",
|
||||
"builddir" : true,
|
||||
@ -49,7 +35,7 @@
|
||||
"sources" : [
|
||||
{
|
||||
"type" : "git",
|
||||
"url" : "file:///home/pavelb/Projects/GnomeBuilderProjects/Roster"
|
||||
"url" : "file:///home/pavelb/GnomeBuilderProjects/Roster"
|
||||
}
|
||||
],
|
||||
"config-opts" : [
|
||||
|
||||
@ -11,15 +11,5 @@
|
||||
<summary>Request timeout</summary>
|
||||
<description>Timeout for HTTP requests in seconds</description>
|
||||
</key>
|
||||
<key name="backup-folder" type="s">
|
||||
<default>''</default>
|
||||
<summary>Backup folder path</summary>
|
||||
<description>Path to the folder where project data is exported as a backup</description>
|
||||
</key>
|
||||
<key name="backup-auto-export" type="b">
|
||||
<default>false</default>
|
||||
<summary>Auto-export on save</summary>
|
||||
<description>Automatically export project data to the backup folder after each save</description>
|
||||
</key>
|
||||
</schema>
|
||||
</schemalist>
|
||||
|
||||
@ -25,11 +25,6 @@
|
||||
<!-- Use the OARS website (https://hughsie.github.io/oars/generate.html) to generate these and make sure to use oars-1.1 -->
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#8b9dbc</color>
|
||||
<color type="primary" scheme_preference="dark">#173c7a</color>
|
||||
</branding>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/main.png</image>
|
||||
@ -43,75 +38,9 @@
|
||||
<image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/history.png</image>
|
||||
<caption>Request history panel</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/load_openapi.png</image>
|
||||
<caption>OpenAPI/Swagger import dialog with loaded operations</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release version="0.11.0" date="2026-05-27">
|
||||
<description translate="no">
|
||||
<ul>
|
||||
<li>Each request is now stored as a separate JSON file, making it easy to share and version-control API collections with Git</li>
|
||||
<li>Existing data is migrated automatically on first launch; the original file is kept as a backup</li>
|
||||
<li>New Backup section in Preferences: export project data to a folder, manually or automatically after every save</li>
|
||||
<li>Method prefix (GET, POST, …) is no longer shown in request names — the colored chip already carries that information</li>
|
||||
<li>Imported request names from OpenAPI and .http files no longer include the method prefix</li>
|
||||
<li>Request names may now contain / and :</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.10.0" date="2026-05-21">
|
||||
<description translate="no">
|
||||
<p>Version 0.10.0 release</p>
|
||||
<ul>
|
||||
<li>Responsive layout: sidebar collapses to an overlay panel on narrow windows</li>
|
||||
<li>Narrow mode: single-panel view with Request/Response toggle buttons when sidebar is hidden</li>
|
||||
<li>Adaptive request panel: Headers/Body/Scripts tabs stack vertically when the panel is narrow, with a hard minimum width</li>
|
||||
<li>Import requests from .http files</li>
|
||||
<li>Sidebar hamburger menu consolidating all actions (Add Project, Import, Preferences, Keyboard Shortcuts, About)</li>
|
||||
<li>Method chips (GET/POST/PUT/DELETE color badges) in sidebar request list</li>
|
||||
<li>Fix header bar clipping and window controls placement on narrow windows</li>
|
||||
<li>Set minimum window width to prevent layout overflow</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.9.3" date="2026-05-19">
|
||||
<description translate="no">
|
||||
<p>Version 0.9.3 release</p>
|
||||
<ul>
|
||||
<li>Fix WSDL import: generated SOAP body now correctly includes wrapper elements and proper namespace prefixes for each type (fixes WCF/DataContract services)</li>
|
||||
<li>Fix window controls disappearing when moved to left side via GNOME Tweaks</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.9.2" date="2026-05-13">
|
||||
<description translate="no">
|
||||
<p>Version 0.9.2 release</p>
|
||||
<ul>
|
||||
<li>Fix console.error() not available in scripts</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.9.1" date="2026-05-12">
|
||||
<description translate="no">
|
||||
<p>Version 0.9.1 release</p>
|
||||
<ul>
|
||||
<li>Add OpenAPI/Swagger import (2.0 + 3.x) with JSON body templates</li>
|
||||
<li>Merge import buttons into single popover menu</li>
|
||||
<li>Update to GNOME Platform 50</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.9.0" date="2026-05-12">
|
||||
<description translate="no">
|
||||
<p>Version 0.9.0 release</p>
|
||||
<ul>
|
||||
<li>Add WSDL import for SOAP services</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.8.1" date="2026-01-15">
|
||||
<description translate="no">
|
||||
<p>Version 0.8.1 release</p>
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px">
|
||||
<g fill="none" stroke="#222222" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Rounded rectangle frame -->
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2.25" ry="2.25" stroke-width="1.5"/>
|
||||
<!-- Left curly brace { -->
|
||||
<path stroke-width="1.6"
|
||||
d="M 7 4.5 C 6.2 4.5 5.75 4.9 5.75 5.6 L 5.75 7 C 5.75 7.7 5.3 8 4.75 8
|
||||
C 5.3 8 5.75 8.3 5.75 9 L 5.75 10.4 C 5.75 11.1 6.2 11.5 7 11.5"/>
|
||||
<!-- Right curly brace } -->
|
||||
<path stroke-width="1.6"
|
||||
d="M 9 4.5 C 9.8 4.5 10.25 4.9 10.25 5.6 L 10.25 7 C 10.25 7.7 10.7 8 11.25 8
|
||||
C 10.7 8 10.25 8.3 10.25 9 L 10.25 10.4 C 10.25 11.1 9.8 11.5 9 11.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 808 B |
@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 7.007812 -0.0078125 c -0.519531 0 -1.035156 0.1367185 -1.5 0.4023435 c -0.925781 0.535157 -1.5 1.527344 -1.5 2.597657 v 7 h -3.9999995 v 3 c 0 1.070312 0.5742185 2.066406 1.4999995 2.601562 c 0.464844 0.265625 0.984376 0.398438 1.5 0.398438 v 0.007812 l 7 -0.003906 v -0.003906 h 0.011719 c 0.796875 0 1.5625 -0.316407 2.121094 -0.875 c 0.5625 -0.5625 0.878906 -1.328126 0.878906 -2.125 c 0.003907 -0.058594 -0.003906 -0.117188 -0.011719 -0.175782 v -6.824218 h 3 v -3 c 0 -0.796876 -0.316406 -1.558594 -0.878906 -2.121094 s -1.324218 -0.8789065 -2.121094 -0.8789065 z m 0 2.0000005 c 0.171876 0 0.34375 0.046874 0.5 0.136718 c 0.3125 0.175782 0.5 0.503906 0.5 0.863282 v 3 h 3 v 7 h 0.011719 c 0 0.265624 -0.101562 0.519531 -0.292969 0.707031 c -0.1875 0.1875 -0.441406 0.292969 -0.707031 0.292969 c -0.023437 0 -0.046875 0 -0.070312 0.003906 l -4.121094 0.003906 c 0.113281 -0.320312 0.179687 -0.660156 0.179687 -1.007812 v -10 c 0 -0.359376 0.1875 -0.6875 0.5 -0.863282 c 0.15625 -0.089844 0.328126 -0.136718 0.5 -0.136718 z m 2.820313 0 h 3.179687 c 0.265626 0 0.519532 0.105468 0.707032 0.292968 s 0.292968 0.441406 0.292968 0.707032 v 1 h -4 v -1 c 0 -0.347657 -0.066406 -0.683594 -0.179687 -1 z m -7.820313 10 h 2 v 1 c 0 0.359374 -0.1875 0.6875 -0.5 0.867187 s -0.6875 0.179687 -1 0 s -0.5 -0.507813 -0.5 -0.867187 z m 0 0" fill="#222222"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px">
|
||||
<g fill="none" stroke="#222222" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Rounded rectangle frame -->
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2.25" ry="2.25" stroke-width="1.5"/>
|
||||
<!-- Bold W letter -->
|
||||
<path stroke-width="2.1" d="M 3.25 5 L 5.25 11 L 8 8 L 10.75 11 L 12.75 5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 458 B |
@ -1,5 +1,5 @@
|
||||
project('roster',
|
||||
version: '0.11.0',
|
||||
version: '0.8.2',
|
||||
meson_version: '>= 1.0.0',
|
||||
default_options: [ 'warning_level=2', 'werror=false', ],
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 136 KiB |
@ -5,7 +5,7 @@
|
||||
|
||||
<template class="EnvironmentsDialog" parent="AdwDialog">
|
||||
<property name="title">Manage Environments</property>
|
||||
<property name="content-width">1200</property>
|
||||
<property name="content-width">1100</property>
|
||||
<property name="content-height">600</property>
|
||||
<property name="follows-content-size">false</property>
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
</child>
|
||||
|
||||
<property name="content">
|
||||
<object class="GtkScrolledWindow" id="scrolled_window">
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import re
|
||||
from gi.repository import Adw, Gtk, GObject
|
||||
from .widgets.variable_row import VariableRow
|
||||
from .widgets.environment_row import EnvironmentRow
|
||||
@ -34,7 +33,6 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
|
||||
table_container = Gtk.Template.Child()
|
||||
add_environment_button = Gtk.Template.Child()
|
||||
scrolled_window = Gtk.Template.Child()
|
||||
|
||||
__gsignals__ = {
|
||||
'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
@ -49,10 +47,6 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
self.header_row = None
|
||||
self.data_rows = []
|
||||
self._populate_table()
|
||||
self.connect('map', self._scroll_to_start)
|
||||
|
||||
def _scroll_to_start(self, widget):
|
||||
self.scrolled_window.get_hadjustment().set_value(0)
|
||||
|
||||
def _populate_table(self):
|
||||
"""Populate the transposed environment-variable table."""
|
||||
@ -71,8 +65,6 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
self._on_environment_edit)
|
||||
self.header_row.connect('environment-delete-requested',
|
||||
self._on_environment_delete)
|
||||
self.header_row.connect('environment-copy-requested',
|
||||
self._on_environment_copy)
|
||||
self.table_container.append(self.header_row)
|
||||
|
||||
# Add separator
|
||||
@ -257,30 +249,6 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
dialog.connect("response", on_response)
|
||||
dialog.present(self)
|
||||
|
||||
def _on_environment_copy(self, widget, environment):
|
||||
"""Copy an environment with a unique name."""
|
||||
existing_names = {env.name for env in self.project.environments}
|
||||
new_name = self._unique_env_name(environment.name, existing_names)
|
||||
|
||||
def on_copy_complete(new_env):
|
||||
self._reload_project()
|
||||
self._populate_table()
|
||||
self.emit('environments-updated')
|
||||
|
||||
self.project_manager.copy_environment(self.project.id, environment.id, new_name, on_copy_complete)
|
||||
|
||||
@staticmethod
|
||||
def _unique_env_name(base_name, existing_names):
|
||||
"""Generate a unique environment name by appending (1), (2), etc."""
|
||||
match = re.match(r'^(.*?) \((\d+)\)$', base_name)
|
||||
base = match.group(1) if match else base_name
|
||||
counter = 1
|
||||
while True:
|
||||
candidate = f"{base} ({counter})"
|
||||
if candidate not in existing_names:
|
||||
return candidate
|
||||
counter += 1
|
||||
|
||||
def _on_environment_delete(self, widget, environment):
|
||||
"""Delete environment with confirmation."""
|
||||
# Prevent deletion of last environment
|
||||
|
||||
@ -1,360 +0,0 @@
|
||||
# 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 <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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")
|
||||
@ -1,188 +0,0 @@
|
||||
# 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 <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# 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 = _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] + '…'
|
||||
@ -3,82 +3,46 @@
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<requires lib="Adw" version="1.0"/>
|
||||
|
||||
<menu id="sidebar_menu">
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label">Add Project</attribute>
|
||||
<attribute name="action">win.add-project</attribute>
|
||||
</item>
|
||||
</section>
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label">Import from OpenAPI / Swagger</attribute>
|
||||
<attribute name="action">win.import-openapi</attribute>
|
||||
</item>
|
||||
<item>
|
||||
<attribute name="label">Import from WSDL</attribute>
|
||||
<attribute name="action">win.import-wsdl</attribute>
|
||||
</item>
|
||||
<item>
|
||||
<attribute name="label">Import from .http File</attribute>
|
||||
<attribute name="action">win.import-http-file</attribute>
|
||||
</item>
|
||||
</section>
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">_Preferences</attribute>
|
||||
<attribute name="action">app.preferences</attribute>
|
||||
</item>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">_Keyboard Shortcuts</attribute>
|
||||
<attribute name="action">app.shortcuts</attribute>
|
||||
</item>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">_About Roster</attribute>
|
||||
<attribute name="action">app.about</attribute>
|
||||
</item>
|
||||
</section>
|
||||
</menu>
|
||||
|
||||
<template class="RosterWindow" parent="AdwApplicationWindow">
|
||||
<property name="default-width">1200</property>
|
||||
<property name="default-height">800</property>
|
||||
<property name="width-request">470</property>
|
||||
<property name="content">
|
||||
<object class="AdwToastOverlay" id="toast_overlay">
|
||||
<property name="child">
|
||||
<object class="AdwOverlaySplitView" id="split_view">
|
||||
<property name="min-sidebar-width">200</property>
|
||||
<property name="max-sidebar-width">320</property>
|
||||
<object class="GtkPaned" id="main_pane">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="position">180</property>
|
||||
<property name="shrink-start-child">False</property>
|
||||
<property name="resize-start-child">True</property>
|
||||
<property name="shrink-end-child">False</property>
|
||||
<property name="resize-end-child">True</property>
|
||||
<property name="wide-handle">False</property>
|
||||
|
||||
<!-- LEFT: Sidebar Panel with AdwToolbarView -->
|
||||
<property name="sidebar">
|
||||
<property name="start-child">
|
||||
<object class="AdwToolbarView">
|
||||
<property name="width-request">200</property>
|
||||
|
||||
<!-- Sidebar Header Bar -->
|
||||
<child type="top">
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-title">False</property>
|
||||
<property name="show-end-title-buttons">False</property>
|
||||
<property name="show-start-title-buttons">False</property>
|
||||
<property name="title-widget">
|
||||
<child type="start">
|
||||
<object class="GtkLabel">
|
||||
<property name="label">Projects</property>
|
||||
<property name="margin-start">6</property>
|
||||
<style>
|
||||
<class name="title"/>
|
||||
</style>
|
||||
</object>
|
||||
</property>
|
||||
<child type="start">
|
||||
<object class="GtkWindowControls">
|
||||
<property name="side">start</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkMenuButton" id="sidebar_hamburger_button">
|
||||
<property name="primary">True</property>
|
||||
<property name="icon-name">open-menu-symbolic</property>
|
||||
<property name="tooltip-text">Menu</property>
|
||||
<property name="menu-model">sidebar_menu</property>
|
||||
<object class="GtkButton" id="add_project_button">
|
||||
<property name="icon-name">list-add-symbolic</property>
|
||||
<property name="tooltip-text">Add Project</property>
|
||||
<signal name="clicked" handler="on_add_project_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
@ -111,7 +75,7 @@
|
||||
</property>
|
||||
|
||||
<!-- RIGHT: Main Content Panel with AdwToolbarView -->
|
||||
<property name="content">
|
||||
<property name="end-child">
|
||||
<object class="AdwToolbarView">
|
||||
|
||||
<!-- Main Header Bar -->
|
||||
@ -121,20 +85,6 @@
|
||||
<property name="show-start-title-buttons">False</property>
|
||||
<property name="show-end-title-buttons">False</property>
|
||||
|
||||
<!-- Sidebar toggle (only visible when collapsed) -->
|
||||
<child type="start">
|
||||
<object class="GtkToggleButton" id="sidebar_toggle_button">
|
||||
<property name="icon-name">sidebar-show-symbolic</property>
|
||||
<property name="tooltip-text">Show Sidebar</property>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
<binding name="visible">
|
||||
<lookup name="collapsed">split_view</lookup>
|
||||
</binding>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Left side buttons -->
|
||||
<child type="start">
|
||||
<object class="GtkButton" id="save_request_button">
|
||||
@ -146,6 +96,7 @@
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child type="start">
|
||||
<object class="GtkButton" id="export_request_button">
|
||||
<property name="icon-name">export-symbolic</property>
|
||||
@ -157,15 +108,13 @@
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Window controls: separate end child so it's always rightmost -->
|
||||
<child type="end">
|
||||
<object class="GtkWindowControls">
|
||||
<property name="side">end</property>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Right side buttons -->
|
||||
<child type="end">
|
||||
<object class="GtkBox">
|
||||
<property name="spacing">6</property>
|
||||
|
||||
<!-- New Request Button -->
|
||||
<child>
|
||||
<object class="GtkButton" id="new_request_button">
|
||||
<property name="icon-name">list-add-symbolic</property>
|
||||
<property name="tooltip-text">New Request (Ctrl+T)</property>
|
||||
@ -174,6 +123,25 @@
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Main Menu -->
|
||||
<child>
|
||||
<object class="GtkMenuButton">
|
||||
<property name="primary">True</property>
|
||||
<property name="icon-name">open-menu-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Main Menu</property>
|
||||
<property name="menu-model">primary_menu</property>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Window Controls -->
|
||||
<child>
|
||||
<object class="GtkWindowControls">
|
||||
<property name="side">end</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
@ -258,14 +226,22 @@
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
|
||||
<!-- Collapse sidebar when window is narrow -->
|
||||
<child>
|
||||
<object class="AdwBreakpoint">
|
||||
<condition>max-width: 700sp</condition>
|
||||
<setter object="split_view" property="collapsed">True</setter>
|
||||
</object>
|
||||
</child>
|
||||
</template>
|
||||
|
||||
<menu id="primary_menu">
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">_Preferences</attribute>
|
||||
<attribute name="action">app.preferences</attribute>
|
||||
</item>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">_Keyboard Shortcuts</attribute>
|
||||
<attribute name="action">app.shortcuts</attribute>
|
||||
</item>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">_About Roster</attribute>
|
||||
<attribute name="action">app.about</attribute>
|
||||
</item>
|
||||
</section>
|
||||
</menu>
|
||||
</interface>
|
||||
|
||||
@ -82,7 +82,7 @@ class RosterApplication(Adw.Application):
|
||||
def on_preferences_action(self, widget, _):
|
||||
"""Callback for the app.preferences action."""
|
||||
window = self.props.active_window
|
||||
preferences = PreferencesDialog(history_manager=window.history_manager, project_manager=window.project_manager)
|
||||
preferences = PreferencesDialog(history_manager=window.history_manager)
|
||||
preferences.set_transient_for(window)
|
||||
preferences.connect('history-cleared', lambda d: window._load_history())
|
||||
preferences.present()
|
||||
|
||||
@ -34,7 +34,6 @@ roster_sources = [
|
||||
'variable_substitution.py',
|
||||
'http_client.py',
|
||||
'history_manager.py',
|
||||
'migration.py',
|
||||
'project_manager.py',
|
||||
'secret_manager.py',
|
||||
'tab_manager.py',
|
||||
@ -45,12 +44,6 @@ roster_sources = [
|
||||
'preferences_dialog.py',
|
||||
'request_tab_widget.py',
|
||||
'script_executor.py',
|
||||
'wsdl_importer.py',
|
||||
'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)
|
||||
|
||||
115
src/migration.py
@ -1,115 +0,0 @@
|
||||
# migration.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 <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sanitize_name(name: str) -> str:
|
||||
s = name.lower().strip()
|
||||
s = re.sub(r'[^\w\s-]', '', s)
|
||||
s = re.sub(r'[\s_]+', '-', s)
|
||||
s = re.sub(r'-+', '-', s)
|
||||
return s.strip('-') or 'unnamed'
|
||||
|
||||
|
||||
def _unique_name(used: set, base: str) -> str:
|
||||
if base not in used:
|
||||
return base
|
||||
i = 2
|
||||
while f"{base}-{i}" in used:
|
||||
i += 1
|
||||
return f"{base}-{i}"
|
||||
|
||||
|
||||
def needs_migration(projects_dir: Path, legacy_file: Path) -> bool:
|
||||
return legacy_file.exists() and not projects_dir.exists()
|
||||
|
||||
|
||||
def run_migration(legacy_file: Path, projects_dir: Path) -> bool:
|
||||
"""
|
||||
Read requests.json, write new per-file directory structure.
|
||||
Atomic: writes to a tmp dir first, then renames.
|
||||
On success renames requests.json → requests.json.bak.
|
||||
On failure leaves no partial state and returns False.
|
||||
"""
|
||||
tmp_dir = projects_dir.parent / 'projects.tmp'
|
||||
|
||||
try:
|
||||
with open(legacy_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
projects = data.get('projects', [])
|
||||
|
||||
if tmp_dir.exists():
|
||||
shutil.rmtree(tmp_dir)
|
||||
tmp_dir.mkdir(parents=True)
|
||||
|
||||
used_project_dirs: set = set()
|
||||
|
||||
for project in projects:
|
||||
proj_base = _sanitize_name(project.get('name', ''))
|
||||
proj_dir_name = _unique_name(used_project_dirs, proj_base)
|
||||
used_project_dirs.add(proj_dir_name)
|
||||
|
||||
proj_dir = tmp_dir / proj_dir_name
|
||||
proj_dir.mkdir()
|
||||
|
||||
requests = project.get('requests', [])
|
||||
request_order = []
|
||||
used_req_basenames: set = set()
|
||||
|
||||
for req in requests:
|
||||
req_base = _sanitize_name(req.get('name', ''))
|
||||
req_basename = _unique_name(used_req_basenames, req_base)
|
||||
used_req_basenames.add(req_basename)
|
||||
req_filename = req_basename + '.json'
|
||||
request_order.append(req_filename)
|
||||
|
||||
with open(proj_dir / req_filename, 'w') as f:
|
||||
json.dump(req, f, indent=2)
|
||||
|
||||
project_meta = {k: v for k, v in project.items() if k != 'requests'}
|
||||
project_meta['request_order'] = request_order
|
||||
|
||||
with open(proj_dir / '_project.json', 'w') as f:
|
||||
json.dump(project_meta, f, indent=2)
|
||||
|
||||
# Atomic rename
|
||||
tmp_dir.rename(projects_dir)
|
||||
|
||||
bak_file = legacy_file.parent / (legacy_file.name + '.bak')
|
||||
legacy_file.rename(bak_file)
|
||||
|
||||
logger.info("Migration successful: %d projects migrated, backup at %s", len(projects), bak_file)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Migration failed: %s", e)
|
||||
try:
|
||||
if tmp_dir.exists():
|
||||
shutil.rmtree(tmp_dir)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
@ -1,381 +0,0 @@
|
||||
# openapi_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 <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import gi
|
||||
gi.require_version('Soup', '3.0')
|
||||
from gi.repository import Adw, Gtk, GObject, GLib, Soup
|
||||
from typing import List
|
||||
|
||||
from .openapi_importer import parse_openapi, build_http_request, OpenApiParseResult, OpenApiOperation
|
||||
|
||||
# Method → Adwaita accent CSS class
|
||||
_METHOD_CLASS = {
|
||||
'GET': 'accent',
|
||||
'POST': 'success',
|
||||
'PUT': 'warning',
|
||||
'PATCH': 'warning',
|
||||
'DELETE': 'error',
|
||||
}
|
||||
|
||||
|
||||
class OpenApiImportDialog(Adw.Dialog):
|
||||
"""Two-step dialog: fetch OpenAPI URL → select operations → import."""
|
||||
|
||||
__gtype_name__ = 'OpenApiImportDialog'
|
||||
|
||||
__gsignals__ = {
|
||||
'import-completed': (GObject.SIGNAL_RUN_FIRST, None, (str,)),
|
||||
}
|
||||
|
||||
def __init__(self, project_manager, existing_projects):
|
||||
super().__init__()
|
||||
self.set_title("Import from OpenAPI")
|
||||
self.set_content_width(580)
|
||||
self.set_content_height(560)
|
||||
|
||||
self._pm = project_manager
|
||||
self._existing_projects = list(existing_projects)
|
||||
self._parse_result: OpenApiParseResult | None = None
|
||||
self._op_checkboxes: List[tuple[OpenApiOperation, Gtk.CheckButton]] = []
|
||||
self._soup = Soup.Session.new()
|
||||
|
||||
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_url_page(), "url")
|
||||
self._stack.add_named(self._make_ops_page(), "ops")
|
||||
tv.set_content(self._stack)
|
||||
|
||||
ab = Gtk.ActionBar()
|
||||
|
||||
self._back_btn = Gtk.Button(label="Back")
|
||||
self._back_btn.connect("clicked", lambda _: self._show_url_page())
|
||||
ab.pack_start(self._back_btn)
|
||||
|
||||
self._fetch_btn = Gtk.Button(label="Fetch spec")
|
||||
self._fetch_btn.add_css_class("suggested-action")
|
||||
self._fetch_btn.connect("clicked", self._on_fetch)
|
||||
ab.pack_end(self._fetch_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_url_page()
|
||||
|
||||
def _make_url_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("openapi-import-symbolic")
|
||||
icon.set_pixel_size(64)
|
||||
icon.add_css_class("dim-label")
|
||||
box.append(icon)
|
||||
|
||||
title = Gtk.Label(label="Import from OpenAPI")
|
||||
title.add_css_class("title-2")
|
||||
box.append(title)
|
||||
|
||||
subtitle = Gtk.Label(
|
||||
label="Enter the URL of an OpenAPI 2.0 (Swagger) or 3.x specification (JSON or YAML)"
|
||||
)
|
||||
subtitle.add_css_class("dim-label")
|
||||
subtitle.set_wrap(True)
|
||||
subtitle.set_justify(Gtk.Justification.CENTER)
|
||||
box.append(subtitle)
|
||||
|
||||
grp = Adw.PreferencesGroup()
|
||||
self._url_row = Adw.EntryRow()
|
||||
self._url_row.set_title("Spec URL")
|
||||
self._url_row.set_input_purpose(Gtk.InputPurpose.URL)
|
||||
self._url_row.connect("entry-activated", lambda _: self._on_fetch(None))
|
||||
grp.add(self._url_row)
|
||||
box.append(grp)
|
||||
|
||||
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)
|
||||
|
||||
self._spinner = Gtk.Spinner()
|
||||
self._spinner.set_size_request(32, 32)
|
||||
self._spinner.set_halign(Gtk.Align.CENTER)
|
||||
self._spinner.set_visible(False)
|
||||
box.append(self._spinner)
|
||||
|
||||
return box
|
||||
|
||||
def _make_ops_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)
|
||||
|
||||
# API info
|
||||
api_grp = Adw.PreferencesGroup()
|
||||
api_grp.set_title("API")
|
||||
self._api_url_row = Adw.ActionRow()
|
||||
self._api_url_row.set_title("Base URL")
|
||||
api_grp.add(self._api_url_row)
|
||||
outer.append(api_grp)
|
||||
|
||||
# Operations header
|
||||
ops_hdr = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
ops_hdr.set_margin_top(2)
|
||||
ops_lbl = Gtk.Label(label="Operations")
|
||||
ops_lbl.add_css_class("title-4")
|
||||
ops_lbl.set_hexpand(True)
|
||||
ops_lbl.set_xalign(0)
|
||||
ops_hdr.append(ops_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)
|
||||
ops_hdr.append(self._sel_all_btn)
|
||||
outer.append(ops_hdr)
|
||||
|
||||
# Scrollable operations list
|
||||
scroll = Gtk.ScrolledWindow()
|
||||
scroll.set_vexpand(True)
|
||||
scroll.set_min_content_height(80)
|
||||
self._ops_list = Gtk.ListBox()
|
||||
self._ops_list.add_css_class("boxed-list")
|
||||
self._ops_list.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||
scroll.set_child(self._ops_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_url_page(self):
|
||||
self._stack.set_visible_child_name("url")
|
||||
self._back_btn.set_visible(False)
|
||||
self._fetch_btn.set_visible(True)
|
||||
self._import_btn.set_visible(False)
|
||||
|
||||
def _show_ops_page(self):
|
||||
self._stack.set_visible_child_name("ops")
|
||||
self._back_btn.set_visible(True)
|
||||
self._fetch_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._op_checkboxes)
|
||||
new_state = not all_on
|
||||
for _, cb in self._op_checkboxes:
|
||||
cb.set_active(new_state)
|
||||
self._sel_all_btn.set_label("Deselect All" if new_state else "Select All")
|
||||
|
||||
def _on_fetch(self, _btn):
|
||||
url = self._url_row.get_text().strip()
|
||||
if not url:
|
||||
self._set_err("Please enter a spec URL")
|
||||
return
|
||||
if not url.startswith(('http://', 'https://')):
|
||||
self._set_err("URL must start with http:// or https://")
|
||||
return
|
||||
|
||||
self._err_label.set_visible(False)
|
||||
self._spinner.set_visible(True)
|
||||
self._spinner.start()
|
||||
self._fetch_btn.set_sensitive(False)
|
||||
|
||||
try:
|
||||
msg = Soup.Message.new("GET", url)
|
||||
except Exception as e:
|
||||
self._fetch_failed(f"Invalid URL: {e}")
|
||||
return
|
||||
|
||||
msg.get_request_headers().append(
|
||||
"Accept", "application/json, application/yaml, text/yaml, */*"
|
||||
)
|
||||
self._soup.send_and_read_async(msg, GLib.PRIORITY_DEFAULT, None,
|
||||
self._on_downloaded, msg)
|
||||
|
||||
def _on_downloaded(self, session, async_result, msg):
|
||||
self._spinner.stop()
|
||||
self._spinner.set_visible(False)
|
||||
self._fetch_btn.set_sensitive(True)
|
||||
|
||||
try:
|
||||
bytes_data = session.send_and_read_finish(async_result)
|
||||
except GLib.Error as e:
|
||||
self._set_err(f"Download failed: {e.message}")
|
||||
return
|
||||
|
||||
status = msg.get_status()
|
||||
if not (200 <= status < 300):
|
||||
self._set_err(f"Server returned HTTP {status}")
|
||||
return
|
||||
|
||||
content = bytes_data.get_data().decode('utf-8', errors='replace')
|
||||
parsed = parse_openapi(content)
|
||||
|
||||
if parsed.error:
|
||||
self._set_err(parsed.error)
|
||||
return
|
||||
|
||||
self._parse_result = parsed
|
||||
self._populate_ops_page(parsed)
|
||||
self._show_ops_page()
|
||||
|
||||
def _on_import(self, _btn):
|
||||
if not self._parse_result:
|
||||
return
|
||||
|
||||
selected = [op for op, cb in self._op_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 op in selected:
|
||||
http_req = build_http_request(op)
|
||||
self._pm.add_request(project_id, op.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 _fetch_failed(self, msg: str):
|
||||
self._spinner.stop()
|
||||
self._spinner.set_visible(False)
|
||||
self._fetch_btn.set_sensitive(True)
|
||||
self._set_err(msg)
|
||||
|
||||
def _populate_ops_page(self, result: OpenApiParseResult):
|
||||
self._api_url_row.set_subtitle(result.base_url or "Not specified")
|
||||
|
||||
while child := self._ops_list.get_first_child():
|
||||
self._ops_list.remove(child)
|
||||
self._op_checkboxes.clear()
|
||||
|
||||
for op in sorted(result.operations, key=lambda o: o.name.lower()):
|
||||
row = Adw.ActionRow()
|
||||
row.set_title(op.name)
|
||||
|
||||
# Subtitle: "METHOD /path" or description
|
||||
if op.name == f'{op.method} {op.path}':
|
||||
if op.description:
|
||||
row.set_subtitle(op.description)
|
||||
else:
|
||||
sub = f'{op.method} {op.path}'
|
||||
if op.description:
|
||||
sub += f' — {op.description}'
|
||||
row.set_subtitle(sub)
|
||||
|
||||
# Method badge suffix
|
||||
badge = Gtk.Label(label=op.method)
|
||||
badge.add_css_class("caption")
|
||||
badge.add_css_class("monospace")
|
||||
css_cls = _METHOD_CLASS.get(op.method, 'dim-label')
|
||||
badge.add_css_class(css_cls)
|
||||
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._ops_list.append(row)
|
||||
self._op_checkboxes.append((op, cb))
|
||||
|
||||
self._new_name_entry.set_text(result.api_title)
|
||||
self._sel_all_btn.set_label("Deselect All")
|
||||
@ -1,395 +0,0 @@
|
||||
# openapi_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 <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
try:
|
||||
import yaml as _yaml
|
||||
_YAML_AVAILABLE = True
|
||||
except ImportError:
|
||||
_YAML_AVAILABLE = False
|
||||
|
||||
# JSON Schema type → representative default value
|
||||
_TYPE_DEFAULTS: Dict[str, Any] = {
|
||||
'string': '',
|
||||
'integer': 0,
|
||||
'number': 0.0,
|
||||
'boolean': False,
|
||||
'null': None,
|
||||
'array': [],
|
||||
'object': {},
|
||||
}
|
||||
|
||||
_FORMAT_DEFAULTS: Dict[str, Any] = {
|
||||
'date-time': '2024-01-01T00:00:00Z',
|
||||
'date': '2024-01-01',
|
||||
'time': '00:00:00',
|
||||
'email': 'user@example.com',
|
||||
'uuid': '00000000-0000-0000-0000-000000000000',
|
||||
'uri': 'https://example.com',
|
||||
'hostname': 'example.com',
|
||||
'ipv4': '0.0.0.0',
|
||||
'int32': 0,
|
||||
'int64': 0,
|
||||
'float': 0.0,
|
||||
'double': 0.0,
|
||||
'byte': '',
|
||||
'binary': '',
|
||||
'password': '',
|
||||
}
|
||||
|
||||
HTTP_METHODS = frozenset({'get', 'post', 'put', 'delete', 'patch', 'head', 'options'})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class OpenApiOperation:
|
||||
name: str # operationId or "METHOD /path"
|
||||
method: str # uppercase: GET, POST, …
|
||||
path: str
|
||||
url: str
|
||||
headers: Dict[str, str]
|
||||
body: str # JSON template or ''
|
||||
description: str # summary / description
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenApiParseResult:
|
||||
api_title: str
|
||||
base_url: str
|
||||
operations: List[OpenApiOperation] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_openapi(content: str) -> OpenApiParseResult:
|
||||
"""Parse OpenAPI 2.0 (Swagger) or 3.x from JSON or YAML."""
|
||||
data = _load(content)
|
||||
if isinstance(data, str): # error message
|
||||
return OpenApiParseResult(api_title='', base_url='', error=data)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return OpenApiParseResult(api_title='', base_url='',
|
||||
error='Not a valid OpenAPI document')
|
||||
|
||||
if 'swagger' in data:
|
||||
return _parse_v2(data)
|
||||
elif 'openapi' in data:
|
||||
return _parse_v3(data)
|
||||
else:
|
||||
return OpenApiParseResult(
|
||||
api_title='', base_url='',
|
||||
error='Not a recognized OpenAPI document (missing "swagger" or "openapi" key)'
|
||||
)
|
||||
|
||||
|
||||
def build_http_request(operation: OpenApiOperation):
|
||||
"""Convert an OpenApiOperation into an HttpRequest."""
|
||||
from .models import HttpRequest
|
||||
|
||||
syntax = 'JSON' if operation.body.lstrip().startswith('{') else 'RAW'
|
||||
return HttpRequest(
|
||||
method=operation.method,
|
||||
url=operation.url,
|
||||
headers=operation.headers,
|
||||
body=operation.body,
|
||||
syntax=syntax,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loader (JSON + optional YAML)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load(content: str):
|
||||
"""Return parsed dict or an error string."""
|
||||
content = content.strip()
|
||||
# Try JSON first
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fall back to YAML
|
||||
if _YAML_AVAILABLE:
|
||||
try:
|
||||
return _yaml.safe_load(content)
|
||||
except Exception as e:
|
||||
return f'Invalid YAML: {e}'
|
||||
|
||||
# Looks like YAML but library not available
|
||||
if not content.startswith('{'):
|
||||
return ('YAML format detected but PyYAML is not available. '
|
||||
'Please export the spec as JSON.')
|
||||
return 'Invalid JSON'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAPI 2.0 (Swagger)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_v2(data: dict) -> OpenApiParseResult:
|
||||
info = data.get('info', {})
|
||||
title = info.get('title', 'API')
|
||||
|
||||
schemes = data.get('schemes', ['https'])
|
||||
scheme = 'https' if 'https' in schemes else (schemes[0] if schemes else 'https')
|
||||
host = data.get('host', 'localhost')
|
||||
base_path = data.get('basePath', '/').rstrip('/')
|
||||
base_url = f'{scheme}://{host}{base_path}'
|
||||
|
||||
definitions = data.get('definitions', {})
|
||||
paths = data.get('paths', {})
|
||||
|
||||
operations = []
|
||||
for path, path_item in paths.items():
|
||||
if not isinstance(path_item, dict):
|
||||
continue
|
||||
path_params = path_item.get('parameters', [])
|
||||
for method, op_data in path_item.items():
|
||||
if method not in HTTP_METHODS or not isinstance(op_data, dict):
|
||||
continue
|
||||
params = _resolve_params(path_params + op_data.get('parameters', []),
|
||||
data.get('parameters', {}))
|
||||
operations.append(_make_op_v2(
|
||||
method.upper(), path, base_url, op_data, params, definitions
|
||||
))
|
||||
|
||||
if not operations:
|
||||
return OpenApiParseResult(api_title=title, base_url=base_url,
|
||||
error='No operations found in this document')
|
||||
|
||||
return OpenApiParseResult(api_title=title, base_url=base_url, operations=operations)
|
||||
|
||||
|
||||
def _make_op_v2(method, path, base_url, op_data, params, definitions) -> OpenApiOperation:
|
||||
op_id = op_data.get('operationId', '')
|
||||
description = op_data.get('summary') or op_data.get('description', '')
|
||||
name = op_id or path.lstrip('/')
|
||||
|
||||
url = base_url + path
|
||||
url = _append_required_query(url, params)
|
||||
|
||||
headers: Dict[str, str] = {}
|
||||
body = ''
|
||||
|
||||
if method in ('POST', 'PUT', 'PATCH', 'DELETE'):
|
||||
body_params = [p for p in params if p.get('in') == 'body']
|
||||
if body_params:
|
||||
consumes = op_data.get('consumes', ['application/json'])
|
||||
if any('json' in ct for ct in consumes):
|
||||
headers['Content-Type'] = 'application/json'
|
||||
schema = body_params[0].get('schema', {})
|
||||
body = _schema_to_json(schema, definitions)
|
||||
else:
|
||||
headers['Content-Type'] = consumes[0] if consumes else 'application/octet-stream'
|
||||
|
||||
form_params = [p for p in params if p.get('in') == 'formData']
|
||||
if form_params and not body:
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
body = '&'.join(f"{p['name']}=" for p in form_params)
|
||||
|
||||
produces = op_data.get('produces', ['application/json'])
|
||||
if any('json' in ct for ct in produces):
|
||||
headers.setdefault('Accept', 'application/json')
|
||||
|
||||
return OpenApiOperation(
|
||||
name=name, method=method, path=path,
|
||||
url=url, headers=headers, body=body, description=description,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAPI 3.x
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_v3(data: dict) -> OpenApiParseResult:
|
||||
info = data.get('info', {})
|
||||
title = info.get('title', 'API')
|
||||
|
||||
servers = data.get('servers') or [{}]
|
||||
base_url = servers[0].get('url', '').rstrip('/')
|
||||
|
||||
components = data.get('components', {})
|
||||
schemas = components.get('schemas', {})
|
||||
param_defs = components.get('parameters', {})
|
||||
paths = data.get('paths', {})
|
||||
|
||||
operations = []
|
||||
for path, path_item in paths.items():
|
||||
if not isinstance(path_item, dict):
|
||||
continue
|
||||
path_params = _resolve_params(path_item.get('parameters', []), param_defs)
|
||||
for method, op_data in path_item.items():
|
||||
if method not in HTTP_METHODS or not isinstance(op_data, dict):
|
||||
continue
|
||||
params = _resolve_params(path_params + op_data.get('parameters', []),
|
||||
param_defs)
|
||||
operations.append(_make_op_v3(
|
||||
method.upper(), path, base_url, op_data, params, schemas
|
||||
))
|
||||
|
||||
if not operations:
|
||||
return OpenApiParseResult(api_title=title, base_url=base_url,
|
||||
error='No operations found in this document')
|
||||
|
||||
return OpenApiParseResult(api_title=title, base_url=base_url, operations=operations)
|
||||
|
||||
|
||||
def _make_op_v3(method, path, base_url, op_data, params, schemas) -> OpenApiOperation:
|
||||
op_id = op_data.get('operationId', '')
|
||||
description = op_data.get('summary') or op_data.get('description', '')
|
||||
name = op_id or path.lstrip('/')
|
||||
|
||||
url = base_url + path
|
||||
url = _append_required_query(url, params)
|
||||
|
||||
headers: Dict[str, str] = {}
|
||||
body = ''
|
||||
|
||||
req_body = op_data.get('requestBody', {})
|
||||
if req_body and method in ('POST', 'PUT', 'PATCH', 'DELETE'):
|
||||
content = req_body.get('content', {})
|
||||
# Prefer application/json
|
||||
json_ct = next(
|
||||
(ct for ct in content if 'json' in ct),
|
||||
None
|
||||
)
|
||||
if json_ct:
|
||||
headers['Content-Type'] = json_ct
|
||||
schema = content[json_ct].get('schema', {})
|
||||
body = _schema_to_json(schema, schemas)
|
||||
elif content:
|
||||
ct = next(iter(content))
|
||||
headers['Content-Type'] = ct
|
||||
if 'form' in ct:
|
||||
schema = content[ct].get('schema', {})
|
||||
props = schema.get('properties', {})
|
||||
body = '&'.join(f'{k}=' for k in props)
|
||||
|
||||
headers.setdefault('Accept', 'application/json')
|
||||
|
||||
return OpenApiOperation(
|
||||
name=name, method=method, path=path,
|
||||
url=url, headers=headers, body=body, description=description,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_params(params: list, param_defs: dict) -> list:
|
||||
"""Resolve $ref entries in a parameter list."""
|
||||
resolved = []
|
||||
for p in params:
|
||||
if isinstance(p, dict) and '$ref' in p:
|
||||
ref_name = p['$ref'].split('/')[-1]
|
||||
p = param_defs.get(ref_name, p)
|
||||
resolved.append(p)
|
||||
return resolved
|
||||
|
||||
|
||||
def _append_required_query(url: str, params: list) -> str:
|
||||
required = [p['name'] for p in params
|
||||
if p.get('in') == 'query' and p.get('required', False)]
|
||||
if required:
|
||||
url += '?' + '&'.join(f'{n}=' for n in required)
|
||||
return url
|
||||
|
||||
|
||||
def _schema_to_json(schema: dict, defs: dict, depth: int = 0) -> str:
|
||||
"""Return a pretty-printed JSON template string for the given schema."""
|
||||
try:
|
||||
value = _schema_value(schema, defs, depth)
|
||||
return json.dumps(value, indent=2, ensure_ascii=False)
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _schema_value(schema: dict, defs: dict, depth: int = 0) -> Any:
|
||||
"""Recursively build a representative value from a JSON Schema."""
|
||||
if depth > 4:
|
||||
return {}
|
||||
|
||||
# Resolve $ref
|
||||
if '$ref' in schema:
|
||||
ref_name = schema['$ref'].split('/')[-1]
|
||||
schema = defs.get(ref_name, {})
|
||||
if not schema:
|
||||
return {}
|
||||
|
||||
# Combiners: allOf / anyOf / oneOf
|
||||
for combiner in ('allOf', 'anyOf', 'oneOf'):
|
||||
if combiner in schema:
|
||||
merged: Dict[str, Any] = {}
|
||||
for sub in schema[combiner]:
|
||||
if '$ref' in sub:
|
||||
sub = defs.get(sub['$ref'].split('/')[-1], {})
|
||||
result = _schema_value(sub, defs, depth)
|
||||
if isinstance(result, dict):
|
||||
merged.update(result)
|
||||
if merged:
|
||||
return merged
|
||||
# Non-object combiners: return first sub-value
|
||||
first = schema[combiner][0] if schema[combiner] else {}
|
||||
return _schema_value(first, defs, depth)
|
||||
|
||||
type_ = schema.get('type', 'object' if 'properties' in schema else None)
|
||||
|
||||
if type_ == 'object' or 'properties' in schema:
|
||||
result = {}
|
||||
required = schema.get('required', [])
|
||||
for prop, prop_schema in schema.get('properties', {}).items():
|
||||
result[prop] = _schema_value(prop_schema, defs, depth + 1)
|
||||
if not result and 'additionalProperties' in schema:
|
||||
add = schema['additionalProperties']
|
||||
if isinstance(add, dict):
|
||||
result['key'] = _schema_value(add, defs, depth + 1)
|
||||
return result
|
||||
|
||||
if type_ == 'array':
|
||||
items = schema.get('items', {})
|
||||
return [_schema_value(items, defs, depth + 1)] if items else []
|
||||
|
||||
if type_ == 'string':
|
||||
enum = schema.get('enum')
|
||||
if enum:
|
||||
return enum[0]
|
||||
fmt = schema.get('format', '')
|
||||
return _FORMAT_DEFAULTS.get(fmt, '')
|
||||
|
||||
if type_ in ('integer', 'number'):
|
||||
return schema.get('minimum', 0)
|
||||
|
||||
if type_ == 'boolean':
|
||||
return False
|
||||
|
||||
if type_ == 'null':
|
||||
return None
|
||||
|
||||
return _TYPE_DEFAULTS.get(type_, {})
|
||||
@ -7,6 +7,7 @@
|
||||
<property name="title" translatable="yes">Preferences</property>
|
||||
<property name="modal">True</property>
|
||||
<property name="default-width">600</property>
|
||||
<property name="default-height">400</property>
|
||||
|
||||
<child>
|
||||
<object class="AdwPreferencesPage">
|
||||
@ -66,65 +67,6 @@
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="AdwPreferencesGroup">
|
||||
<property name="title" translatable="yes">Data</property>
|
||||
<property name="description" translatable="yes">Project storage and backup</property>
|
||||
|
||||
<child>
|
||||
<object class="AdwActionRow" id="data_folder_row">
|
||||
<property name="title" translatable="yes">Data folder</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="open_folder_button">
|
||||
<property name="label" translatable="yes">Open</property>
|
||||
<property name="valign">center</property>
|
||||
<signal name="clicked" handler="on_open_folder_clicked" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="AdwActionRow" id="backup_folder_row">
|
||||
<property name="title" translatable="yes">Backup folder</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="spacing">6</property>
|
||||
<property name="valign">center</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="choose_backup_folder_button">
|
||||
<property name="label" translatable="yes">Choose…</property>
|
||||
<signal name="clicked" handler="on_choose_backup_folder_clicked" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="AdwSwitchRow" id="auto_backup_row">
|
||||
<property name="title" translatable="yes">Auto-backup on save</property>
|
||||
<property name="subtitle" translatable="yes">Copy project files to the backup folder after each save</property>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="AdwActionRow">
|
||||
<property name="title" translatable="yes">Backup now</property>
|
||||
<property name="subtitle" translatable="yes">Copy all project files to the backup folder</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="backup_now_button">
|
||||
<property name="label" translatable="yes">Backup</property>
|
||||
<property name="valign">center</property>
|
||||
<signal name="clicked" handler="on_backup_now_clicked" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</template>
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
# 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
|
||||
@ -17,11 +18,8 @@
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import logging
|
||||
from gi.repository import Adw, Gtk, Gio, GObject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@Gtk.Template(resource_path='/cz/bugsy/roster/preferences-dialog.ui')
|
||||
class PreferencesDialog(Adw.PreferencesWindow):
|
||||
@ -30,70 +28,41 @@ class PreferencesDialog(Adw.PreferencesWindow):
|
||||
tls_verification_row = Gtk.Template.Child()
|
||||
timeout_row = Gtk.Template.Child()
|
||||
clear_history_button = Gtk.Template.Child()
|
||||
data_folder_row = Gtk.Template.Child()
|
||||
open_folder_button = Gtk.Template.Child()
|
||||
backup_folder_row = Gtk.Template.Child()
|
||||
choose_backup_folder_button = Gtk.Template.Child()
|
||||
auto_backup_row = Gtk.Template.Child()
|
||||
backup_now_button = Gtk.Template.Child()
|
||||
|
||||
__gsignals__ = {
|
||||
'history-cleared': (GObject.SIGNAL_RUN_FIRST, None, ())
|
||||
}
|
||||
|
||||
def __init__(self, history_manager=None, project_manager=None, **kwargs):
|
||||
def __init__(self, history_manager=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.history_manager = history_manager
|
||||
self.project_manager = project_manager
|
||||
|
||||
# Get settings
|
||||
self.settings = Gio.Settings.new('cz.bugsy.roster')
|
||||
|
||||
# Bind settings to UI
|
||||
self.settings.bind(
|
||||
'force-tls-verification',
|
||||
self.tls_verification_row,
|
||||
'active',
|
||||
Gio.SettingsBindFlags.DEFAULT
|
||||
)
|
||||
|
||||
self.settings.bind(
|
||||
'request-timeout',
|
||||
self.timeout_row,
|
||||
'value',
|
||||
Gio.SettingsBindFlags.DEFAULT
|
||||
)
|
||||
self.settings.bind(
|
||||
'backup-auto-export',
|
||||
self.auto_backup_row,
|
||||
'active',
|
||||
Gio.SettingsBindFlags.DEFAULT
|
||||
)
|
||||
|
||||
# Populate data folder path
|
||||
if project_manager is not None:
|
||||
self.data_folder_row.set_subtitle(str(project_manager.data_dir))
|
||||
|
||||
# Populate backup folder path
|
||||
self._update_backup_folder_subtitle()
|
||||
self.settings.connect('changed::backup-folder', lambda *_: self._update_backup_folder_subtitle())
|
||||
|
||||
# Disable backup buttons when no backup folder is set
|
||||
self._update_backup_button_sensitivity()
|
||||
self.settings.connect('changed::backup-folder', lambda *_: self._update_backup_button_sensitivity())
|
||||
|
||||
def _update_backup_folder_subtitle(self):
|
||||
folder = self.settings.get_string('backup-folder')
|
||||
self.backup_folder_row.set_subtitle(folder if folder else 'Not set')
|
||||
|
||||
def _update_backup_button_sensitivity(self):
|
||||
has_folder = bool(self.settings.get_string('backup-folder'))
|
||||
self.backup_now_button.set_sensitive(has_folder)
|
||||
self.auto_backup_row.set_sensitive(has_folder)
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_clear_history_clicked(self, button):
|
||||
"""Clear all history after confirmation."""
|
||||
if not self.history_manager:
|
||||
return
|
||||
|
||||
# Create confirmation dialog
|
||||
dialog = Adw.AlertDialog.new(
|
||||
"Clear All History?",
|
||||
"This will permanently delete all saved request and response history. This action cannot be undone."
|
||||
@ -106,59 +75,10 @@ class PreferencesDialog(Adw.PreferencesWindow):
|
||||
|
||||
def on_response(dialog, response):
|
||||
if response == "clear":
|
||||
# Clear the history
|
||||
self.history_manager.clear_history()
|
||||
# Notify the main window to refresh
|
||||
self.emit('history-cleared')
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
dialog.present(self)
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_open_folder_clicked(self, button):
|
||||
if self.project_manager is None:
|
||||
return
|
||||
Gio.AppInfo.launch_default_for_uri(
|
||||
f"file://{self.project_manager.data_dir}", None
|
||||
)
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_choose_backup_folder_clicked(self, button):
|
||||
chooser = Gtk.FileChooserNative.new(
|
||||
"Choose Backup Folder",
|
||||
self,
|
||||
Gtk.FileChooserAction.SELECT_FOLDER,
|
||||
"Select",
|
||||
"Cancel"
|
||||
)
|
||||
|
||||
current = self.settings.get_string('backup-folder')
|
||||
if current:
|
||||
try:
|
||||
chooser.set_current_folder(Gio.File.new_for_path(current))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_response(chooser, response):
|
||||
if response == Gtk.ResponseType.ACCEPT:
|
||||
folder = chooser.get_file()
|
||||
if folder:
|
||||
self.settings.set_string('backup-folder', folder.get_path())
|
||||
|
||||
chooser.connect('response', on_response)
|
||||
chooser.show()
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_backup_now_clicked(self, button):
|
||||
if self.project_manager is None:
|
||||
return
|
||||
folder = self.settings.get_string('backup-folder')
|
||||
if not folder:
|
||||
return
|
||||
|
||||
try:
|
||||
self.project_manager.export_to_folder(folder)
|
||||
toast = Adw.Toast.new("Backup completed")
|
||||
self.add_toast(toast)
|
||||
except Exception as e:
|
||||
logger.error("Backup failed: %s", e)
|
||||
toast = Adw.Toast.new("Backup failed")
|
||||
self.add_toast(toast)
|
||||
|
||||
@ -19,7 +19,6 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import uuid
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@ -30,300 +29,85 @@ gi.require_version('GLib', '2.0')
|
||||
from gi.repository import GLib
|
||||
from .models import Project, SavedRequest, HttpRequest, Environment
|
||||
from .secret_manager import get_secret_manager
|
||||
from .migration import _sanitize_name, needs_migration, run_migration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _unique_filename(base: str, exclude: set) -> str:
|
||||
"""Return a unique .json filename whose basename is not in `exclude`."""
|
||||
if base not in exclude:
|
||||
return base + '.json'
|
||||
i = 2
|
||||
while f"{base}-{i}" in exclude:
|
||||
i += 1
|
||||
return f"{base}-{i}.json"
|
||||
|
||||
|
||||
class ProjectManager:
|
||||
"""Manages project and saved request persistence."""
|
||||
|
||||
_INDEX_FILE = '_index.json'
|
||||
|
||||
def __init__(self):
|
||||
# Use XDG data directory (works for both Flatpak and native)
|
||||
# Flatpak: ~/.var/app/cz.bugsy.roster/data/cz.bugsy.roster
|
||||
# Native: ~/.local/share/cz.bugsy.roster
|
||||
self.data_dir = Path(GLib.get_user_data_dir()) / 'cz.bugsy.roster'
|
||||
self.projects_dir = self.data_dir / 'projects'
|
||||
self.legacy_file = self.data_dir / 'requests.json'
|
||||
self.projects_file = self.data_dir / 'requests.json'
|
||||
self._ensure_data_dir()
|
||||
|
||||
# In-memory cache for projects to reduce disk I/O
|
||||
self._projects_cache: Optional[List[Project]] = None
|
||||
# project_id → directory name under projects_dir
|
||||
self._project_dirs: dict = {}
|
||||
# request_id → filename (e.g. "get-users.json")
|
||||
self._request_filenames: dict = {}
|
||||
self._legacy_mode = False
|
||||
|
||||
if needs_migration(self.projects_dir, self.legacy_file):
|
||||
if not run_migration(self.legacy_file, self.projects_dir):
|
||||
logger.error("Migration failed – falling back to legacy mode")
|
||||
self._legacy_mode = True
|
||||
|
||||
def _ensure_data_dir(self):
|
||||
"""Create data directory if it doesn't exist."""
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ===== Loading =====
|
||||
|
||||
def load_projects(self, force_reload: bool = False) -> List[Project]:
|
||||
"""
|
||||
Load projects from JSON file with in-memory caching.
|
||||
|
||||
Args:
|
||||
force_reload: If True, bypass cache and reload from disk
|
||||
|
||||
Returns:
|
||||
List of Project objects
|
||||
"""
|
||||
# Return cached data if available and not forcing reload
|
||||
if self._projects_cache is not None and not force_reload:
|
||||
return self._projects_cache
|
||||
|
||||
if self._legacy_mode or not self.projects_dir.exists():
|
||||
return self._load_legacy()
|
||||
|
||||
return self._load_from_dirs()
|
||||
|
||||
def _load_project_order(self) -> List[str]:
|
||||
"""Load project order from _index.json. Returns list of project IDs."""
|
||||
index_file = self.projects_dir / self._INDEX_FILE
|
||||
if not index_file.exists():
|
||||
return []
|
||||
try:
|
||||
with open(index_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error("Error loading project order: %s", e)
|
||||
return []
|
||||
|
||||
def _save_project_order(self, project_ids: List[str]):
|
||||
"""Save project order to _index.json."""
|
||||
index_file = self.projects_dir / self._INDEX_FILE
|
||||
try:
|
||||
with open(index_file, 'w') as f:
|
||||
json.dump(project_ids, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error("Error saving project order: %s", e)
|
||||
|
||||
def _load_from_dirs(self) -> List[Project]:
|
||||
projects_by_id = {}
|
||||
self._project_dirs.clear()
|
||||
self._request_filenames.clear()
|
||||
|
||||
try:
|
||||
entries = sorted(self.projects_dir.iterdir())
|
||||
except Exception as e:
|
||||
logger.error("Cannot iterate projects dir: %s", e)
|
||||
# Load from disk
|
||||
if not self.projects_file.exists():
|
||||
self._projects_cache = []
|
||||
return []
|
||||
|
||||
for proj_dir in entries:
|
||||
if not proj_dir.is_dir():
|
||||
continue
|
||||
meta_file = proj_dir / '_project.json'
|
||||
if not meta_file.exists():
|
||||
continue
|
||||
try:
|
||||
with open(meta_file, 'r') as f:
|
||||
meta = json.load(f)
|
||||
|
||||
request_order = meta.get('request_order', [])
|
||||
requests = []
|
||||
loaded_files: set = set()
|
||||
|
||||
for filename in request_order:
|
||||
req_file = proj_dir / filename
|
||||
if not req_file.exists():
|
||||
continue
|
||||
try:
|
||||
with open(req_file, 'r') as f:
|
||||
req_data = json.load(f)
|
||||
req = SavedRequest.from_dict(req_data)
|
||||
requests.append(req)
|
||||
self._request_filenames[req.id] = filename
|
||||
loaded_files.add(filename)
|
||||
except Exception as e:
|
||||
logger.error("Error loading request %s: %s", req_file, e)
|
||||
|
||||
# Also pick up files not listed in request_order
|
||||
for req_file in sorted(proj_dir.iterdir()):
|
||||
if req_file.name.startswith('_') or req_file.suffix != '.json':
|
||||
continue
|
||||
if req_file.name in loaded_files:
|
||||
continue
|
||||
try:
|
||||
with open(req_file, 'r') as f:
|
||||
req_data = json.load(f)
|
||||
req = SavedRequest.from_dict(req_data)
|
||||
requests.append(req)
|
||||
self._request_filenames[req.id] = req_file.name
|
||||
except Exception as e:
|
||||
logger.error("Error loading extra request %s: %s", req_file, e)
|
||||
|
||||
project = Project(
|
||||
id=meta['id'],
|
||||
name=meta['name'],
|
||||
requests=requests,
|
||||
created_at=meta['created_at'],
|
||||
icon=meta.get('icon', 'folder-symbolic'),
|
||||
variable_names=meta.get('variable_names', []),
|
||||
environments=[Environment.from_dict(e) for e in meta.get('environments', [])],
|
||||
sensitive_variables=meta.get('sensitive_variables', []),
|
||||
)
|
||||
projects_by_id[project.id] = project
|
||||
self._project_dirs[project.id] = proj_dir.name
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error loading project %s: %s", proj_dir, e)
|
||||
|
||||
# Apply saved order, then append any projects not yet in the order file
|
||||
ordered_ids = self._load_project_order()
|
||||
projects = []
|
||||
seen: set = set()
|
||||
for pid in ordered_ids:
|
||||
if pid in projects_by_id and pid not in seen:
|
||||
projects.append(projects_by_id[pid])
|
||||
seen.add(pid)
|
||||
for pid, project in projects_by_id.items():
|
||||
if pid not in seen:
|
||||
projects.append(project)
|
||||
|
||||
self._projects_cache = projects
|
||||
return projects
|
||||
|
||||
def _load_legacy(self) -> List[Project]:
|
||||
if not self.legacy_file.exists():
|
||||
self._projects_cache = []
|
||||
return []
|
||||
try:
|
||||
with open(self.legacy_file, 'r') as f:
|
||||
with open(self.projects_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
self._projects_cache = [Project.from_dict(p) for p in data.get('projects', [])]
|
||||
return self._projects_cache
|
||||
except Exception as e:
|
||||
logger.error("Error loading legacy projects: %s", e)
|
||||
logger.error(f"Error loading projects: {e}")
|
||||
self._projects_cache = []
|
||||
return []
|
||||
|
||||
# ===== Saving =====
|
||||
|
||||
def _save_project(self, project: Project):
|
||||
if self._legacy_mode:
|
||||
self._save_all_legacy()
|
||||
return
|
||||
|
||||
def save_projects(self, projects: List[Project]):
|
||||
"""Save projects to JSON file and update cache."""
|
||||
try:
|
||||
old_dir_name = self._project_dirs.get(project.id)
|
||||
new_dir_name = _sanitize_name(project.name)
|
||||
|
||||
# Handle project rename
|
||||
if old_dir_name and old_dir_name != new_dir_name:
|
||||
old_dir = self.projects_dir / old_dir_name
|
||||
if old_dir.exists():
|
||||
# Ensure new name is unique
|
||||
candidate = new_dir_name
|
||||
i = 2
|
||||
while (self.projects_dir / candidate).exists():
|
||||
candidate = f"{new_dir_name}-{i}"
|
||||
i += 1
|
||||
new_dir_name = candidate
|
||||
old_dir.rename(self.projects_dir / new_dir_name)
|
||||
|
||||
project_dir = self.projects_dir / new_dir_name
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._project_dirs[project.id] = new_dir_name
|
||||
|
||||
# Assign filenames preserving stable names where possible
|
||||
request_order = []
|
||||
used_basenames: set = set()
|
||||
|
||||
for req in project.requests:
|
||||
old_filename = self._request_filenames.get(req.id)
|
||||
desired_base = _sanitize_name(req.name)
|
||||
|
||||
if old_filename is not None:
|
||||
old_base = old_filename[:-5] if old_filename.endswith('.json') else old_filename
|
||||
if old_base == desired_base and old_base not in used_basenames:
|
||||
filename = old_filename
|
||||
else:
|
||||
filename = _unique_filename(desired_base, used_basenames)
|
||||
else:
|
||||
filename = _unique_filename(desired_base, used_basenames)
|
||||
|
||||
used_basenames.add(filename[:-5] if filename.endswith('.json') else filename)
|
||||
request_order.append(filename)
|
||||
self._request_filenames[req.id] = filename
|
||||
|
||||
# Write request files
|
||||
for req, filename in zip(project.requests, request_order):
|
||||
with open(project_dir / filename, 'w') as f:
|
||||
json.dump(req.to_dict(), f, indent=2)
|
||||
|
||||
# Remove orphaned request files
|
||||
for existing in list(project_dir.iterdir()):
|
||||
if existing.name.startswith('_') or existing.suffix != '.json':
|
||||
continue
|
||||
if existing.name not in request_order:
|
||||
existing.unlink()
|
||||
|
||||
# Write project metadata
|
||||
meta = {
|
||||
'id': project.id,
|
||||
'name': project.name,
|
||||
'created_at': project.created_at,
|
||||
'icon': project.icon,
|
||||
'variable_names': project.variable_names,
|
||||
'sensitive_variables': project.sensitive_variables,
|
||||
'environments': [e.to_dict() for e in project.environments],
|
||||
'request_order': request_order,
|
||||
data = {
|
||||
'version': 1,
|
||||
'projects': [p.to_dict() for p in projects]
|
||||
}
|
||||
with open(project_dir / '_project.json', 'w') as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
|
||||
self._trigger_auto_backup()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error saving project %s: %s", project.name, e)
|
||||
|
||||
def _save_all_legacy(self):
|
||||
"""Fallback: write all cached projects to the monolithic JSON file."""
|
||||
try:
|
||||
projects = self._projects_cache or []
|
||||
data = {'version': 1, 'projects': [p.to_dict() for p in projects]}
|
||||
with open(self.legacy_file, 'w') as f:
|
||||
with open(self.projects_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
# Update cache with the saved data
|
||||
self._projects_cache = projects
|
||||
except Exception as e:
|
||||
logger.error("Error saving projects (legacy): %s", e)
|
||||
|
||||
def _trigger_auto_backup(self):
|
||||
try:
|
||||
import gi
|
||||
gi.require_version('Gio', '2.0')
|
||||
from gi.repository import Gio
|
||||
settings = Gio.Settings.new('cz.bugsy.roster')
|
||||
if settings.get_boolean('backup-auto-export'):
|
||||
folder = settings.get_string('backup-folder')
|
||||
if folder:
|
||||
self.export_to_folder(folder)
|
||||
except Exception as e:
|
||||
logger.error("Auto-backup failed: %s", e)
|
||||
|
||||
# ===== Backup / export =====
|
||||
|
||||
def export_to_folder(self, destination: str):
|
||||
dest = Path(destination) / 'roster-backup'
|
||||
shutil.copytree(self.projects_dir, dest, dirs_exist_ok=True)
|
||||
|
||||
# ===== Public API =====
|
||||
logger.error(f"Error saving projects: {e}")
|
||||
|
||||
def add_project(self, name: str) -> Project:
|
||||
"""Create new project with default environment."""
|
||||
projects = self.load_projects()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Create default environment
|
||||
default_env = Environment(
|
||||
id=str(uuid.uuid4()),
|
||||
name="Default",
|
||||
variables={},
|
||||
created_at=now
|
||||
)
|
||||
|
||||
project = Project(
|
||||
id=str(uuid.uuid4()),
|
||||
name=name,
|
||||
@ -333,14 +117,11 @@ class ProjectManager:
|
||||
environments=[default_env]
|
||||
)
|
||||
projects.append(project)
|
||||
self._save_project(project)
|
||||
if not self._legacy_mode:
|
||||
order = self._load_project_order()
|
||||
order.append(project.id)
|
||||
self._save_project_order(order)
|
||||
self.save_projects(projects)
|
||||
return project
|
||||
|
||||
def update_project(self, project_id: str, new_name: str = None, new_icon: str = None):
|
||||
"""Update a project's name and/or icon."""
|
||||
projects = self.load_projects()
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
@ -348,33 +129,20 @@ class ProjectManager:
|
||||
p.name = new_name
|
||||
if new_icon is not None:
|
||||
p.icon = new_icon
|
||||
self._save_project(p)
|
||||
break
|
||||
self.save_projects(projects)
|
||||
|
||||
def delete_project(self, project_id: str,
|
||||
callback: Optional[Callable[[], None]] = None):
|
||||
"""Delete a project and all its requests (async for secret cleanup)."""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
if not self._legacy_mode:
|
||||
dir_name = self._project_dirs.get(project_id)
|
||||
if dir_name:
|
||||
project_dir = self.projects_dir / dir_name
|
||||
if project_dir.exists():
|
||||
shutil.rmtree(project_dir)
|
||||
self._project_dirs.pop(project_id, None)
|
||||
break
|
||||
|
||||
self._projects_cache = [p for p in projects if p.id != project_id]
|
||||
|
||||
if self._legacy_mode:
|
||||
self._save_all_legacy()
|
||||
else:
|
||||
order = self._load_project_order()
|
||||
self._save_project_order([pid for pid in order if pid != project_id])
|
||||
# Remove project from list and save immediately
|
||||
projects = [p for p in projects if p.id != project_id]
|
||||
self.save_projects(projects)
|
||||
|
||||
# Delete all secrets for this project asynchronously
|
||||
def on_secrets_deleted(success):
|
||||
if callback:
|
||||
callback()
|
||||
@ -382,6 +150,7 @@ class ProjectManager:
|
||||
secret_manager.delete_all_project_secrets(project_id, on_secrets_deleted)
|
||||
|
||||
def add_request(self, project_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest:
|
||||
"""Add request to a project."""
|
||||
projects = self.load_projects()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
saved_request = SavedRequest(
|
||||
@ -395,11 +164,12 @@ class ProjectManager:
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
p.requests.append(saved_request)
|
||||
self._save_project(p)
|
||||
break
|
||||
self.save_projects(projects)
|
||||
return saved_request
|
||||
|
||||
def find_request_by_name(self, project_id: str, name: str) -> Optional[SavedRequest]:
|
||||
"""Find a request by name within a project. Returns None if not found."""
|
||||
projects = self.load_projects()
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
@ -410,6 +180,7 @@ class ProjectManager:
|
||||
return None
|
||||
|
||||
def update_request(self, project_id: str, request_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest:
|
||||
"""Update an existing request."""
|
||||
projects = self.load_projects()
|
||||
updated_request = None
|
||||
for p in projects:
|
||||
@ -422,73 +193,29 @@ class ProjectManager:
|
||||
req.modified_at = datetime.now(timezone.utc).isoformat()
|
||||
updated_request = req
|
||||
break
|
||||
if updated_request:
|
||||
self._save_project(p)
|
||||
break
|
||||
if updated_request:
|
||||
self.save_projects(projects)
|
||||
return updated_request
|
||||
|
||||
def delete_request(self, project_id: str, request_id: str):
|
||||
"""Delete a saved request."""
|
||||
projects = self.load_projects()
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
p.requests = [r for r in p.requests if r.id != request_id]
|
||||
self._request_filenames.pop(request_id, None)
|
||||
self._save_project(p)
|
||||
break
|
||||
|
||||
def reorder_project(self, project_id: str, direction: int):
|
||||
"""Move a project up (direction=-1) or down (direction=+1)."""
|
||||
projects = self.load_projects()
|
||||
idx = next((i for i, p in enumerate(projects) if p.id == project_id), None)
|
||||
if idx is None:
|
||||
return
|
||||
new_idx = idx + direction
|
||||
if new_idx < 0 or new_idx >= len(projects):
|
||||
return
|
||||
projects[idx], projects[new_idx] = projects[new_idx], projects[idx]
|
||||
self._projects_cache = projects
|
||||
if not self._legacy_mode:
|
||||
self._save_project_order([p.id for p in projects])
|
||||
|
||||
def reorder_request(self, project_id: str, request_id: str, direction: int):
|
||||
"""Move a request up (direction=-1) or down (direction=+1) within its project."""
|
||||
projects = self.load_projects()
|
||||
for p in projects:
|
||||
if p.id != project_id:
|
||||
continue
|
||||
idx = next((i for i, r in enumerate(p.requests) if r.id == request_id), None)
|
||||
if idx is None:
|
||||
return
|
||||
new_idx = idx + direction
|
||||
if new_idx < 0 or new_idx >= len(p.requests):
|
||||
return
|
||||
p.requests[idx], p.requests[new_idx] = p.requests[new_idx], p.requests[idx]
|
||||
self._save_project(p)
|
||||
return
|
||||
|
||||
def move_request_to_project(self, request_id: str, source_project_id: str, target_project_id: str):
|
||||
"""Move a request from one project to another."""
|
||||
projects = self.load_projects()
|
||||
source = next((p for p in projects if p.id == source_project_id), None)
|
||||
target = next((p for p in projects if p.id == target_project_id), None)
|
||||
if not source or not target:
|
||||
return
|
||||
req = next((r for r in source.requests if r.id == request_id), None)
|
||||
if not req:
|
||||
return
|
||||
# Write to target first so the file is never lost if source save fails
|
||||
target.requests.append(req)
|
||||
self._save_project(target)
|
||||
source.requests = [r for r in source.requests if r.id != request_id]
|
||||
self._save_project(source)
|
||||
self.save_projects(projects)
|
||||
|
||||
def add_environment(self, project_id: str, name: str) -> Environment:
|
||||
"""Add environment to a project."""
|
||||
projects = self.load_projects()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
environment = None
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
# Create environment with empty values for all variables
|
||||
variables = {var_name: "" for var_name in p.variable_names}
|
||||
environment = Environment(
|
||||
id=str(uuid.uuid4()),
|
||||
@ -497,78 +224,13 @@ class ProjectManager:
|
||||
created_at=now
|
||||
)
|
||||
p.environments.append(environment)
|
||||
self._save_project(p)
|
||||
break
|
||||
|
||||
self.save_projects(projects)
|
||||
return environment
|
||||
|
||||
def copy_environment(self, project_id: str, source_env_id: str, new_name: str,
|
||||
callback: Optional[Callable] = None):
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
source_env = next((e for e in p.environments if e.id == source_env_id), None)
|
||||
if source_env is None:
|
||||
if callback:
|
||||
callback(None)
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
new_env_id = str(uuid.uuid4())
|
||||
new_env = Environment(
|
||||
id=new_env_id,
|
||||
name=new_name,
|
||||
variables=dict(source_env.variables),
|
||||
created_at=now
|
||||
)
|
||||
p.environments.append(new_env)
|
||||
self._save_project(p)
|
||||
|
||||
sensitive_vars = [v for v in p.sensitive_variables if v in source_env.variables]
|
||||
if not sensitive_vars:
|
||||
if callback:
|
||||
callback(new_env)
|
||||
return
|
||||
|
||||
pending_count = [len(sensitive_vars)]
|
||||
|
||||
def make_copy_handler(var_name, project_name, env_name):
|
||||
def on_store_done(success=True):
|
||||
pending_count[0] -= 1
|
||||
if pending_count[0] == 0 and callback:
|
||||
callback(new_env)
|
||||
|
||||
def on_retrieve(value):
|
||||
if value:
|
||||
secret_manager.store_secret(
|
||||
project_id=project_id,
|
||||
environment_id=new_env_id,
|
||||
variable_name=var_name,
|
||||
value=value,
|
||||
project_name=project_name,
|
||||
environment_name=env_name,
|
||||
callback=lambda success: on_store_done(success)
|
||||
)
|
||||
else:
|
||||
on_store_done()
|
||||
|
||||
return on_retrieve
|
||||
|
||||
for var_name in sensitive_vars:
|
||||
secret_manager.retrieve_secret(
|
||||
project_id=project_id,
|
||||
environment_id=source_env_id,
|
||||
variable_name=var_name,
|
||||
callback=make_copy_handler(var_name, p.name, new_name)
|
||||
)
|
||||
return
|
||||
|
||||
if callback:
|
||||
callback(None)
|
||||
|
||||
def update_environment(self, project_id: str, env_id: str, name: str = None, variables: dict = None):
|
||||
"""Update an environment's name and/or variables."""
|
||||
projects = self.load_projects()
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
@ -579,20 +241,24 @@ class ProjectManager:
|
||||
if variables is not None:
|
||||
env.variables = variables
|
||||
break
|
||||
self._save_project(p)
|
||||
break
|
||||
self.save_projects(projects)
|
||||
|
||||
def delete_environment(self, project_id: str, env_id: str,
|
||||
callback: Optional[Callable[[], None]] = None):
|
||||
"""Delete an environment (async for secret cleanup)."""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
# Remove environment from list
|
||||
p.environments = [e for e in p.environments if e.id != env_id]
|
||||
self._save_project(p)
|
||||
break
|
||||
|
||||
self.save_projects(projects)
|
||||
|
||||
# Delete all secrets for this environment asynchronously
|
||||
def on_secrets_deleted(success):
|
||||
if callback:
|
||||
callback()
|
||||
@ -600,39 +266,47 @@ class ProjectManager:
|
||||
secret_manager.delete_all_environment_secrets(project_id, env_id, on_secrets_deleted)
|
||||
|
||||
def add_variable(self, project_id: str, variable_name: str):
|
||||
"""Add a variable to the project and all environments."""
|
||||
projects = self.load_projects()
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
if variable_name not in p.variable_names:
|
||||
p.variable_names.append(variable_name)
|
||||
# Add empty value to all environments
|
||||
for env in p.environments:
|
||||
env.variables[variable_name] = ""
|
||||
self._save_project(p)
|
||||
break
|
||||
self.save_projects(projects)
|
||||
|
||||
def rename_variable(self, project_id: str, old_name: str, new_name: str,
|
||||
callback: Optional[Callable[[], None]] = None):
|
||||
"""Rename a variable in the project and all environments (async for secrets)."""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
is_sensitive = False
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
# Update variable_names list
|
||||
if old_name in p.variable_names:
|
||||
idx = p.variable_names.index(old_name)
|
||||
p.variable_names[idx] = new_name
|
||||
|
||||
# Check if sensitive and update list
|
||||
if old_name in p.sensitive_variables:
|
||||
is_sensitive = True
|
||||
idx_sensitive = p.sensitive_variables.index(old_name)
|
||||
p.sensitive_variables[idx_sensitive] = new_name
|
||||
|
||||
# Update all environments
|
||||
for env in p.environments:
|
||||
if old_name in env.variables:
|
||||
env.variables[new_name] = env.variables.pop(old_name)
|
||||
self._save_project(p)
|
||||
break
|
||||
|
||||
self.save_projects(projects)
|
||||
|
||||
# Rename secrets in keyring if sensitive
|
||||
if is_sensitive:
|
||||
secret_manager.rename_variable_secrets(project_id, old_name, new_name, lambda success: callback() if callback else None)
|
||||
elif callback:
|
||||
@ -640,6 +314,7 @@ class ProjectManager:
|
||||
|
||||
def delete_variable(self, project_id: str, variable_name: str,
|
||||
callback: Optional[Callable[[], None]] = None):
|
||||
"""Delete a variable from the project and all environments (async for secrets)."""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
is_sensitive = False
|
||||
@ -647,19 +322,24 @@ class ProjectManager:
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
# Remove from variable_names
|
||||
if variable_name in p.variable_names:
|
||||
p.variable_names.remove(variable_name)
|
||||
|
||||
# Check if sensitive and get environments for cleanup
|
||||
if variable_name in p.sensitive_variables:
|
||||
is_sensitive = True
|
||||
p.sensitive_variables.remove(variable_name)
|
||||
environments_to_cleanup = [env.id for env in p.environments]
|
||||
|
||||
# Remove from all environments
|
||||
for env in p.environments:
|
||||
env.variables.pop(variable_name, None)
|
||||
self._save_project(p)
|
||||
break
|
||||
|
||||
self.save_projects(projects)
|
||||
|
||||
# Delete secrets for this variable asynchronously
|
||||
if is_sensitive and environments_to_cleanup:
|
||||
pending_count = [len(environments_to_cleanup)]
|
||||
|
||||
@ -674,6 +354,7 @@ class ProjectManager:
|
||||
callback()
|
||||
|
||||
def update_environment_variable(self, project_id: str, env_id: str, variable_name: str, value: str):
|
||||
"""Update a single variable value in an environment."""
|
||||
projects = self.load_projects()
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
@ -681,34 +362,58 @@ class ProjectManager:
|
||||
if env.id == env_id:
|
||||
env.variables[variable_name] = value
|
||||
break
|
||||
self._save_project(p)
|
||||
break
|
||||
self.save_projects(projects)
|
||||
|
||||
def batch_update_environment_variables(self, project_id: str, env_id: str, variables: dict):
|
||||
"""
|
||||
Update multiple variables in an environment in one operation.
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
env_id: Environment ID
|
||||
variables: Dict of variable_name -> value pairs to update
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
for env in p.environments:
|
||||
if env.id == env_id:
|
||||
# Update all variables
|
||||
for var_name, var_value in variables.items():
|
||||
env.variables[var_name] = var_value
|
||||
break
|
||||
self._save_project(p)
|
||||
break
|
||||
self.save_projects(projects)
|
||||
|
||||
# ===== Sensitive Variable Management =====
|
||||
# ========== Sensitive Variable Management ==========
|
||||
|
||||
def mark_variable_as_sensitive(self, project_id: str, variable_name: str,
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
"""
|
||||
Mark a variable as sensitive (move values from JSON to keyring) - async.
|
||||
|
||||
This will:
|
||||
1. Add variable to sensitive_variables list
|
||||
2. Move all environment values to keyring
|
||||
3. Clear values from JSON (store empty string as placeholder)
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
variable_name: Variable name to mark as sensitive
|
||||
callback: Optional callback called with success boolean
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
secrets_to_store = []
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
# Add to sensitive list if not already there
|
||||
if variable_name not in p.sensitive_variables:
|
||||
p.sensitive_variables.append(variable_name)
|
||||
|
||||
# Collect all environment values to store in keyring
|
||||
for env in p.environments:
|
||||
if variable_name in env.variables:
|
||||
value = env.variables[variable_name]
|
||||
@ -718,10 +423,12 @@ class ProjectManager:
|
||||
'value': value,
|
||||
'project_name': p.name
|
||||
})
|
||||
# Clear from JSON (keep empty placeholder)
|
||||
env.variables[variable_name] = ""
|
||||
|
||||
self._save_project(p)
|
||||
self.save_projects(projects)
|
||||
|
||||
# Store all secrets asynchronously
|
||||
if not secrets_to_store:
|
||||
if callback:
|
||||
callback(True)
|
||||
@ -754,6 +461,19 @@ class ProjectManager:
|
||||
|
||||
def mark_variable_as_nonsensitive(self, project_id: str, variable_name: str,
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
"""
|
||||
Mark a variable as non-sensitive (move values from keyring to JSON) - async.
|
||||
|
||||
This will:
|
||||
1. Remove variable from sensitive_variables list
|
||||
2. Move all environment values from keyring to JSON
|
||||
3. Delete values from keyring
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
variable_name: Variable name to mark as non-sensitive
|
||||
callback: Optional callback called with success boolean
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
@ -763,8 +483,10 @@ class ProjectManager:
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
target_project = p
|
||||
# Remove from sensitive list
|
||||
if variable_name in p.sensitive_variables:
|
||||
p.sensitive_variables.remove(variable_name)
|
||||
|
||||
environments_to_migrate = list(p.environments)
|
||||
break
|
||||
|
||||
@ -773,14 +495,17 @@ class ProjectManager:
|
||||
callback(False)
|
||||
return
|
||||
|
||||
# Retrieve all secrets, then update JSON, then delete from keyring
|
||||
pending_count = [len(environments_to_migrate)]
|
||||
all_success = [True]
|
||||
|
||||
def on_single_migrate(env):
|
||||
def on_retrieve(value):
|
||||
# Store in JSON
|
||||
env.variables[variable_name] = value or ""
|
||||
self._save_project(target_project)
|
||||
self.save_projects(projects)
|
||||
|
||||
# Delete from keyring
|
||||
def on_delete(success):
|
||||
if not success:
|
||||
all_success[0] = False
|
||||
@ -805,6 +530,7 @@ class ProjectManager:
|
||||
)
|
||||
|
||||
def is_variable_sensitive(self, project_id: str, variable_name: str) -> bool:
|
||||
"""Check if a variable is marked as sensitive."""
|
||||
projects = self.load_projects()
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
@ -813,11 +539,24 @@ class ProjectManager:
|
||||
|
||||
def get_variable_value(self, project_id: str, env_id: str, variable_name: str,
|
||||
callback: Callable[[str], None]):
|
||||
"""
|
||||
Get variable value from appropriate storage (keyring or JSON) - async.
|
||||
|
||||
This is a convenience method that handles both sensitive and non-sensitive variables.
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
env_id: Environment ID
|
||||
variable_name: Variable name
|
||||
callback: Callback called with variable value (empty string if not found)
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
# Check if sensitive
|
||||
if variable_name in p.sensitive_variables:
|
||||
# Retrieve from keyring asynchronously
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
def on_retrieve(value):
|
||||
@ -831,6 +570,7 @@ class ProjectManager:
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Retrieve from JSON synchronously
|
||||
for env in p.environments:
|
||||
if env.id == env_id:
|
||||
callback(env.variables.get(variable_name, ""))
|
||||
@ -840,12 +580,26 @@ class ProjectManager:
|
||||
|
||||
def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str,
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
"""
|
||||
Set variable value in appropriate storage (keyring or JSON) - async.
|
||||
|
||||
This is a convenience method that handles both sensitive and non-sensitive variables.
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
env_id: Environment ID
|
||||
variable_name: Variable name
|
||||
value: Value to set
|
||||
callback: Optional callback called with success boolean
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
# Check if sensitive
|
||||
if variable_name in p.sensitive_variables:
|
||||
# Store in keyring asynchronously
|
||||
for env in p.environments:
|
||||
if env.id == env_id:
|
||||
secret_manager.store_secret(
|
||||
@ -857,15 +611,18 @@ class ProjectManager:
|
||||
environment_name=env.name,
|
||||
callback=callback
|
||||
)
|
||||
# Keep empty placeholder in JSON
|
||||
env.variables[variable_name] = ""
|
||||
self._save_project(p)
|
||||
self.save_projects(projects)
|
||||
return
|
||||
else:
|
||||
# Store in JSON synchronously
|
||||
for env in p.environments:
|
||||
if env.id == env_id:
|
||||
env.variables[variable_name] = value
|
||||
break
|
||||
self._save_project(p)
|
||||
|
||||
self.save_projects(projects)
|
||||
if callback:
|
||||
callback(True)
|
||||
return
|
||||
@ -875,6 +632,17 @@ class ProjectManager:
|
||||
|
||||
def get_environment_with_secrets(self, project_id: str, env_id: str,
|
||||
callback: Callable[[Optional[Environment]], None]):
|
||||
"""
|
||||
Get an environment with all variable values (including secrets from keyring) - async.
|
||||
|
||||
This is useful for variable substitution - it returns a complete Environment
|
||||
object with all values populated from both JSON and keyring.
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
env_id: Environment ID
|
||||
callback: Callback called with Environment object (or None if not found)
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
@ -882,6 +650,7 @@ class ProjectManager:
|
||||
if p.id == project_id:
|
||||
for env in p.environments:
|
||||
if env.id == env_id:
|
||||
# Create a copy of the environment
|
||||
complete_env = Environment(
|
||||
id=env.id,
|
||||
name=env.name,
|
||||
@ -889,10 +658,12 @@ class ProjectManager:
|
||||
created_at=env.created_at
|
||||
)
|
||||
|
||||
# If no sensitive variables, return immediately
|
||||
if not p.sensitive_variables:
|
||||
callback(complete_env)
|
||||
return
|
||||
|
||||
# Fill in sensitive variable values from keyring
|
||||
def on_secrets_retrieved(secrets_dict):
|
||||
for var_name, value in secrets_dict.items():
|
||||
if value is not None:
|
||||
|
||||
@ -80,24 +80,28 @@ class RequestTabWidget(Gtk.Box):
|
||||
def _build_ui(self) -> None:
|
||||
"""Build the complete UI for this tab."""
|
||||
|
||||
# URL Input Section
|
||||
# URL Input Section - outer container (store as instance var for dynamic updates)
|
||||
self.url_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
||||
self.url_container.set_margin_start(12)
|
||||
self.url_container.set_margin_end(12)
|
||||
self.url_container.set_margin_top(12)
|
||||
self.url_container.set_margin_bottom(12)
|
||||
|
||||
# Environment Selector (left-aligned, outside clamp)
|
||||
if self.project_id:
|
||||
self._build_environment_selector_inline(self.url_container)
|
||||
# Add visual separator/spacer after environment
|
||||
self.env_separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.env_separator.set_margin_start(12)
|
||||
self.env_separator.set_margin_end(12)
|
||||
self.url_container.append(self.env_separator)
|
||||
|
||||
# URL bar (method, URL, send) - centered in clamp
|
||||
url_clamp = Adw.Clamp(maximum_size=1000)
|
||||
url_clamp.set_hexpand(True)
|
||||
self.url_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
|
||||
# Method Dropdown
|
||||
self.method_dropdown = Gtk.DropDown()
|
||||
methods = Gtk.StringList()
|
||||
for method in ["GET", "POST", "PUT", "DELETE"]:
|
||||
@ -107,12 +111,13 @@ class RequestTabWidget(Gtk.Box):
|
||||
self.method_dropdown.set_enable_search(False)
|
||||
self.url_box.append(self.method_dropdown)
|
||||
|
||||
# URL Entry
|
||||
self.url_entry = Gtk.Entry()
|
||||
self.url_entry.set_placeholder_text("Enter URL...")
|
||||
self.url_entry.set_hexpand(True)
|
||||
self.url_entry.add_css_class("url-entry")
|
||||
self.url_box.append(self.url_entry)
|
||||
|
||||
# Send Button
|
||||
self.send_button = Gtk.Button(icon_name="media-playback-start-symbolic")
|
||||
self.send_button.set_tooltip_text("Send Request")
|
||||
self.send_button.add_css_class("suggested-action")
|
||||
@ -122,89 +127,63 @@ class RequestTabWidget(Gtk.Box):
|
||||
self.url_container.append(url_clamp)
|
||||
self.append(self.url_container)
|
||||
|
||||
# Build content panels
|
||||
self.request_panel = self._create_request_panel()
|
||||
self.response_panel = self._create_response_panel()
|
||||
|
||||
# Normal layout: horizontal split pane
|
||||
self.split_pane = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
self.split_pane.set_vexpand(True)
|
||||
self.split_pane.set_position(UI_PANE_REQUEST_RESPONSE_POSITION)
|
||||
self.split_pane.set_shrink_start_child(False)
|
||||
self.split_pane.set_shrink_end_child(False)
|
||||
self.split_pane.set_resize_start_child(True)
|
||||
self.split_pane.set_resize_end_child(False)
|
||||
self.split_pane.set_start_child(self.request_panel)
|
||||
self.split_pane.set_end_child(self.response_panel)
|
||||
|
||||
# Narrow layout: single panel with Request/Response toggle
|
||||
self._build_narrow_layout()
|
||||
self._narrow_mode = False
|
||||
|
||||
self.append(self.split_pane)
|
||||
|
||||
def _create_request_panel(self) -> Gtk.Box:
|
||||
"""Create the request panel with a responsive tab switcher."""
|
||||
request_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
request_panel.set_size_request(220, -1)
|
||||
|
||||
# Responsive tab switcher (custom, so orientation can change)
|
||||
self.request_tab_switcher = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
||||
self.request_tab_switcher.set_halign(Gtk.Align.CENTER)
|
||||
self.request_tab_switcher.set_margin_top(8)
|
||||
self.request_tab_switcher.set_margin_bottom(8)
|
||||
self.request_tab_switcher.add_css_class("request-tab-switcher")
|
||||
# Horizontal Split: Request | Response
|
||||
split_pane = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
split_pane.set_vexpand(True)
|
||||
split_pane.set_position(UI_PANE_REQUEST_RESPONSE_POSITION)
|
||||
split_pane.set_shrink_start_child(False)
|
||||
split_pane.set_shrink_end_child(False)
|
||||
split_pane.set_resize_start_child(True)
|
||||
split_pane.set_resize_end_child(True)
|
||||
|
||||
# Request Panel
|
||||
request_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.request_stack = Gtk.Stack()
|
||||
self.request_stack.set_vexpand(True)
|
||||
self.request_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
||||
self.request_stack.set_transition_duration(150)
|
||||
|
||||
# Build tab pages
|
||||
request_switcher = Gtk.StackSwitcher()
|
||||
request_switcher.set_stack(self.request_stack)
|
||||
request_switcher.set_halign(Gtk.Align.CENTER)
|
||||
request_switcher.set_margin_top(8)
|
||||
request_switcher.set_margin_bottom(8)
|
||||
|
||||
request_box.append(request_switcher)
|
||||
request_box.append(self.request_stack)
|
||||
|
||||
# Headers tab
|
||||
self._build_headers_tab()
|
||||
|
||||
# Body tab
|
||||
self._build_body_tab()
|
||||
|
||||
# Scripts tab
|
||||
self._build_scripts_tab()
|
||||
|
||||
# Build linked toggle buttons for each page
|
||||
self._request_tab_buttons = {}
|
||||
first_btn = None
|
||||
for page_name, label in [("headers", "Headers"), ("body", "Body"), ("scripts", "Scripts")]:
|
||||
btn = Gtk.ToggleButton(label=label)
|
||||
btn.add_css_class("flat")
|
||||
if first_btn is None:
|
||||
btn.set_active(True)
|
||||
first_btn = btn
|
||||
else:
|
||||
btn.set_group(first_btn)
|
||||
btn.connect("toggled", self._on_request_tab_toggled, page_name)
|
||||
self.request_tab_switcher.append(btn)
|
||||
self._request_tab_buttons[page_name] = btn
|
||||
split_pane.set_start_child(request_box)
|
||||
|
||||
request_panel.append(self.request_tab_switcher)
|
||||
request_panel.append(self.request_stack)
|
||||
|
||||
# Switch switcher orientation based on available width
|
||||
request_panel.connect("notify::width", self._on_request_panel_width_changed)
|
||||
|
||||
return request_panel
|
||||
|
||||
def _create_response_panel(self) -> Gtk.Box:
|
||||
"""Create the response panel."""
|
||||
# Response Panel
|
||||
response_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
response_box.set_size_request(250, -1)
|
||||
|
||||
# Stack switcher at the top
|
||||
response_switcher = Gtk.StackSwitcher()
|
||||
response_switcher.set_halign(Gtk.Align.START)
|
||||
response_switcher.set_valign(Gtk.Align.CENTER)
|
||||
response_switcher.set_halign(Gtk.Align.CENTER)
|
||||
response_switcher.set_margin_top(8)
|
||||
response_switcher.set_margin_bottom(8)
|
||||
response_box.append(response_switcher)
|
||||
|
||||
# Create a vertical paned for response stack and result panels
|
||||
self.response_main_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.response_main_paned.set_vexpand(True)
|
||||
self.response_main_paned.set_position(UI_PANE_RESPONSE_DETAILS_POSITION)
|
||||
self.response_main_paned.set_shrink_start_child(False)
|
||||
# Don't allow results panels to shrink below their minimum size
|
||||
self.response_main_paned.set_shrink_end_child(False)
|
||||
self.response_main_paned.set_resize_start_child(True)
|
||||
self.response_main_paned.set_resize_end_child(True)
|
||||
|
||||
# Response Stack
|
||||
self.response_stack = Gtk.Stack()
|
||||
self.response_stack.set_vexpand(True)
|
||||
self.response_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
||||
@ -213,6 +192,7 @@ class RequestTabWidget(Gtk.Box):
|
||||
response_switcher.set_stack(self.response_stack)
|
||||
self.response_main_paned.set_start_child(self.response_stack)
|
||||
|
||||
# Response headers
|
||||
headers_scroll = Gtk.ScrolledWindow()
|
||||
headers_scroll.set_vexpand(True)
|
||||
self.response_headers_textview = Gtk.TextView()
|
||||
@ -225,6 +205,7 @@ class RequestTabWidget(Gtk.Box):
|
||||
headers_scroll.set_child(self.response_headers_textview)
|
||||
self.response_stack.add_titled(headers_scroll, "headers", "Headers")
|
||||
|
||||
# Response body
|
||||
body_scroll = Gtk.ScrolledWindow()
|
||||
body_scroll.set_vexpand(True)
|
||||
self.response_body_sourceview = GtkSource.View()
|
||||
@ -236,8 +217,10 @@ class RequestTabWidget(Gtk.Box):
|
||||
self.response_body_sourceview.set_top_margin(12)
|
||||
self.response_body_sourceview.set_bottom_margin(12)
|
||||
|
||||
# Set up theme
|
||||
self._setup_sourceview_theme()
|
||||
|
||||
# Set font
|
||||
css_provider = Gtk.CssProvider()
|
||||
css_provider.load_from_data(b"""
|
||||
textview {
|
||||
@ -254,19 +237,24 @@ class RequestTabWidget(Gtk.Box):
|
||||
body_scroll.set_child(self.response_body_sourceview)
|
||||
self.response_stack.add_titled(body_scroll, "body", "Body")
|
||||
|
||||
# Create a second paned for preprocessing and script results
|
||||
self.results_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.results_paned.set_vexpand(True)
|
||||
self.results_paned.set_position(UI_PANE_RESULTS_PANEL_POSITION)
|
||||
self.results_paned.set_visible(False)
|
||||
self.results_paned.set_visible(False) # Initially hidden until scripts produce output
|
||||
# Don't allow children to shrink below their minimum size
|
||||
self.results_paned.set_shrink_start_child(False)
|
||||
self.results_paned.set_shrink_end_child(False)
|
||||
self.results_paned.set_resize_start_child(True)
|
||||
self.results_paned.set_resize_end_child(True)
|
||||
|
||||
# Preprocessing Results Panel (resizable)
|
||||
self.preprocessing_results_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
self.preprocessing_results_container.set_visible(False)
|
||||
self.preprocessing_results_container.set_visible(False) # Initially hidden
|
||||
# Set minimum height to ensure header is always visible (header ~40px + content min 60px)
|
||||
self.preprocessing_results_container.set_size_request(-1, 100)
|
||||
|
||||
# Header
|
||||
preprocessing_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
preprocessing_header.set_margin_start(12)
|
||||
preprocessing_header.set_margin_end(12)
|
||||
@ -278,16 +266,19 @@ class RequestTabWidget(Gtk.Box):
|
||||
preprocessing_label.set_halign(Gtk.Align.START)
|
||||
preprocessing_header.append(preprocessing_label)
|
||||
|
||||
# Spacer
|
||||
preprocessing_spacer = Gtk.Box()
|
||||
preprocessing_spacer.set_hexpand(True)
|
||||
preprocessing_header.append(preprocessing_spacer)
|
||||
|
||||
# Status icon
|
||||
self.preprocessing_status_icon = Gtk.Image()
|
||||
self.preprocessing_status_icon.set_from_icon_name("object-select-symbolic")
|
||||
preprocessing_header.append(self.preprocessing_status_icon)
|
||||
|
||||
self.preprocessing_results_container.append(preprocessing_header)
|
||||
|
||||
# Output text view (scrollable, resizable)
|
||||
self.preprocessing_output_scroll = Gtk.ScrolledWindow()
|
||||
self.preprocessing_output_scroll.set_vexpand(True)
|
||||
self.preprocessing_output_scroll.set_min_content_height(60)
|
||||
@ -309,10 +300,13 @@ class RequestTabWidget(Gtk.Box):
|
||||
|
||||
self.results_paned.set_start_child(self.preprocessing_results_container)
|
||||
|
||||
# Script Results Panel (resizable)
|
||||
self.script_results_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
self.script_results_container.set_visible(False)
|
||||
self.script_results_container.set_visible(False) # Initially hidden
|
||||
# Set minimum height to ensure header is always visible (header ~40px + content min 60px)
|
||||
self.script_results_container.set_size_request(-1, 100)
|
||||
|
||||
# Header
|
||||
results_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
results_header.set_margin_start(12)
|
||||
results_header.set_margin_end(12)
|
||||
@ -324,16 +318,19 @@ class RequestTabWidget(Gtk.Box):
|
||||
results_label.set_halign(Gtk.Align.START)
|
||||
results_header.append(results_label)
|
||||
|
||||
# Spacer
|
||||
results_spacer = Gtk.Box()
|
||||
results_spacer.set_hexpand(True)
|
||||
results_header.append(results_spacer)
|
||||
|
||||
# Status icon
|
||||
self.script_status_icon = Gtk.Image()
|
||||
self.script_status_icon.set_from_icon_name("object-select-symbolic")
|
||||
results_header.append(self.script_status_icon)
|
||||
|
||||
self.script_results_container.append(results_header)
|
||||
|
||||
# Output text view (scrollable, resizable)
|
||||
self.script_output_scroll = Gtk.ScrolledWindow()
|
||||
self.script_output_scroll.set_vexpand(True)
|
||||
self.script_output_scroll.set_min_content_height(60)
|
||||
@ -354,23 +351,20 @@ class RequestTabWidget(Gtk.Box):
|
||||
self.script_results_container.append(self.script_output_scroll)
|
||||
|
||||
self.results_paned.set_end_child(self.script_results_container)
|
||||
|
||||
# Set the results paned as the end child of the main response paned
|
||||
self.response_main_paned.set_end_child(self.results_paned)
|
||||
|
||||
# Add the main paned to response_box
|
||||
response_box.append(self.response_main_paned)
|
||||
|
||||
bottom_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
||||
bottom_bar.set_margin_start(6)
|
||||
bottom_bar.set_margin_end(12)
|
||||
bottom_bar.set_margin_top(4)
|
||||
bottom_bar.set_margin_bottom(4)
|
||||
|
||||
bottom_bar.append(response_switcher)
|
||||
|
||||
spacer = Gtk.Box()
|
||||
spacer.set_hexpand(True)
|
||||
bottom_bar.append(spacer)
|
||||
|
||||
# Status Bar
|
||||
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
status_box.set_margin_start(12)
|
||||
status_box.set_margin_end(12)
|
||||
status_box.set_margin_top(6)
|
||||
status_box.set_margin_bottom(6)
|
||||
|
||||
self.status_label = Gtk.Label(label="Ready")
|
||||
self.status_label.add_css_class("heading")
|
||||
status_box.append(self.status_label)
|
||||
@ -381,95 +375,16 @@ class RequestTabWidget(Gtk.Box):
|
||||
self.size_label = Gtk.Label(label="")
|
||||
status_box.append(self.size_label)
|
||||
|
||||
bottom_bar.append(status_box)
|
||||
response_box.append(bottom_bar)
|
||||
# Spacer
|
||||
spacer = Gtk.Box()
|
||||
spacer.set_hexpand(True)
|
||||
status_box.append(spacer)
|
||||
|
||||
return response_box
|
||||
response_box.append(status_box)
|
||||
|
||||
def _build_narrow_layout(self) -> None:
|
||||
"""Build the narrow single-panel layout with Request/Response toggle."""
|
||||
self.narrow_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.narrow_box.set_vexpand(True)
|
||||
split_pane.set_end_child(response_box)
|
||||
|
||||
toggle_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
||||
toggle_bar.set_halign(Gtk.Align.CENTER)
|
||||
toggle_bar.set_margin_top(6)
|
||||
toggle_bar.set_margin_bottom(6)
|
||||
toggle_bar.add_css_class("linked")
|
||||
|
||||
self.narrow_request_btn = Gtk.ToggleButton(label="Request")
|
||||
self.narrow_request_btn.set_active(True)
|
||||
|
||||
self.narrow_response_btn = Gtk.ToggleButton(label="Response")
|
||||
self.narrow_response_btn.set_group(self.narrow_request_btn)
|
||||
|
||||
self.narrow_request_btn.connect("toggled", self._on_narrow_panel_toggled, "request")
|
||||
self.narrow_response_btn.connect("toggled", self._on_narrow_panel_toggled, "response")
|
||||
|
||||
toggle_bar.append(self.narrow_request_btn)
|
||||
toggle_bar.append(self.narrow_response_btn)
|
||||
self.narrow_box.append(toggle_bar)
|
||||
|
||||
self.narrow_stack = Gtk.Stack()
|
||||
self.narrow_stack.set_vexpand(True)
|
||||
self.narrow_stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
|
||||
self.narrow_stack.set_transition_duration(200)
|
||||
self.narrow_box.append(self.narrow_stack)
|
||||
|
||||
def set_narrow_mode(self, narrow: bool) -> None:
|
||||
"""Switch between split-pane (normal) and single-panel (narrow) layout."""
|
||||
if narrow == self._narrow_mode:
|
||||
return
|
||||
self._narrow_mode = narrow
|
||||
|
||||
if narrow:
|
||||
self.split_pane.set_start_child(None)
|
||||
self.split_pane.set_end_child(None)
|
||||
self.remove(self.split_pane)
|
||||
|
||||
self.narrow_stack.add_named(self.request_panel, "request")
|
||||
self.narrow_stack.add_named(self.response_panel, "response")
|
||||
|
||||
# Sync stack visible child to match current button state
|
||||
# (button state persists across wide↔narrow transitions)
|
||||
if self.narrow_response_btn.get_active():
|
||||
self.narrow_stack.set_visible_child_name("response")
|
||||
else:
|
||||
self.narrow_stack.set_visible_child_name("request")
|
||||
|
||||
self.append(self.narrow_box)
|
||||
else:
|
||||
self.narrow_stack.remove(self.request_panel)
|
||||
self.narrow_stack.remove(self.response_panel)
|
||||
self.remove(self.narrow_box)
|
||||
|
||||
self.split_pane.set_start_child(self.request_panel)
|
||||
self.split_pane.set_end_child(self.response_panel)
|
||||
self.append(self.split_pane)
|
||||
|
||||
def _on_request_tab_toggled(self, button: Gtk.ToggleButton, page_name: str) -> None:
|
||||
"""Handle request tab button toggle."""
|
||||
if button.get_active():
|
||||
self.request_stack.set_visible_child_name(page_name)
|
||||
|
||||
def _on_request_panel_width_changed(self, widget, pspec) -> None:
|
||||
"""Switch request tab switcher to vertical when panel is narrow."""
|
||||
width = widget.get_width()
|
||||
if width > 0 and width < 260:
|
||||
if self.request_tab_switcher.get_orientation() != Gtk.Orientation.VERTICAL:
|
||||
self.request_tab_switcher.set_orientation(Gtk.Orientation.VERTICAL)
|
||||
self.request_tab_switcher.set_halign(Gtk.Align.START)
|
||||
self.request_tab_switcher.set_margin_start(8)
|
||||
elif width >= 260:
|
||||
if self.request_tab_switcher.get_orientation() != Gtk.Orientation.HORIZONTAL:
|
||||
self.request_tab_switcher.set_orientation(Gtk.Orientation.HORIZONTAL)
|
||||
self.request_tab_switcher.set_halign(Gtk.Align.CENTER)
|
||||
self.request_tab_switcher.set_margin_start(0)
|
||||
|
||||
def _on_narrow_panel_toggled(self, button: Gtk.ToggleButton, panel_name: str) -> None:
|
||||
"""Handle narrow mode Request/Response toggle."""
|
||||
if button.get_active():
|
||||
self.narrow_stack.set_visible_child_name(panel_name)
|
||||
self.append(split_pane)
|
||||
|
||||
def _build_headers_tab(self) -> None:
|
||||
"""Build the headers tab."""
|
||||
@ -1009,14 +924,6 @@ class RequestTabWidget(Gtk.Box):
|
||||
language = self._get_language_from_content_type(content_type)
|
||||
source_buffer.set_language(language)
|
||||
|
||||
# Switch to body tab
|
||||
self.response_stack.set_visible_child_name("body")
|
||||
|
||||
# In narrow mode, automatically show the response panel
|
||||
if self._narrow_mode:
|
||||
self.narrow_stack.set_visible_child_name("response")
|
||||
self.narrow_response_btn.set_active(True)
|
||||
|
||||
def display_error(self, error: str) -> None:
|
||||
"""Display error in this tab's UI."""
|
||||
self.status_label.set_text("Error")
|
||||
@ -1032,10 +939,6 @@ class RequestTabWidget(Gtk.Box):
|
||||
source_buffer.set_text(error)
|
||||
source_buffer.set_language(None)
|
||||
|
||||
if self._narrow_mode:
|
||||
self.narrow_stack.set_visible_child_name("response")
|
||||
self.narrow_response_btn.set_active(True)
|
||||
|
||||
def display_script_results(self, script_result):
|
||||
"""Display script execution results."""
|
||||
from .script_executor import ScriptResult
|
||||
@ -1247,18 +1150,6 @@ class RequestTabWidget(Gtk.Box):
|
||||
|
||||
return ""
|
||||
|
||||
def _is_json_content_type(self, content_type):
|
||||
"""Check if the content type is JSON or a JSON-based format."""
|
||||
return ('application/json' in content_type
|
||||
or 'text/json' in content_type
|
||||
or '+json' in content_type)
|
||||
|
||||
def _is_xml_content_type(self, content_type):
|
||||
"""Check if the content type is XML or an XML-based format."""
|
||||
return ('application/xml' in content_type
|
||||
or 'text/xml' in content_type
|
||||
or '+xml' in content_type)
|
||||
|
||||
def _get_language_from_content_type(self, content_type):
|
||||
"""Get GtkSourceView language ID from content type."""
|
||||
if not content_type:
|
||||
@ -1266,9 +1157,9 @@ class RequestTabWidget(Gtk.Box):
|
||||
|
||||
language_manager = GtkSource.LanguageManager.get_default()
|
||||
|
||||
if self._is_json_content_type(content_type):
|
||||
if 'application/json' in content_type or 'text/json' in content_type:
|
||||
return language_manager.get_language('json')
|
||||
elif self._is_xml_content_type(content_type):
|
||||
elif 'application/xml' in content_type or 'text/xml' in content_type:
|
||||
return language_manager.get_language('xml')
|
||||
elif 'text/html' in content_type:
|
||||
return language_manager.get_language('html')
|
||||
@ -1285,10 +1176,10 @@ class RequestTabWidget(Gtk.Box):
|
||||
return body
|
||||
|
||||
try:
|
||||
if self._is_json_content_type(content_type):
|
||||
if 'application/json' in content_type or 'text/json' in content_type:
|
||||
parsed = json.loads(body)
|
||||
return json.dumps(parsed, indent=2, ensure_ascii=False)
|
||||
elif self._is_xml_content_type(content_type):
|
||||
elif 'application/xml' in content_type or 'text/xml' in content_type:
|
||||
dom = xml.dom.minidom.parseString(body)
|
||||
return dom.toprettyxml(indent=" ")
|
||||
except Exception as e:
|
||||
@ -1349,7 +1240,6 @@ class RequestTabWidget(Gtk.Box):
|
||||
# Set flag to indicate we're programmatically changing the dropdown
|
||||
# This prevents the signal handler from triggering indicator updates during initialization
|
||||
self._is_programmatically_changing_environment = True
|
||||
old_env_id = self.selected_environment_id
|
||||
|
||||
try:
|
||||
# Build string list with "None" + environment names
|
||||
@ -1371,8 +1261,6 @@ class RequestTabWidget(Gtk.Box):
|
||||
index = self.environment_ids.index(self.selected_environment_id)
|
||||
self.environment_dropdown.set_selected(index)
|
||||
except ValueError:
|
||||
# Previously selected environment no longer exists
|
||||
self.selected_environment_id = None
|
||||
self.environment_dropdown.set_selected(0) # Default to "None"
|
||||
else:
|
||||
self.environment_dropdown.set_selected(0) # Default to "None"
|
||||
@ -1381,9 +1269,8 @@ class RequestTabWidget(Gtk.Box):
|
||||
# Always clear the flag
|
||||
self._is_programmatically_changing_environment = False
|
||||
|
||||
# If the effective environment changed (e.g. was deleted), update indicators
|
||||
if self.selected_environment_id != old_env_id:
|
||||
self._update_variable_indicators()
|
||||
# Note: Don't update indicators here as the request might not be loaded yet
|
||||
# Indicators will be updated when environment changes or request is loaded
|
||||
|
||||
def _show_environment_selector(self):
|
||||
"""Show environment selector (create if it doesn't exist)."""
|
||||
|
||||
@ -8,9 +8,6 @@
|
||||
<file preprocess="xml-stripblanks">environments-dialog.ui</file>
|
||||
<file preprocess="xml-stripblanks">export-dialog.ui</file>
|
||||
<file alias="icons/export-symbolic.svg">../data/icons/hicolor/symbolic/apps/export-symbolic.svg</file>
|
||||
<file alias="icons/wsdl-import-symbolic.svg">../data/icons/hicolor/symbolic/apps/wsdl-import-symbolic.svg</file>
|
||||
<file alias="icons/papyrus-vertical-symbolic.svg">../data/icons/hicolor/symbolic/apps/papyrus-vertical-symbolic.svg</file>
|
||||
<file alias="icons/openapi-import-symbolic.svg">../data/icons/hicolor/symbolic/apps/openapi-import-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">widgets/header-row.ui</file>
|
||||
<file preprocess="xml-stripblanks">widgets/history-item.ui</file>
|
||||
<file preprocess="xml-stripblanks">widgets/project-item.ui</file>
|
||||
|
||||
@ -102,9 +102,6 @@ let consoleOutput = [];
|
||||
const console = {{
|
||||
log: function(...args) {{
|
||||
consoleOutput.push(args.map(arg => String(arg)).join(' '));
|
||||
}},
|
||||
error: function(...args) {{
|
||||
consoleOutput.push(args.map(arg => String(arg)).join(' '));
|
||||
}}
|
||||
}};
|
||||
|
||||
@ -228,9 +225,6 @@ let consoleOutput = [];
|
||||
const console = {{
|
||||
log: function(...args) {{
|
||||
consoleOutput.push(args.map(arg => String(arg)).join(' '));
|
||||
}},
|
||||
error: function(...args) {{
|
||||
consoleOutput.push(args.map(arg => String(arg)).join(' '));
|
||||
}}
|
||||
}};
|
||||
|
||||
|
||||
@ -23,18 +23,6 @@
|
||||
<property name="halign">center</property>
|
||||
<property name="spacing">4</property>
|
||||
|
||||
<child>
|
||||
<object class="GtkButton" id="copy_button">
|
||||
<property name="icon-name">edit-copy-symbolic</property>
|
||||
<property name="tooltip-text">Copy environment</property>
|
||||
<signal name="clicked" handler="on_copy_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
<class name="circular"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkButton" id="edit_button">
|
||||
<property name="icon-name">document-properties-symbolic</property>
|
||||
|
||||
@ -28,14 +28,12 @@ class EnvironmentColumnHeader(Gtk.Box):
|
||||
__gtype_name__ = 'EnvironmentColumnHeader'
|
||||
|
||||
name_label = Gtk.Template.Child()
|
||||
copy_button = Gtk.Template.Child()
|
||||
edit_button = Gtk.Template.Child()
|
||||
delete_button = Gtk.Template.Child()
|
||||
|
||||
__gsignals__ = {
|
||||
'edit-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
'copy-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
}
|
||||
|
||||
def __init__(self, environment):
|
||||
@ -43,11 +41,6 @@ class EnvironmentColumnHeader(Gtk.Box):
|
||||
self.environment = environment
|
||||
self.name_label.set_text(environment.name)
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_copy_clicked(self, button):
|
||||
"""Handle copy button click."""
|
||||
self.emit('copy-requested')
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_edit_clicked(self, button):
|
||||
"""Handle edit button click."""
|
||||
|
||||
@ -35,7 +35,6 @@ class EnvironmentHeaderRow(Gtk.Box):
|
||||
__gsignals__ = {
|
||||
'environment-edit-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||
'environment-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||
'environment-copy-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||
}
|
||||
|
||||
def __init__(self, environments, size_group):
|
||||
@ -63,7 +62,6 @@ class EnvironmentHeaderRow(Gtk.Box):
|
||||
header = EnvironmentColumnHeader(env)
|
||||
header.connect('edit-requested', self._on_edit_requested, env)
|
||||
header.connect('delete-requested', self._on_delete_requested, env)
|
||||
header.connect('copy-requested', self._on_copy_requested, env)
|
||||
|
||||
# Add to size group for alignment with data rows
|
||||
if self.size_group:
|
||||
@ -80,10 +78,6 @@ class EnvironmentHeaderRow(Gtk.Box):
|
||||
"""Handle delete request from column header."""
|
||||
self.emit('environment-delete-requested', environment)
|
||||
|
||||
def _on_copy_requested(self, widget, environment):
|
||||
"""Handle copy request from column header."""
|
||||
self.emit('environment-copy-requested', environment)
|
||||
|
||||
def refresh(self, environments):
|
||||
"""Refresh with updated environments list."""
|
||||
self.environments = environments
|
||||
|
||||
@ -4,16 +4,6 @@
|
||||
<requires lib="libadwaita" version="1.0"/>
|
||||
|
||||
<menu id="project_menu">
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label">Move Up</attribute>
|
||||
<attribute name="action">project.move-up</attribute>
|
||||
</item>
|
||||
<item>
|
||||
<attribute name="label">Move Down</attribute>
|
||||
<attribute name="action">project.move-down</attribute>
|
||||
</item>
|
||||
</section>
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label">Manage Environments</attribute>
|
||||
@ -82,8 +72,7 @@
|
||||
|
||||
<child>
|
||||
<object class="GtkListBox" id="requests_listbox">
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="margin-start">12</property>
|
||||
<style>
|
||||
<class name="navigation-sidebar"/>
|
||||
</style>
|
||||
|
||||
@ -41,17 +41,11 @@ class ProjectItem(Gtk.Box):
|
||||
'request-load-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||
'request-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||
'manage-environments-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
'move-up-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
'move-down-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
'request-move-up-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||
'request-move-down-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||
'request-move-to-project-requested': (GObject.SIGNAL_RUN_FIRST, None, (object, GObject.TYPE_STRING)),
|
||||
}
|
||||
|
||||
def __init__(self, project, other_projects=None):
|
||||
def __init__(self, project):
|
||||
super().__init__()
|
||||
self.project = project
|
||||
self.other_projects = other_projects or []
|
||||
self.expanded = False
|
||||
self._setup_actions()
|
||||
self._populate()
|
||||
@ -65,14 +59,6 @@ class ProjectItem(Gtk.Box):
|
||||
"""Setup action group for menu."""
|
||||
actions = Gio.SimpleActionGroup.new()
|
||||
|
||||
self._move_up_action = Gio.SimpleAction.new("move-up", None)
|
||||
self._move_up_action.connect("activate", lambda *_: self.emit('move-up-requested'))
|
||||
actions.add_action(self._move_up_action)
|
||||
|
||||
self._move_down_action = Gio.SimpleAction.new("move-down", None)
|
||||
self._move_down_action.connect("activate", lambda *_: self.emit('move-down-requested'))
|
||||
actions.add_action(self._move_down_action)
|
||||
|
||||
action = Gio.SimpleAction.new("manage-environments", None)
|
||||
action.connect("activate", lambda *_: self.emit('manage-environments-requested'))
|
||||
actions.add_action(action)
|
||||
@ -96,25 +82,16 @@ class ProjectItem(Gtk.Box):
|
||||
self.name_label.set_text(self.project.name)
|
||||
self.project_icon.set_from_icon_name(self.project.icon)
|
||||
|
||||
# Clear and add requests
|
||||
while child := self.requests_listbox.get_first_child():
|
||||
self.requests_listbox.remove(child)
|
||||
|
||||
total = len(self.project.requests)
|
||||
for i, saved_request in enumerate(self.project.requests):
|
||||
for saved_request in self.project.requests:
|
||||
item = RequestItem(saved_request)
|
||||
item.set_can_move_up(i > 0)
|
||||
item.set_can_move_down(i < total - 1)
|
||||
item.set_other_projects(self.other_projects)
|
||||
item.connect('load-requested',
|
||||
lambda w, sr=saved_request: self.emit('request-load-requested', sr))
|
||||
item.connect('delete-requested',
|
||||
lambda w, sr=saved_request: self.emit('request-delete-requested', sr))
|
||||
item.connect('move-up-requested',
|
||||
lambda w, sr=saved_request: self.emit('request-move-up-requested', sr))
|
||||
item.connect('move-down-requested',
|
||||
lambda w, sr=saved_request: self.emit('request-move-down-requested', sr))
|
||||
item.connect('move-to-project-requested',
|
||||
lambda w, pid, sr=saved_request: self.emit('request-move-to-project-requested', sr, pid))
|
||||
self.requests_listbox.append(item)
|
||||
|
||||
def _on_header_clicked(self, gesture, n_press, x, y):
|
||||
@ -122,14 +99,6 @@ class ProjectItem(Gtk.Box):
|
||||
self.expanded = not self.expanded
|
||||
self.requests_revealer.set_reveal_child(self.expanded)
|
||||
|
||||
def set_can_move_up(self, can: bool):
|
||||
self._move_up_action.set_enabled(can)
|
||||
|
||||
def set_can_move_down(self, can: bool):
|
||||
self._move_down_action.set_enabled(can)
|
||||
|
||||
def refresh(self, other_projects=None):
|
||||
"""Refresh from project data, optionally updating the other_projects list."""
|
||||
if other_projects is not None:
|
||||
self.other_projects = other_projects
|
||||
def refresh(self):
|
||||
"""Refresh from project data."""
|
||||
self._populate()
|
||||
|
||||
@ -11,9 +11,10 @@
|
||||
|
||||
<child>
|
||||
<object class="GtkLabel" id="method_label">
|
||||
<property name="xalign">0.5</property>
|
||||
<property name="width-chars">6</property>
|
||||
<style>
|
||||
<class name="method-chip"/>
|
||||
<class name="caption"/>
|
||||
<class name="dim-label"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
@ -27,12 +28,12 @@
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkMenuButton" id="menu_button">
|
||||
<property name="icon-name">view-more-symbolic</property>
|
||||
<property name="tooltip-text">Options</property>
|
||||
<object class="GtkButton" id="delete_button">
|
||||
<property name="icon-name">edit-delete-symbolic</property>
|
||||
<property name="tooltip-text">Delete request</property>
|
||||
<signal name="clicked" handler="on_delete_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
<class name="request-menu-button"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
@ -18,15 +18,7 @@
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from gi.repository import Gtk, GObject, Gio, GLib
|
||||
|
||||
_METHOD_CSS = {
|
||||
'GET': 'accent',
|
||||
'POST': 'success',
|
||||
'PUT': 'warning',
|
||||
'PATCH': 'warning',
|
||||
'DELETE': 'error',
|
||||
}
|
||||
from gi.repository import Gtk, GObject
|
||||
|
||||
|
||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/request-item.ui')
|
||||
@ -37,115 +29,28 @@ class RequestItem(Gtk.Box):
|
||||
|
||||
method_label = Gtk.Template.Child()
|
||||
name_label = Gtk.Template.Child()
|
||||
menu_button = Gtk.Template.Child()
|
||||
|
||||
__gsignals__ = {
|
||||
'load-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
'move-up-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
'move-down-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
'move-to-project-requested': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_STRING,)),
|
||||
}
|
||||
|
||||
def __init__(self, saved_request):
|
||||
super().__init__()
|
||||
self.saved_request = saved_request
|
||||
method = saved_request.request.method
|
||||
self.method_label.set_text(method)
|
||||
self.method_label.add_css_class(_METHOD_CSS.get(method, 'dim-label'))
|
||||
display_name = saved_request.name
|
||||
for prefix in _METHOD_CSS:
|
||||
if display_name.upper().startswith(prefix + ' '):
|
||||
display_name = display_name[len(prefix) + 1:]
|
||||
break
|
||||
self.name_label.set_text(display_name)
|
||||
self.method_label.set_text(saved_request.request.method)
|
||||
self.name_label.set_text(saved_request.name)
|
||||
|
||||
self._setup_menu()
|
||||
|
||||
# Click gesture for loading
|
||||
# Add click gesture for loading
|
||||
gesture = Gtk.GestureClick.new()
|
||||
gesture.connect('released', self._on_clicked)
|
||||
self.add_controller(gesture)
|
||||
|
||||
# Show/hide menu button on hover
|
||||
motion = Gtk.EventControllerMotion.new()
|
||||
motion.connect('enter', self._on_hover_enter)
|
||||
motion.connect('leave', self._on_hover_leave)
|
||||
self.add_controller(motion)
|
||||
|
||||
def _setup_menu(self):
|
||||
actions = Gio.SimpleActionGroup.new()
|
||||
|
||||
self._move_up_action = Gio.SimpleAction.new("move-up", None)
|
||||
self._move_up_action.connect("activate", lambda *_: self.emit('move-up-requested'))
|
||||
actions.add_action(self._move_up_action)
|
||||
|
||||
self._move_down_action = Gio.SimpleAction.new("move-down", None)
|
||||
self._move_down_action.connect("activate", lambda *_: self.emit('move-down-requested'))
|
||||
actions.add_action(self._move_down_action)
|
||||
|
||||
move_to = Gio.SimpleAction.new("move-to-project", GLib.VariantType.new("s"))
|
||||
move_to.connect("activate", self._on_move_to_project_activated)
|
||||
actions.add_action(move_to)
|
||||
|
||||
delete = Gio.SimpleAction.new("delete", None)
|
||||
delete.connect("activate", lambda *_: self.emit('delete-requested'))
|
||||
actions.add_action(delete)
|
||||
|
||||
self.insert_action_group("request", actions)
|
||||
|
||||
# Build menu model
|
||||
menu = Gio.Menu()
|
||||
|
||||
reorder_section = Gio.Menu()
|
||||
reorder_section.append("Move Up", "request.move-up")
|
||||
reorder_section.append("Move Down", "request.move-down")
|
||||
menu.append_section(None, reorder_section)
|
||||
|
||||
self._move_to_submenu = Gio.Menu()
|
||||
move_section = Gio.Menu()
|
||||
move_section.append_submenu("Move to Project", self._move_to_submenu)
|
||||
menu.append_section(None, move_section)
|
||||
|
||||
delete_section = Gio.Menu()
|
||||
delete_section.append("Delete", "request.delete")
|
||||
menu.append_section(None, delete_section)
|
||||
|
||||
self.menu_button.set_menu_model(menu)
|
||||
|
||||
# Hidden by default; shown on hover via EventControllerMotion.
|
||||
# When the popover closes, hide the button again.
|
||||
self.menu_button.set_opacity(0.0)
|
||||
popover = self.menu_button.get_popover()
|
||||
if popover:
|
||||
popover.connect('closed', lambda p: self.menu_button.set_opacity(0.0))
|
||||
|
||||
def _on_move_to_project_activated(self, action, param):
|
||||
self.emit('move-to-project-requested', param.get_string())
|
||||
|
||||
def _on_clicked(self, gesture, n_press, x, y):
|
||||
"""Load request on row click (but not if the menu button was clicked)."""
|
||||
alloc = self.menu_button.get_allocation()
|
||||
if alloc.x <= x <= alloc.x + alloc.width and alloc.y <= y <= alloc.y + alloc.height:
|
||||
return
|
||||
"""Handle click to load request."""
|
||||
self.emit('load-requested')
|
||||
|
||||
def _on_hover_enter(self, controller, x, y):
|
||||
self.menu_button.set_opacity(1.0)
|
||||
|
||||
def _on_hover_leave(self, controller):
|
||||
popover = self.menu_button.get_popover()
|
||||
if not (popover and popover.get_visible()):
|
||||
self.menu_button.set_opacity(0.0)
|
||||
|
||||
def set_can_move_up(self, can: bool):
|
||||
self._move_up_action.set_enabled(can)
|
||||
|
||||
def set_can_move_down(self, can: bool):
|
||||
self._move_down_action.set_enabled(can)
|
||||
|
||||
def set_other_projects(self, projects):
|
||||
"""Rebuild the 'Move to Project' submenu from the given project list."""
|
||||
self._move_to_submenu.remove_all()
|
||||
for p in projects:
|
||||
self._move_to_submenu.append(p.name, f"request.move-to-project::{p.id}")
|
||||
@Gtk.Template.Callback()
|
||||
def on_delete_clicked(self, button):
|
||||
"""Handle delete button click."""
|
||||
self.emit('delete-requested')
|
||||
|
||||
197
src/window.py
@ -19,7 +19,7 @@
|
||||
|
||||
import gi
|
||||
gi.require_version('GtkSource', '5')
|
||||
from gi.repository import Adw, Gtk, GLib, Gio, GtkSource, GObject
|
||||
from gi.repository import Adw, Gtk, GLib, Gio, GtkSource
|
||||
from typing import Dict, Optional
|
||||
import logging
|
||||
from .models import HttpRequest, HttpResponse, HistoryEntry, RequestTab
|
||||
@ -36,9 +36,6 @@ from .project_manager import ProjectManager
|
||||
from .tab_manager import TabManager
|
||||
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
|
||||
@ -63,12 +60,12 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
tab_view = Gtk.Template.Child()
|
||||
tab_bar = Gtk.Template.Child()
|
||||
|
||||
# Split view
|
||||
split_view = Gtk.Template.Child()
|
||||
sidebar_toggle_button = Gtk.Template.Child()
|
||||
# Panes
|
||||
main_pane = Gtk.Template.Child()
|
||||
|
||||
# Sidebar widgets
|
||||
projects_listbox = Gtk.Template.Child()
|
||||
add_project_button = Gtk.Template.Child()
|
||||
|
||||
# History (hidden but kept for compatibility)
|
||||
history_listbox = Gtk.Template.Child()
|
||||
@ -100,17 +97,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# Setup custom CSS
|
||||
self._setup_custom_css()
|
||||
|
||||
# Bind sidebar toggle button to split view (bidirectional)
|
||||
self.split_view.bind_property(
|
||||
'show-sidebar',
|
||||
self.sidebar_toggle_button,
|
||||
'active',
|
||||
GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE
|
||||
)
|
||||
|
||||
# Switch tab widgets to narrow layout when sidebar collapses
|
||||
self.split_view.connect("notify::collapsed", self._on_split_view_collapsed_changed)
|
||||
|
||||
# Setup UI
|
||||
self._setup_tab_system()
|
||||
self._load_projects()
|
||||
@ -122,12 +108,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# Create first tab
|
||||
self._create_new_tab()
|
||||
|
||||
def _on_split_view_collapsed_changed(self, split_view, pspec) -> None:
|
||||
"""Switch all tab widgets to narrow or normal layout based on sidebar state."""
|
||||
is_narrow = split_view.get_collapsed()
|
||||
for widget in self.page_to_widget.values():
|
||||
widget.set_narrow_mode(is_narrow)
|
||||
|
||||
def _on_close_request(self, window) -> bool:
|
||||
"""Handle window close request - warn if there are unsaved changes."""
|
||||
# Check if any tabs have unsaved changes
|
||||
@ -176,15 +156,15 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
/* AdwToolbarView handles header bar heights automatically */
|
||||
/* Just add minimal custom styling for other elements */
|
||||
|
||||
/* Request tab switcher (Headers/Body/Scripts) */
|
||||
.request-tab-switcher button {
|
||||
/* Stack switchers styling (Headers/Body tabs) */
|
||||
stackswitcher button {
|
||||
padding: 6px 16px;
|
||||
min-height: 32px;
|
||||
border-radius: 6px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.request-tab-switcher button:checked {
|
||||
stackswitcher button:checked {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@ -218,23 +198,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
color: @accent_color;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* URL entry can shrink to near-zero so the Send button stays visible */
|
||||
entry.url-entry {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Method chips in sidebar request list */
|
||||
.method-chip {
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-size: 0.72em;
|
||||
font-weight: 700;
|
||||
}
|
||||
.method-chip.accent { background-color: alpha(@accent_bg_color, 0.18); }
|
||||
.method-chip.success { background-color: alpha(@success_bg_color, 0.18); }
|
||||
.method-chip.warning { background-color: alpha(@warning_bg_color, 0.18); }
|
||||
.method-chip.error { background-color: alpha(@error_bg_color, 0.18); }
|
||||
""")
|
||||
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
@ -409,14 +372,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# Populate environment dropdown if project_id is set
|
||||
if project_id and hasattr(widget, '_populate_environment_dropdown'):
|
||||
widget._populate_environment_dropdown()
|
||||
# Re-run indicator update now that project_manager is injected;
|
||||
# the earlier call in __init__ ran with project_manager=None and
|
||||
# incorrectly marked all variables as undefined.
|
||||
widget._update_variable_indicators()
|
||||
|
||||
# Apply narrow mode if sidebar is currently collapsed
|
||||
if self.split_view.get_collapsed():
|
||||
widget.set_narrow_mode(True)
|
||||
|
||||
# Connect to send button
|
||||
widget.send_button.connect("clicked", lambda btn: self._on_send_clicked(widget))
|
||||
@ -532,22 +487,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
self.add_action(action)
|
||||
self.get_application().set_accels_for_action("win.send-request", ["<Control>Return"])
|
||||
|
||||
action = Gio.SimpleAction.new("add-project", None)
|
||||
action.connect("activate", lambda a, p: self.on_add_project_clicked(None))
|
||||
self.add_action(action)
|
||||
|
||||
action = Gio.SimpleAction.new("import-openapi", None)
|
||||
action.connect("activate", lambda a, p: self.on_import_openapi_clicked(None))
|
||||
self.add_action(action)
|
||||
|
||||
action = Gio.SimpleAction.new("import-wsdl", None)
|
||||
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
|
||||
@ -952,7 +891,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
return False, f"Request name is too long (max {REQUEST_NAME_MAX_LENGTH} characters)"
|
||||
|
||||
# Check for invalid characters (file system unsafe characters)
|
||||
invalid_chars = ['\\', '*', '?', '"', '<', '>', '|', '\0']
|
||||
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0']
|
||||
for char in invalid_chars:
|
||||
if char in name:
|
||||
return False, f"Request name cannot contain '{char}'"
|
||||
@ -961,47 +900,23 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
|
||||
def _load_projects(self) -> None:
|
||||
"""Load and display projects."""
|
||||
# Remember which projects are currently expanded.
|
||||
# GtkListBox wraps each child in a GtkListBoxRow, so we call get_child()
|
||||
# on the row to reach the actual ProjectItem widget.
|
||||
expanded_ids = set()
|
||||
row = self.projects_listbox.get_first_child()
|
||||
while row:
|
||||
project_item = row.get_child()
|
||||
if isinstance(project_item, ProjectItem) and project_item.expanded:
|
||||
expanded_ids.add(project_item.project.id)
|
||||
row = row.get_next_sibling()
|
||||
|
||||
# Clear existing
|
||||
while child := self.projects_listbox.get_first_child():
|
||||
self.projects_listbox.remove(child)
|
||||
|
||||
# Load and populate
|
||||
projects = self.project_manager.load_projects()
|
||||
total = len(projects)
|
||||
for i, project in enumerate(projects):
|
||||
other_projects = [p for p in projects if p.id != project.id]
|
||||
item = ProjectItem(project, other_projects)
|
||||
item.set_can_move_up(i > 0)
|
||||
item.set_can_move_down(i < total - 1)
|
||||
for project in projects:
|
||||
item = ProjectItem(project)
|
||||
item.connect('edit-requested', self._on_project_edit, project)
|
||||
item.connect('delete-requested', self._on_project_delete, project)
|
||||
item.connect('add-request-requested', self._on_add_to_project, project)
|
||||
item.connect('request-load-requested', self._on_load_request)
|
||||
item.connect('request-delete-requested', self._on_delete_request, project)
|
||||
item.connect('manage-environments-requested', self._on_manage_environments, project)
|
||||
item.connect('move-up-requested', self._on_project_move_up, project)
|
||||
item.connect('move-down-requested', self._on_project_move_down, project)
|
||||
item.connect('request-move-up-requested', self._on_request_move_up, project)
|
||||
item.connect('request-move-down-requested', self._on_request_move_down, project)
|
||||
item.connect('request-move-to-project-requested', self._on_request_move_to_project, project)
|
||||
if project.id in expanded_ids:
|
||||
item.expanded = True
|
||||
item.requests_revealer.set_transition_duration(0)
|
||||
item.requests_revealer.set_reveal_child(True)
|
||||
GLib.idle_add(lambda r=item.requests_revealer: (r.set_transition_duration(250), False)[1])
|
||||
self.projects_listbox.append(item)
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_add_project_clicked(self, button):
|
||||
"""Show dialog to create project."""
|
||||
dialog = Adw.AlertDialog()
|
||||
@ -1129,41 +1044,10 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
def on_environments_updated(dlg):
|
||||
# Reload projects to reflect changes
|
||||
self._load_projects()
|
||||
# Refresh environment dropdowns in all open tabs for this project
|
||||
for page, tab_widget in self.page_to_widget.items():
|
||||
if tab_widget.project_id == project.id:
|
||||
tab_widget._populate_environment_dropdown()
|
||||
|
||||
dialog.connect('environments-updated', on_environments_updated)
|
||||
dialog.present(self)
|
||||
|
||||
def _on_project_move_up(self, widget, project):
|
||||
self.project_manager.reorder_project(project.id, -1)
|
||||
self._load_projects()
|
||||
|
||||
def _on_project_move_down(self, widget, project):
|
||||
self.project_manager.reorder_project(project.id, +1)
|
||||
self._load_projects()
|
||||
|
||||
def _on_request_move_up(self, widget, saved_request, project):
|
||||
self.project_manager.reorder_request(project.id, saved_request.id, -1)
|
||||
self._load_projects()
|
||||
|
||||
def _on_request_move_down(self, widget, saved_request, project):
|
||||
self.project_manager.reorder_request(project.id, saved_request.id, +1)
|
||||
self._load_projects()
|
||||
|
||||
def _on_request_move_to_project(self, widget, saved_request, target_project_id, source_project):
|
||||
self.project_manager.move_request_to_project(
|
||||
saved_request.id, source_project.id, target_project_id
|
||||
)
|
||||
self._load_projects()
|
||||
# Find target project name for toast
|
||||
projects = self.project_manager.load_projects()
|
||||
target = next((p for p in projects if p.id == target_project_id), None)
|
||||
if target:
|
||||
self._show_toast(f"Moved to '{target.name}'")
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_save_request_clicked(self, button):
|
||||
"""Save current request to a project."""
|
||||
@ -1299,8 +1183,9 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
return
|
||||
|
||||
# Apply variable substitution if environment is selected
|
||||
def show_export(env):
|
||||
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)
|
||||
@ -1316,11 +1201,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
dialog = ExportDialog(substituted_request)
|
||||
dialog.present(self)
|
||||
|
||||
if widget.selected_environment_id:
|
||||
widget.get_selected_environment(show_export)
|
||||
else:
|
||||
show_export(None)
|
||||
|
||||
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()
|
||||
@ -1518,57 +1398,6 @@ 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()
|
||||
dialog = WsdlImportDialog(self.project_manager, projects)
|
||||
dialog.connect('import-completed', self._on_wsdl_import_completed)
|
||||
dialog.present(self)
|
||||
|
||||
def on_import_openapi_clicked(self, button):
|
||||
"""Open the OpenAPI import dialog."""
|
||||
projects = self.project_manager.load_projects()
|
||||
dialog = OpenApiImportDialog(self.project_manager, projects)
|
||||
dialog.connect('import-completed', self._on_openapi_import_completed)
|
||||
dialog.present(self)
|
||||
|
||||
def _on_openapi_import_completed(self, dialog, project_id: str):
|
||||
"""Refresh sidebar after a successful OpenAPI 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)} operation(s) into '{p.name}'")
|
||||
break
|
||||
|
||||
def _on_wsdl_import_completed(self, dialog, project_id: str):
|
||||
"""Refresh sidebar after a successful WSDL import."""
|
||||
self._load_projects()
|
||||
projects = self.project_manager.load_projects()
|
||||
count = 0
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
count = len(p.requests)
|
||||
name = p.name
|
||||
break
|
||||
self._show_toast(f"Imported {count} operation(s) into '{name}'")
|
||||
|
||||
def _on_delete_request(self, widget, saved_request, project):
|
||||
"""Delete saved request with confirmation."""
|
||||
dialog = Adw.AlertDialog()
|
||||
|
||||
@ -1,355 +0,0 @@
|
||||
# wsdl_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 <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import gi
|
||||
gi.require_version('Soup', '3.0')
|
||||
from gi.repository import Adw, Gtk, GObject, GLib, Soup
|
||||
from typing import List
|
||||
|
||||
from .wsdl_importer import parse_wsdl, build_http_request, WsdlParseResult, WsdlOperation
|
||||
|
||||
|
||||
class WsdlImportDialog(Adw.Dialog):
|
||||
"""Two-step dialog: fetch WSDL URL → select operations → import."""
|
||||
|
||||
__gtype_name__ = 'WsdlImportDialog'
|
||||
|
||||
__gsignals__ = {
|
||||
'import-completed': (GObject.SIGNAL_RUN_FIRST, None, (str,)),
|
||||
}
|
||||
|
||||
def __init__(self, project_manager, existing_projects):
|
||||
super().__init__()
|
||||
self.set_title("Import from WSDL")
|
||||
self.set_content_width(560)
|
||||
self.set_content_height(540)
|
||||
|
||||
self._pm = project_manager
|
||||
self._existing_projects = list(existing_projects)
|
||||
self._wsdl_result: WsdlParseResult | None = None
|
||||
self._op_checkboxes: List[tuple[WsdlOperation, Gtk.CheckButton]] = []
|
||||
self._soup = Soup.Session.new()
|
||||
|
||||
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_url_page(), "url")
|
||||
self._stack.add_named(self._make_ops_page(), "ops")
|
||||
tv.set_content(self._stack)
|
||||
|
||||
ab = Gtk.ActionBar()
|
||||
|
||||
self._back_btn = Gtk.Button(label="Back")
|
||||
self._back_btn.connect("clicked", lambda _: self._show_url_page())
|
||||
ab.pack_start(self._back_btn)
|
||||
|
||||
self._fetch_btn = Gtk.Button(label="Fetch WSDL")
|
||||
self._fetch_btn.add_css_class("suggested-action")
|
||||
self._fetch_btn.connect("clicked", self._on_fetch)
|
||||
ab.pack_end(self._fetch_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_url_page()
|
||||
|
||||
def _make_url_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("network-server-symbolic")
|
||||
icon.set_pixel_size(64)
|
||||
icon.add_css_class("dim-label")
|
||||
box.append(icon)
|
||||
|
||||
title = Gtk.Label(label="Import from WSDL")
|
||||
title.add_css_class("title-2")
|
||||
box.append(title)
|
||||
|
||||
subtitle = Gtk.Label(label="Enter the URL of a WSDL 1.1 or 2.0 document to discover SOAP operations")
|
||||
subtitle.add_css_class("dim-label")
|
||||
subtitle.set_wrap(True)
|
||||
subtitle.set_justify(Gtk.Justification.CENTER)
|
||||
box.append(subtitle)
|
||||
|
||||
grp = Adw.PreferencesGroup()
|
||||
self._url_row = Adw.EntryRow()
|
||||
self._url_row.set_title("WSDL URL")
|
||||
self._url_row.set_input_purpose(Gtk.InputPurpose.URL)
|
||||
self._url_row.connect("entry-activated", lambda _: self._on_fetch(None))
|
||||
grp.add(self._url_row)
|
||||
box.append(grp)
|
||||
|
||||
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)
|
||||
|
||||
self._spinner = Gtk.Spinner()
|
||||
self._spinner.set_size_request(32, 32)
|
||||
self._spinner.set_halign(Gtk.Align.CENTER)
|
||||
self._spinner.set_visible(False)
|
||||
box.append(self._spinner)
|
||||
|
||||
return box
|
||||
|
||||
def _make_ops_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)
|
||||
|
||||
# --- Endpoint row (Name removed per UX feedback) ---
|
||||
svc_grp = Adw.PreferencesGroup()
|
||||
svc_grp.set_title("Service")
|
||||
self._svc_url_row = Adw.ActionRow()
|
||||
self._svc_url_row.set_title("Endpoint")
|
||||
svc_grp.add(self._svc_url_row)
|
||||
outer.append(svc_grp)
|
||||
|
||||
# --- Operations header ---
|
||||
ops_hdr = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
ops_hdr.set_margin_top(2)
|
||||
ops_lbl = Gtk.Label(label="Operations")
|
||||
ops_lbl.add_css_class("title-4")
|
||||
ops_lbl.set_hexpand(True)
|
||||
ops_lbl.set_xalign(0)
|
||||
ops_hdr.append(ops_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)
|
||||
ops_hdr.append(self._sel_all_btn)
|
||||
outer.append(ops_hdr)
|
||||
|
||||
# --- Scrollable operations list ---
|
||||
scroll = Gtk.ScrolledWindow()
|
||||
scroll.set_vexpand(True)
|
||||
scroll.set_min_content_height(80)
|
||||
self._ops_list = Gtk.ListBox()
|
||||
self._ops_list.add_css_class("boxed-list")
|
||||
self._ops_list.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||
scroll.set_child(self._ops_list)
|
||||
outer.append(scroll)
|
||||
|
||||
# --- Import target (compact: 1 row per option with suffix widget) ---
|
||||
tgt_grp = Adw.PreferencesGroup()
|
||||
tgt_grp.set_title("Import to")
|
||||
tgt_grp.set_margin_top(2)
|
||||
|
||||
# Row: [radio] Create new project [entry: name]
|
||||
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:
|
||||
# Row: [radio] Add to existing project [dropdown]
|
||||
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_url_page(self):
|
||||
self._stack.set_visible_child_name("url")
|
||||
self._back_btn.set_visible(False)
|
||||
self._fetch_btn.set_visible(True)
|
||||
self._import_btn.set_visible(False)
|
||||
|
||||
def _show_ops_page(self):
|
||||
self._stack.set_visible_child_name("ops")
|
||||
self._back_btn.set_visible(True)
|
||||
self._fetch_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._op_checkboxes)
|
||||
new_state = not all_on
|
||||
for _, cb in self._op_checkboxes:
|
||||
cb.set_active(new_state)
|
||||
self._sel_all_btn.set_label("Deselect All" if new_state else "Select All")
|
||||
|
||||
def _on_fetch(self, _btn):
|
||||
url = self._url_row.get_text().strip()
|
||||
if not url:
|
||||
self._set_err("Please enter a WSDL URL")
|
||||
return
|
||||
if not url.startswith(('http://', 'https://')):
|
||||
self._set_err("URL must start with http:// or https://")
|
||||
return
|
||||
|
||||
self._err_label.set_visible(False)
|
||||
self._spinner.set_visible(True)
|
||||
self._spinner.start()
|
||||
self._fetch_btn.set_sensitive(False)
|
||||
|
||||
try:
|
||||
msg = Soup.Message.new("GET", url)
|
||||
except Exception as e:
|
||||
self._fetch_failed(f"Invalid URL: {e}")
|
||||
return
|
||||
|
||||
msg.get_request_headers().append("Accept", "text/xml, application/xml, */*")
|
||||
self._soup.send_and_read_async(msg, GLib.PRIORITY_DEFAULT, None,
|
||||
self._on_downloaded, msg)
|
||||
|
||||
def _on_downloaded(self, session, async_result, msg):
|
||||
self._spinner.stop()
|
||||
self._spinner.set_visible(False)
|
||||
self._fetch_btn.set_sensitive(True)
|
||||
|
||||
try:
|
||||
bytes_data = session.send_and_read_finish(async_result)
|
||||
except GLib.Error as e:
|
||||
self._set_err(f"Download failed: {e.message}")
|
||||
return
|
||||
|
||||
status = msg.get_status()
|
||||
if not (200 <= status < 300):
|
||||
self._set_err(f"Server returned HTTP {status}")
|
||||
return
|
||||
|
||||
xml = bytes_data.get_data().decode('utf-8', errors='replace')
|
||||
parsed = parse_wsdl(xml)
|
||||
|
||||
if parsed.error:
|
||||
self._set_err(parsed.error)
|
||||
return
|
||||
|
||||
self._wsdl_result = parsed
|
||||
self._populate_ops_page(parsed)
|
||||
self._show_ops_page()
|
||||
|
||||
def _on_import(self, _btn):
|
||||
if not self._wsdl_result:
|
||||
return
|
||||
|
||||
selected = [op for op, cb in self._op_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 op in selected:
|
||||
http_req = build_http_request(op)
|
||||
self._pm.add_request(project_id, op.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 _fetch_failed(self, msg: str):
|
||||
self._spinner.stop()
|
||||
self._spinner.set_visible(False)
|
||||
self._fetch_btn.set_sensitive(True)
|
||||
self._set_err(msg)
|
||||
|
||||
def _populate_ops_page(self, result: WsdlParseResult):
|
||||
self._svc_url_row.set_subtitle(result.endpoint_url or "Not specified")
|
||||
|
||||
while child := self._ops_list.get_first_child():
|
||||
self._ops_list.remove(child)
|
||||
self._op_checkboxes.clear()
|
||||
|
||||
# Sort alphabetically
|
||||
for op in sorted(result.operations, key=lambda o: o.name.lower()):
|
||||
row = Adw.ActionRow()
|
||||
row.set_title(op.name)
|
||||
if op.soap_action:
|
||||
row.set_subtitle(f"SOAPAction: {op.soap_action}")
|
||||
cb = Gtk.CheckButton()
|
||||
cb.set_active(True)
|
||||
row.add_prefix(cb)
|
||||
row.set_activatable_widget(cb)
|
||||
self._ops_list.append(row)
|
||||
self._op_checkboxes.append((op, cb))
|
||||
|
||||
# Pre-fill project name; all ops checked → "Deselect All"
|
||||
self._new_name_entry.set_text(result.service_name)
|
||||
self._sel_all_btn.set_label("Deselect All")
|
||||
@ -1,702 +0,0 @@
|
||||
# wsdl_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 <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
# WSDL 1.1
|
||||
_WSDL = 'http://schemas.xmlsoap.org/wsdl/'
|
||||
_SOAP11 = 'http://schemas.xmlsoap.org/wsdl/soap/'
|
||||
_SOAP12 = 'http://schemas.xmlsoap.org/wsdl/soap12/'
|
||||
_ENV11 = 'http://schemas.xmlsoap.org/soap/envelope/'
|
||||
_ENV12 = 'http://www.w3.org/2003/05/soap-envelope'
|
||||
|
||||
# WSDL 2.0
|
||||
_WSDL2 = 'http://www.w3.org/ns/wsdl'
|
||||
_WSDL2_SOAP = 'http://www.w3.org/ns/wsdl/soap'
|
||||
|
||||
# XML Schema
|
||||
_XS = 'http://www.w3.org/2001/XMLSchema'
|
||||
|
||||
# XSD simple-type → human hint
|
||||
_XS_HINTS: Dict[str, str] = {
|
||||
'string': 'string', 'normalizedString': 'string', 'token': 'string',
|
||||
'int': 'int', 'integer': 'int', 'nonNegativeInteger': 'int',
|
||||
'positiveInteger': 'int', 'negativeInteger': 'int',
|
||||
'short': 'int', 'byte': 'int', 'unsignedByte': 'int',
|
||||
'unsignedInt': 'int', 'unsignedShort': 'int',
|
||||
'long': 'long', 'unsignedLong': 'long',
|
||||
'boolean': 'boolean',
|
||||
'float': 'float', 'double': 'float', 'decimal': 'decimal',
|
||||
'dateTime': 'datetime', 'date': 'date', 'time': 'time',
|
||||
'base64Binary': 'base64', 'hexBinary': 'hex',
|
||||
'anyType': 'any', 'anySimpleType': 'any',
|
||||
'duration': 'duration', 'guid': 'guid',
|
||||
}
|
||||
|
||||
# Well-known namespace → preferred short prefix
|
||||
_KNOWN_NS_PREFIXES: Dict[str, str] = {
|
||||
'http://schemas.datacontract.org': 'dc',
|
||||
'http://schemas.microsoft.com/2003/10/Serialization/': 'ser',
|
||||
'http://www.w3.org/2001/XMLSchema-instance': 'xsi',
|
||||
'http://www.w3.org/2001/XMLSchema': 'xs',
|
||||
}
|
||||
|
||||
|
||||
def _q(ns: str, tag: str) -> str:
|
||||
return f'{{{ns}}}{tag}'
|
||||
|
||||
|
||||
def _local(tag: str) -> str:
|
||||
return tag.split('}')[-1] if '}' in tag else tag
|
||||
|
||||
|
||||
def _hint(xs_local: str, optional: bool) -> str:
|
||||
base = _XS_HINTS.get(xs_local, xs_local)
|
||||
return f'[{base}{"?" if optional else ""}]'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal parameter tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class _Param:
|
||||
"""Tree node for building a typed SOAP body element."""
|
||||
name: str
|
||||
ns: str = '' # element namespace; '' = inherit op namespace
|
||||
hint: Optional[str] = None # leaf text like '[string]'; None = container node
|
||||
children: list = field(default_factory=list) # list[_Param]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class WsdlOperation:
|
||||
name: str
|
||||
soap_action: str
|
||||
endpoint_url: str
|
||||
soap_version: str # '1.1' or '1.2'
|
||||
target_namespace: str
|
||||
body_template: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class WsdlParseResult:
|
||||
service_name: str
|
||||
endpoint_url: str
|
||||
operations: List[WsdlOperation] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_wsdl(xml_content: str) -> WsdlParseResult:
|
||||
"""Auto-detect WSDL 1.1 / 2.0 and return service info + operations."""
|
||||
try:
|
||||
root = ET.fromstring(xml_content)
|
||||
except ET.ParseError as e:
|
||||
return WsdlParseResult(service_name='', endpoint_url='', error=f'Invalid XML: {e}')
|
||||
|
||||
local = _local(root.tag)
|
||||
if local == 'definitions':
|
||||
return _parse_wsdl11(root)
|
||||
elif local == 'description':
|
||||
return _parse_wsdl20(root)
|
||||
else:
|
||||
return WsdlParseResult(
|
||||
service_name='', endpoint_url='',
|
||||
error='Document is not a valid WSDL 1.1 (definitions) or WSDL 2.0 (description) file'
|
||||
)
|
||||
|
||||
|
||||
def build_http_request(operation: WsdlOperation):
|
||||
"""Convert a WsdlOperation into an HttpRequest ready to send."""
|
||||
from .models import HttpRequest
|
||||
|
||||
headers: Dict[str, str] = {}
|
||||
if operation.soap_version == '1.2':
|
||||
ct = 'application/soap+xml; charset=utf-8'
|
||||
if operation.soap_action:
|
||||
ct += f'; action="{operation.soap_action}"'
|
||||
headers['Content-Type'] = ct
|
||||
else:
|
||||
headers['Content-Type'] = 'text/xml; charset=utf-8'
|
||||
if operation.soap_action:
|
||||
headers['SOAPAction'] = f'"{operation.soap_action}"'
|
||||
|
||||
return HttpRequest(
|
||||
method='POST',
|
||||
url=operation.endpoint_url,
|
||||
headers=headers,
|
||||
body=operation.body_template,
|
||||
syntax='XML',
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WSDL 1.1 parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_wsdl11(root: ET.Element) -> WsdlParseResult:
|
||||
target_ns = root.get('targetNamespace', '')
|
||||
service_name = root.get('name', '') or 'WSDL Service'
|
||||
|
||||
# Service name + endpoint URL
|
||||
service_el = root.find(_q(_WSDL, 'service'))
|
||||
if service_el is None:
|
||||
service_el = root.find('service')
|
||||
if service_el is not None and service_el.get('name'):
|
||||
service_name = service_el.get('name')
|
||||
|
||||
endpoint_url = ''
|
||||
default_soap_ver = '1.1'
|
||||
if service_el is not None:
|
||||
for port in service_el:
|
||||
addr11 = port.find(_q(_SOAP11, 'address'))
|
||||
if addr11 is not None:
|
||||
endpoint_url = addr11.get('location', '')
|
||||
default_soap_ver = '1.1'
|
||||
break
|
||||
addr12 = port.find(_q(_SOAP12, 'address'))
|
||||
if addr12 is not None:
|
||||
endpoint_url = addr12.get('location', '')
|
||||
default_soap_ver = '1.2'
|
||||
break
|
||||
|
||||
# Collect (soap_action, soap_version) per operation from bindings
|
||||
op_info: Dict[str, Tuple[str, str]] = {}
|
||||
for binding in root.iter():
|
||||
if _local(binding.tag) != 'binding':
|
||||
continue
|
||||
is11 = binding.find(_q(_SOAP11, 'binding')) is not None
|
||||
is12 = binding.find(_q(_SOAP12, 'binding')) is not None
|
||||
if not is11 and not is12:
|
||||
continue
|
||||
bv = '1.2' if is12 else '1.1'
|
||||
for op in binding:
|
||||
if _local(op.tag) != 'operation':
|
||||
continue
|
||||
op_name = _local(op.get('name', ''))
|
||||
if not op_name:
|
||||
continue
|
||||
soap_op = op.find(_q(_SOAP11, 'operation'))
|
||||
if soap_op is None:
|
||||
soap_op = op.find(_q(_SOAP12, 'operation'))
|
||||
action = soap_op.get('soapAction', '') if soap_op is not None else ''
|
||||
if op_name not in op_info:
|
||||
op_info[op_name] = (action, bv)
|
||||
|
||||
# Fallback: portType when no SOAP binding found
|
||||
if not op_info:
|
||||
for pt in root.iter():
|
||||
if _local(pt.tag) != 'portType':
|
||||
continue
|
||||
for op in pt:
|
||||
if _local(op.tag) == 'operation':
|
||||
op_name = op.get('name', '')
|
||||
if op_name and op_name not in op_info:
|
||||
op_info[op_name] = ('', default_soap_ver)
|
||||
|
||||
if not op_info:
|
||||
return WsdlParseResult(
|
||||
service_name=service_name, endpoint_url=endpoint_url,
|
||||
error='No SOAP operations found in this WSDL document'
|
||||
)
|
||||
|
||||
# Build schema maps: name → (element, namespace)
|
||||
elem_map, type_map = _build_schema_maps(root, _WSDL)
|
||||
|
||||
operations = []
|
||||
for op_name, (action, ver) in op_info.items():
|
||||
params = _extract_params_wsdl11(root, op_name, elem_map, type_map)
|
||||
body = _build_envelope(op_name, target_ns, ver, params)
|
||||
operations.append(WsdlOperation(
|
||||
name=op_name, soap_action=action, endpoint_url=endpoint_url,
|
||||
soap_version=ver, target_namespace=target_ns, body_template=body,
|
||||
))
|
||||
|
||||
return WsdlParseResult(
|
||||
service_name=service_name or 'WSDL Service',
|
||||
endpoint_url=endpoint_url,
|
||||
operations=operations,
|
||||
)
|
||||
|
||||
|
||||
def _extract_params_wsdl11(root, op_name: str, elem_map, type_map) -> list:
|
||||
"""Return list[_Param] for the input of a WSDL 1.1 operation."""
|
||||
input_elem_name = _find_input_elem_wsdl11(root, op_name)
|
||||
|
||||
# Naming-convention fallback
|
||||
if not input_elem_name:
|
||||
for candidate in [op_name, op_name + 'Request', op_name + 'Input']:
|
||||
if candidate in elem_map:
|
||||
input_elem_name = candidate
|
||||
break
|
||||
|
||||
if not input_elem_name or input_elem_name not in elem_map:
|
||||
return []
|
||||
|
||||
elem, elem_ns = elem_map[input_elem_name]
|
||||
return _parse_element(elem, elem_ns, elem_map, type_map)
|
||||
|
||||
|
||||
def _find_input_elem_wsdl11(root, op_name: str) -> Optional[str]:
|
||||
"""Walk portType → input message → part/@element and return local element name."""
|
||||
for pt in root.iter():
|
||||
if _local(pt.tag) != 'portType':
|
||||
continue
|
||||
for op in pt:
|
||||
if _local(op.tag) != 'operation' or op.get('name') != op_name:
|
||||
continue
|
||||
inp = op.find(_q(_WSDL, 'input'))
|
||||
if inp is None:
|
||||
inp = op.find('input')
|
||||
if inp is None:
|
||||
return None
|
||||
msg_local = (inp.get('message') or '').split(':')[-1]
|
||||
for msg in root.iter():
|
||||
if _local(msg.tag) != 'message' or msg.get('name') != msg_local:
|
||||
continue
|
||||
for part in msg:
|
||||
if _local(part.tag) != 'part':
|
||||
continue
|
||||
elem_ref = part.get('element', '')
|
||||
if elem_ref:
|
||||
return elem_ref.split(':')[-1]
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WSDL 2.0 parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_wsdl20(root: ET.Element) -> WsdlParseResult:
|
||||
target_ns = root.get('targetNamespace', '')
|
||||
service_name = root.get('name', '') or 'WSDL Service'
|
||||
|
||||
# Service element
|
||||
service_el = root.find(_q(_WSDL2, 'service'))
|
||||
if service_el is None:
|
||||
service_el = root.find('service')
|
||||
if service_el is not None and service_el.get('name'):
|
||||
service_name = service_el.get('name')
|
||||
|
||||
# Endpoint address
|
||||
endpoint_url = ''
|
||||
if service_el is not None:
|
||||
for ep in service_el:
|
||||
if _local(ep.tag) == 'endpoint':
|
||||
addr = ep.get('address', '')
|
||||
if addr:
|
||||
endpoint_url = addr
|
||||
break
|
||||
|
||||
# Determine which binding the service uses
|
||||
service_binding_local = None
|
||||
if service_el is not None:
|
||||
for ep in service_el:
|
||||
if _local(ep.tag) == 'endpoint':
|
||||
b_ref = ep.get('binding', '')
|
||||
service_binding_local = b_ref.split(':')[-1]
|
||||
break
|
||||
|
||||
# Collect (soap_action) per operation from SOAP bindings
|
||||
binding_ops: Dict[str, Dict[str, str]] = {}
|
||||
for binding in root.iter():
|
||||
if _local(binding.tag) != 'binding':
|
||||
continue
|
||||
b_name = binding.get('name', '')
|
||||
b_type = binding.get('type', '')
|
||||
is_soap = (_WSDL2_SOAP in b_type or 'soap' in b_type.lower())
|
||||
if not is_soap:
|
||||
is_soap = any(
|
||||
_WSDL2_SOAP in (child.tag or '') or 'soap' in _local(child.tag).lower()
|
||||
for child in binding
|
||||
)
|
||||
if not is_soap:
|
||||
continue
|
||||
|
||||
ops_actions: Dict[str, str] = {}
|
||||
for child in binding:
|
||||
if _local(child.tag) != 'operation':
|
||||
continue
|
||||
ref = (child.get('ref') or '').split(':')[-1]
|
||||
action = (
|
||||
child.get(_q(_WSDL2_SOAP, 'action'))
|
||||
or child.get('action')
|
||||
or ''
|
||||
)
|
||||
if ref:
|
||||
ops_actions[ref] = action
|
||||
binding_ops[b_name] = ops_actions
|
||||
|
||||
# Choose the right binding's operations
|
||||
op_info: Dict[str, str] = {} # op_name → soap_action
|
||||
if service_binding_local and service_binding_local in binding_ops:
|
||||
op_info = binding_ops[service_binding_local]
|
||||
else:
|
||||
for ops in binding_ops.values():
|
||||
for op_name, action in ops.items():
|
||||
if op_name not in op_info:
|
||||
op_info[op_name] = action
|
||||
|
||||
# Fallback: collect from interface operations
|
||||
if not op_info:
|
||||
for iface in root.iter():
|
||||
if _local(iface.tag) != 'interface':
|
||||
continue
|
||||
for op in iface:
|
||||
if _local(op.tag) == 'operation':
|
||||
op_name = op.get('name', '')
|
||||
if op_name and op_name not in op_info:
|
||||
op_info[op_name] = ''
|
||||
|
||||
if not op_info:
|
||||
return WsdlParseResult(
|
||||
service_name=service_name, endpoint_url=endpoint_url,
|
||||
error='No SOAP operations found in WSDL 2.0 document'
|
||||
)
|
||||
|
||||
elem_map, type_map = _build_schema_maps(root, _WSDL2)
|
||||
|
||||
operations = []
|
||||
for op_name, action in op_info.items():
|
||||
params = _extract_params_wsdl20(root, op_name, elem_map, type_map)
|
||||
body = _build_envelope(op_name, target_ns, '1.2', params)
|
||||
operations.append(WsdlOperation(
|
||||
name=op_name, soap_action=action, endpoint_url=endpoint_url,
|
||||
soap_version='1.2', target_namespace=target_ns, body_template=body,
|
||||
))
|
||||
|
||||
return WsdlParseResult(
|
||||
service_name=service_name or 'WSDL Service',
|
||||
endpoint_url=endpoint_url,
|
||||
operations=operations,
|
||||
)
|
||||
|
||||
|
||||
def _extract_params_wsdl20(root, op_name: str, elem_map, type_map) -> list:
|
||||
"""Return list[_Param] for the input of a WSDL 2.0 operation."""
|
||||
for iface in root.iter():
|
||||
if _local(iface.tag) != 'interface':
|
||||
continue
|
||||
for op in iface:
|
||||
if _local(op.tag) != 'operation' or op.get('name') != op_name:
|
||||
continue
|
||||
for child in op:
|
||||
if _local(child.tag) == 'input':
|
||||
elem_ref = (child.get('element') or '').split(':')[-1]
|
||||
if elem_ref in elem_map:
|
||||
elem, elem_ns = elem_map[elem_ref]
|
||||
return _parse_element(elem, elem_ns, elem_map, type_map)
|
||||
|
||||
# Naming-convention fallback
|
||||
for candidate in [op_name, op_name + 'Request', op_name + 'Input']:
|
||||
if candidate in elem_map:
|
||||
elem, elem_ns = elem_map[candidate]
|
||||
return _parse_element(elem, elem_ns, elem_map, type_map)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# XSD schema helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_schema_maps(root: ET.Element, wsdl_ns: str) -> Tuple[Dict, Dict]:
|
||||
"""Build {name: (element, ns)} and {name: (complexType, ns)} maps from <types>.
|
||||
|
||||
Each map value is a (ET.Element, targetNamespace) tuple so callers can
|
||||
track which schema namespace every element / type belongs to.
|
||||
"""
|
||||
elem_map: Dict[str, Tuple[ET.Element, str]] = {}
|
||||
type_map: Dict[str, Tuple[ET.Element, str]] = {}
|
||||
|
||||
types_el = root.find(_q(wsdl_ns, 'types'))
|
||||
if types_el is None:
|
||||
types_el = root.find('types')
|
||||
if types_el is None:
|
||||
return elem_map, type_map
|
||||
|
||||
for node in types_el.iter():
|
||||
if _local(node.tag) != 'schema':
|
||||
continue
|
||||
schema_ns = node.get('targetNamespace', '')
|
||||
for child in node:
|
||||
name = child.get('name', '')
|
||||
if not name:
|
||||
continue
|
||||
loc = _local(child.tag)
|
||||
if loc == 'element':
|
||||
elem_map[name] = (child, schema_ns)
|
||||
elif loc == 'complexType':
|
||||
type_map[name] = (child, schema_ns)
|
||||
|
||||
return elem_map, type_map
|
||||
|
||||
|
||||
def _parse_element(elem: ET.Element, elem_ns: str, elem_map: Dict, type_map: Dict,
|
||||
depth: int = 0) -> list:
|
||||
"""Extract list[_Param] children from an xs:element."""
|
||||
if depth > 4:
|
||||
return []
|
||||
|
||||
# Inline complexType
|
||||
ct = elem.find(_q(_XS, 'complexType'))
|
||||
if ct is not None:
|
||||
return _parse_complex_type(ct, elem_ns, elem_map, type_map, depth)
|
||||
|
||||
# Named type reference
|
||||
type_ref = elem.get('type', '')
|
||||
type_local = type_ref.split(':')[-1] if type_ref else ''
|
||||
if type_local:
|
||||
if type_local in _XS_HINTS:
|
||||
return [] # simple scalar — not a parameter container
|
||||
entry = type_map.get(type_local)
|
||||
if entry is not None:
|
||||
ct, type_ns = entry
|
||||
return _parse_complex_type(ct, type_ns, elem_map, type_map, depth)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _parse_complex_type(ct: ET.Element, ns: str, elem_map: Dict, type_map: Dict,
|
||||
depth: int = 0) -> list:
|
||||
"""Extract list[_Param] from an xs:complexType."""
|
||||
params: list = []
|
||||
|
||||
# xs:complexContent / xs:extension (inheritance)
|
||||
cc = ct.find(_q(_XS, 'complexContent'))
|
||||
if cc is not None:
|
||||
ext = cc.find(_q(_XS, 'extension'))
|
||||
if ext is not None:
|
||||
base_local = (ext.get('base') or '').split(':')[-1]
|
||||
entry = type_map.get(base_local)
|
||||
if entry is not None:
|
||||
base_ct, base_ns = entry
|
||||
params.extend(_parse_complex_type(base_ct, base_ns, elem_map, type_map, depth + 1))
|
||||
for tag in ('sequence', 'all', 'choice'):
|
||||
seq = ext.find(_q(_XS, tag))
|
||||
if seq is not None:
|
||||
params.extend(_parse_sequence(seq, ns, elem_map, type_map, depth))
|
||||
break
|
||||
return params
|
||||
|
||||
# Direct sequence / all / choice
|
||||
for tag in ('sequence', 'all', 'choice'):
|
||||
seq = ct.find(_q(_XS, tag))
|
||||
if seq is not None:
|
||||
params.extend(_parse_sequence(seq, ns, elem_map, type_map, depth))
|
||||
break
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def _parse_sequence(seq: ET.Element, ns: str, elem_map: Dict, type_map: Dict,
|
||||
depth: int = 0) -> list:
|
||||
"""Extract list[_Param] from xs:sequence / xs:all / xs:choice.
|
||||
|
||||
Complex child elements are kept as container _Param nodes (preserving the
|
||||
wrapper element), rather than being flattened into the parent list.
|
||||
Child elements of a referenced type carry that type's namespace.
|
||||
"""
|
||||
params: list = []
|
||||
choice_optional = _local(seq.tag) == 'choice'
|
||||
|
||||
for child in seq:
|
||||
loc = _local(child.tag)
|
||||
|
||||
if loc == 'element':
|
||||
name = child.get('name', '')
|
||||
if not name:
|
||||
ref_local = (child.get('ref') or '').split(':')[-1]
|
||||
if ref_local:
|
||||
name = ref_local
|
||||
|
||||
if not name:
|
||||
continue
|
||||
|
||||
optional = choice_optional or child.get('minOccurs', '1') == '0'
|
||||
type_ref = child.get('type', '')
|
||||
type_local = type_ref.split(':')[-1] if type_ref else ''
|
||||
|
||||
if type_local and type_local in _XS_HINTS:
|
||||
params.append(_Param(name=name, ns=ns, hint=_hint(type_local, optional)))
|
||||
else:
|
||||
inline_ct = child.find(_q(_XS, 'complexType'))
|
||||
if inline_ct is not None and depth < 3:
|
||||
sub = _parse_complex_type(inline_ct, ns, elem_map, type_map, depth + 1)
|
||||
if sub:
|
||||
params.append(_Param(name=name, ns=ns, children=sub))
|
||||
else:
|
||||
params.append(_Param(name=name, ns=ns, hint=_hint('anyType', optional)))
|
||||
elif type_local and type_local in type_map and depth < 3:
|
||||
child_ct, child_ns = type_map[type_local]
|
||||
sub = _parse_complex_type(child_ct, child_ns, elem_map, type_map, depth + 1)
|
||||
if sub:
|
||||
# Keep wrapper element; children carry child_ns namespace
|
||||
params.append(_Param(name=name, ns=ns, children=sub))
|
||||
else:
|
||||
params.append(_Param(name=name, ns=ns, hint=_hint('anyType', optional)))
|
||||
else:
|
||||
params.append(_Param(name=name, ns=ns, hint=_hint('anyType', optional)))
|
||||
|
||||
elif loc in ('sequence', 'all', 'choice') and depth < 4:
|
||||
params.extend(_parse_sequence(child, ns, elem_map, type_map, depth + 1))
|
||||
|
||||
return params
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SOAP envelope builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _assign_ns_prefix(ns: str, ns_to_pfx: Dict[str, str], used_pfx: set) -> str:
|
||||
"""Return an existing or newly-assigned XML prefix for *ns*."""
|
||||
if ns in ns_to_pfx:
|
||||
return ns_to_pfx[ns]
|
||||
|
||||
# Check well-known namespaces first (prefix-match)
|
||||
candidate = ''
|
||||
for known_ns, known_pfx in _KNOWN_NS_PREFIXES.items():
|
||||
if ns.startswith(known_ns):
|
||||
candidate = known_pfx
|
||||
break
|
||||
|
||||
if not candidate:
|
||||
# Derive a short name from the last meaningful URL path segment
|
||||
last = ns.rstrip('/').rsplit('/', 1)[-1]
|
||||
base = ''.join(c for c in last.lower() if c.isalpha())[:4]
|
||||
_generic = {'org', 'com', 'net', 'gov', 'www', 'http', 'wsdl', 'soap', ''}
|
||||
if base in _generic:
|
||||
parts = ns.rstrip('/').split('/')
|
||||
for part in reversed(parts):
|
||||
seg = ''.join(c for c in part.lower() if c.isalpha())[:4]
|
||||
if seg and seg not in _generic:
|
||||
base = seg
|
||||
break
|
||||
candidate = base or 'ns'
|
||||
|
||||
# Ensure uniqueness
|
||||
orig, i = candidate, 1
|
||||
while candidate in used_pfx:
|
||||
candidate = f'{orig}{i}'
|
||||
i += 1
|
||||
|
||||
ns_to_pfx[ns] = candidate
|
||||
used_pfx.add(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def _build_envelope(op_name: str, target_ns: str, soap_version: str,
|
||||
params=None) -> str:
|
||||
env_ns = _ENV12 if soap_version == '1.2' else _ENV11
|
||||
|
||||
if params:
|
||||
# --- collect all unique namespaces in tree order ---
|
||||
ns_order: List[str] = []
|
||||
ns_seen: set = set()
|
||||
|
||||
def _collect_ns(ps):
|
||||
for p in ps:
|
||||
if p.ns and p.ns not in ns_seen:
|
||||
ns_order.append(p.ns)
|
||||
ns_seen.add(p.ns)
|
||||
_collect_ns(p.children)
|
||||
|
||||
if target_ns and target_ns not in ns_seen:
|
||||
ns_order.append(target_ns)
|
||||
ns_seen.add(target_ns)
|
||||
_collect_ns(params)
|
||||
|
||||
# --- assign prefixes ---
|
||||
ns_to_pfx: Dict[str, str] = {}
|
||||
used_pfx: set = set()
|
||||
|
||||
# Target namespace always gets 'tns' (consistent with the no-params branch)
|
||||
if target_ns:
|
||||
ns_to_pfx[target_ns] = 'tns'
|
||||
used_pfx.add('tns')
|
||||
|
||||
for ns in ns_order:
|
||||
if ns not in ns_to_pfx:
|
||||
_assign_ns_prefix(ns, ns_to_pfx, used_pfx)
|
||||
|
||||
# --- namespace declarations on the operation element ---
|
||||
ns_decls = ' '.join(
|
||||
f'xmlns:{ns_to_pfx[ns]}="{ns}"'
|
||||
for ns in ns_order
|
||||
if ns in ns_to_pfx
|
||||
)
|
||||
|
||||
# --- recursive XML renderer ---
|
||||
def _render(ps, indent: str) -> List[str]:
|
||||
lines: List[str] = []
|
||||
for p in ps:
|
||||
pfx = ns_to_pfx.get(p.ns or target_ns, '')
|
||||
tag = f'{pfx}:{p.name}' if pfx else p.name
|
||||
if p.hint is not None:
|
||||
lines.append(f'{indent}<{tag}>{p.hint}</{tag}>')
|
||||
elif p.children:
|
||||
lines.append(f'{indent}<{tag}>')
|
||||
lines.extend(_render(p.children, indent + ' '))
|
||||
lines.append(f'{indent}</{tag}>')
|
||||
else:
|
||||
lines.append(f'{indent}<{tag}/>')
|
||||
return lines
|
||||
|
||||
op_pfx = ns_to_pfx.get(target_ns, '')
|
||||
op_tag = f'{op_pfx}:{op_name}' if op_pfx else op_name
|
||||
op_open = f'<{op_tag} {ns_decls}>' if ns_decls else f'<{op_tag}>'
|
||||
|
||||
body_lines = [f' {op_open}']
|
||||
body_lines.extend(_render(params, ' '))
|
||||
body_lines.append(f' </{op_tag}>')
|
||||
body = '\n'.join(body_lines)
|
||||
|
||||
return (
|
||||
f'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||
f'<soap:Envelope xmlns:soap="{env_ns}">\n'
|
||||
f' <soap:Header/>\n'
|
||||
f' <soap:Body>\n'
|
||||
f'{body}\n'
|
||||
f' </soap:Body>\n'
|
||||
f'</soap:Envelope>'
|
||||
)
|
||||
else:
|
||||
ns_decl = f'\n xmlns:tns="{target_ns}"' if target_ns else ''
|
||||
op_el = f'tns:{op_name}' if target_ns else op_name
|
||||
return (
|
||||
f'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||
f'<soap:Envelope xmlns:soap="{env_ns}"{ns_decl}>\n'
|
||||
f' <soap:Header/>\n'
|
||||
f' <soap:Body>\n'
|
||||
f' <{op_el}>\n'
|
||||
f' <!-- Add parameters here -->\n'
|
||||
f' </{op_el}>\n'
|
||||
f' </soap:Body>\n'
|
||||
f'</soap:Envelope>'
|
||||
)
|
||||