Compare commits
No commits in common. "master" and "adw-tabview-refactor" have entirely different histories.
master
...
adw-tabvie
1
.gitignore
vendored
@ -25,4 +25,3 @@ __pycache__/
|
|||||||
# Flatpak
|
# Flatpak
|
||||||
.flatpak/
|
.flatpak/
|
||||||
repo/
|
repo/
|
||||||
*.local.json
|
|
||||||
|
|||||||
23
CLAUDE.md
@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Roster is a GNOME application written in Python using GTK 4 and libadwaita. The project follows GNOME application conventions and uses the Meson build system.
|
Roster is a GNOME application written in Python using GTK 4 and libadwaita. The project follows GNOME application conventions and uses the Meson build system.
|
||||||
|
|
||||||
- **Application ID**: `cz.bugsy.roster`
|
- **Application ID**: `cz.vesp.roster`
|
||||||
- **Build System**: Meson
|
- **Build System**: Meson
|
||||||
- **Runtime**: GNOME Platform (org.gnome.Platform)
|
- **Runtime**: GNOME Platform (org.gnome.Platform)
|
||||||
- **UI Framework**: GTK 4 + libadwaita (Adw)
|
- **UI Framework**: GTK 4 + libadwaita (Adw)
|
||||||
@ -40,12 +40,12 @@ The application uses **libsoup3** (from GNOME Platform) for HTTP requests - no e
|
|||||||
|
|
||||||
## Development with Flatpak
|
## Development with Flatpak
|
||||||
|
|
||||||
The project includes a Flatpak manifest (`cz.bugsy.roster.json`) for building and running the application in a sandboxed environment:
|
The project includes a Flatpak manifest (`cz.vesp.roster.json`) for building and running the application in a sandboxed environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build with GNOME Builder (recommended for GNOME apps)
|
# Build with GNOME Builder (recommended for GNOME apps)
|
||||||
# Or use flatpak-builder directly:
|
# Or use flatpak-builder directly:
|
||||||
flatpak-builder --user --install --force-clean build-dir cz.bugsy.roster.json
|
flatpak-builder --user --install --force-clean build-dir cz.vesp.roster.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@ -83,10 +83,10 @@ src/
|
|||||||
roster.gresource.xml # GResource bundle definition
|
roster.gresource.xml # GResource bundle definition
|
||||||
|
|
||||||
data/
|
data/
|
||||||
cz.bugsy.roster.desktop.in # Desktop entry (i18n template)
|
cz.vesp.roster.desktop.in # Desktop entry (i18n template)
|
||||||
cz.bugsy.roster.metainfo.xml.in # AppStream metadata (i18n template)
|
cz.vesp.roster.metainfo.xml.in # AppStream metadata (i18n template)
|
||||||
cz.bugsy.roster.gschema.xml # GSettings schema
|
cz.vesp.roster.gschema.xml # GSettings schema
|
||||||
cz.bugsy.roster.service.in # D-Bus service file (configured by Meson)
|
cz.vesp.roster.service.in # D-Bus service file (configured by Meson)
|
||||||
icons/ # Application icons
|
icons/ # Application icons
|
||||||
|
|
||||||
po/
|
po/
|
||||||
@ -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.
|
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
|
## Adding New Python Modules
|
||||||
|
|
||||||
When adding new `.py` files to `src/`:
|
When adding new `.py` files to `src/`:
|
||||||
@ -121,10 +116,10 @@ When adding new `.py` files to `src/`:
|
|||||||
When adding new `.ui` files:
|
When adding new `.ui` files:
|
||||||
1. Place in `src/` directory
|
1. Place in `src/` directory
|
||||||
2. Add `<file>` entry to `src/roster.gresource.xml`
|
2. Add `<file>` entry to `src/roster.gresource.xml`
|
||||||
3. Use `@Gtk.Template(resource_path='/cz/bugsy/roster/filename.ui')` in the corresponding Python class
|
3. Use `@Gtk.Template(resource_path='/cz/vesp/roster/filename.ui')` in the corresponding Python class
|
||||||
|
|
||||||
## GSettings Schema
|
## GSettings Schema
|
||||||
|
|
||||||
Settings are defined in `data/cz.bugsy.roster.gschema.xml`. After modifying:
|
Settings are defined in `data/cz.vesp.roster.gschema.xml`. After modifying:
|
||||||
- Schema is validated during `meson test`
|
- Schema is validated during `meson test`
|
||||||
- Compiled automatically during installation via `gnome.post_install()`
|
- Compiled automatically during installation via `gnome.post_install()`
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
Application Icons License
|
|
||||||
=========================
|
|
||||||
|
|
||||||
The application icons in this repository are licensed under the Creative Commons
|
|
||||||
Attribution-ShareAlike 3.0 International License (CC BY-SA 3.0).
|
|
||||||
|
|
||||||
Copyright (c) 2025 Pavel Baksy
|
|
||||||
|
|
||||||
You are free to:
|
|
||||||
- Share — copy and redistribute the material in any medium or format
|
|
||||||
- Adapt — remix, transform, and build upon the material for any purpose,
|
|
||||||
even commercially
|
|
||||||
|
|
||||||
Under the following terms:
|
|
||||||
- Attribution — You must give appropriate credit, provide a link to the
|
|
||||||
license, and indicate if changes were made. You may do so in any reasonable
|
|
||||||
manner, but not in any way that suggests the licensor endorses you or your use.
|
|
||||||
|
|
||||||
- ShareAlike — If you remix, transform, or build upon the material, you must
|
|
||||||
distribute your contributions under the same license as the original.
|
|
||||||
|
|
||||||
- No additional restrictions — You may not apply legal terms or technological
|
|
||||||
measures that legally restrict others from doing anything the license permits.
|
|
||||||
|
|
||||||
Icons covered by this license:
|
|
||||||
- data/icons/hicolor/scalable/apps/cz.bugsy.roster.svg
|
|
||||||
- data/icons/hicolor/symbolic/apps/cz.bugsy.roster-symbolic.svg
|
|
||||||
|
|
||||||
To view the full license, visit:
|
|
||||||
https://creativecommons.org/licenses/by-sa/3.0/legalcode
|
|
||||||
|
|
||||||
SPDX-License-Identifier: CC-BY-SA-3.0
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
# Third-Party Dependencies
|
|
||||||
|
|
||||||
This document is provided for informational purposes and does not replace the original licenses.
|
|
||||||
|
|
||||||
Roster uses GNOME platform libraries and Python, all with GPL-3.0-or-later compatible licenses.
|
|
||||||
|
|
||||||
## GNOME Platform Libraries (LGPL-2.1-or-later)
|
|
||||||
- GTK 4, libadwaita, libsoup 3, GtkSourceView 5, GLib
|
|
||||||
|
|
||||||
These are dynamically linked system libraries available on https://gitlab.gnome.org/GNOME/
|
|
||||||
|
|
||||||
## Python (PSF-2.0)
|
|
||||||
- Python 3 standard library
|
|
||||||
|
|
||||||
## GNOME Icons (LGPL-3.0-or-later and CC-BY-SA-3.0)
|
|
||||||
- This application uses icons from the GNOME Project (Adwaita icon theme)
|
|
||||||
|
|
||||||
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.
|
|
||||||
127
README.md
@ -6,132 +6,33 @@ A modern HTTP client for GNOME, built with GTK 4 and libadwaita.
|
|||||||
|
|
||||||
- Send HTTP requests (GET, POST, PUT, DELETE)
|
- Send HTTP requests (GET, POST, PUT, DELETE)
|
||||||
- Configure custom headers and request bodies
|
- Configure custom headers and request bodies
|
||||||
- View response headers and bodies with syntax highlighting
|
- View response headers and bodies
|
||||||
- Track request history with persistence
|
- Track request history with persistence
|
||||||
- Organize requests into projects
|
- Beautiful GNOME-native UI
|
||||||
- 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
|
## Dependencies
|
||||||
|
|
||||||
### Main Interface
|
- GTK 4
|
||||||

|
- libadwaita 1
|
||||||
|
- Python 3
|
||||||
|
- HTTPie (http command)
|
||||||
|
|
||||||
### Request History
|
## Building
|
||||||

|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||

|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
**Build from source:**
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.bugsy.cz/beval/roster.git
|
|
||||||
cd roster
|
|
||||||
meson setup builddir
|
meson setup builddir
|
||||||
meson compile -C builddir
|
meson compile -C builddir
|
||||||
sudo meson install -C builddir
|
sudo meson install -C builddir
|
||||||
```
|
```
|
||||||
|
|
||||||
**Flatpak:**
|
## Usage
|
||||||
```bash
|
|
||||||
flatpak-builder --user --install --force-clean build-dir cz.bugsy.roster.json
|
|
||||||
flatpak run cz.bugsy.roster
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [Installation Guide](https://git.bugsy.cz/beval/roster/wiki/Installation) for detailed instructions.
|
Roster uses HTTPie as the backend for making HTTP requests. Ensure HTTPie is installed:
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
Launch Roster from your application menu or run `roster` from the command line.
|
|
||||||
|
|
||||||
See the [Getting Started Guide](https://git.bugsy.cz/beval/roster/wiki/Getting-Started) for a walkthrough.
|
|
||||||
|
|
||||||
Complete documentation is available in the [Wiki](https://git.bugsy.cz/beval/roster/wiki):
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
### Multi-Environment Support
|
|
||||||
|
|
||||||
Manage multiple environments (development, staging, production) with different variable values. Switch environments with one click.
|
|
||||||
|
|
||||||
[Learn more about Variables](https://git.bugsy.cz/beval/roster/wiki/Variables)
|
|
||||||
|
|
||||||
### Secure Credential Storage
|
|
||||||
|
|
||||||
Store API keys, passwords, and tokens securely in GNOME Keyring with one-click encryption. Regular variables are stored in JSON, while sensitive variables are encrypted.
|
|
||||||
|
|
||||||
[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
|
```bash
|
||||||
# Put your Roster data directory under version control
|
pip install httpie
|
||||||
cd ~/.local/share/cz.bugsy.roster/projects
|
# or on Fedora
|
||||||
git init
|
sudo dnf install httpie
|
||||||
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.
|
Then run Roster from your application menu or with the `roster` command.
|
||||||
|
|
||||||
### JavaScript Automation
|
|
||||||
|
|
||||||
Use preprocessing and postprocessing scripts to:
|
|
||||||
- Extract authentication tokens from responses
|
|
||||||
- Add dynamic headers (timestamps, signatures)
|
|
||||||
- Chain requests together
|
|
||||||
- Validate responses
|
|
||||||
|
|
||||||
[Learn more about Scripts](https://git.bugsy.cz/bavel/roster/wiki/Scripts)
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
### Runtime
|
|
||||||
- GTK 4
|
|
||||||
- libadwaita 1
|
|
||||||
- Python 3
|
|
||||||
- libsoup3 (provided by GNOME Platform)
|
|
||||||
- libsecret (provided by GNOME Platform)
|
|
||||||
- GJS (GNOME JavaScript)
|
|
||||||
|
|
||||||
### Build
|
|
||||||
- Meson (>= 1.0.0)
|
|
||||||
- Ninja
|
|
||||||
- pkg-config
|
|
||||||
- gettext
|
|
||||||
|
|
||||||
See the [Installation Guide](https://git.bugsy.cz/beval/roster/wiki/Installation) for platform-specific dependencies.
|
|
||||||
|
|
||||||
## Platform
|
|
||||||
|
|
||||||
Roster is built specifically for the GNOME desktop using native technologies:
|
|
||||||
- **GTK 4** - Modern UI toolkit
|
|
||||||
- **libadwaita** - GNOME design patterns
|
|
||||||
- **libsoup3** - HTTP networking
|
|
||||||
- **libsecret** - Secure credential storage
|
|
||||||
- **GJS** - JavaScript runtime for scripts
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
GPL-3.0-or-later
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
- **Issues**: [git.bugsy.cz/beval/roster/issues](https://git.bugsy.cz/beval/roster/issues)
|
|
||||||
- **Wiki**: [git.bugsy.cz/beval/roster/wiki](https://git.bugsy.cz/bavel/roster/wiki)
|
|
||||||
- **Source**: [git.bugsy.cz/beval/roster](https://git.bugsy.cz/beval/roster)
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are welcome! Please open an issue or pull request.
|
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "cz.bugsy.roster",
|
|
||||||
"runtime": "org.gnome.Platform",
|
|
||||||
"runtime-version": "49",
|
|
||||||
"sdk": "org.gnome.Sdk",
|
|
||||||
"command": "roster",
|
|
||||||
"finish-args": [
|
|
||||||
"--share=network",
|
|
||||||
"--share=ipc",
|
|
||||||
"--socket=fallback-x11",
|
|
||||||
"--device=dri",
|
|
||||||
"--socket=wayland"
|
|
||||||
],
|
|
||||||
"cleanup": [
|
|
||||||
"/include",
|
|
||||||
"/lib/pkgconfig",
|
|
||||||
"/man",
|
|
||||||
"/share/doc",
|
|
||||||
"/share/gtk-doc",
|
|
||||||
"/share/man",
|
|
||||||
"/share/pkgconfig",
|
|
||||||
"*.la",
|
|
||||||
"*.a"
|
|
||||||
],
|
|
||||||
"modules": [
|
|
||||||
{
|
|
||||||
"name": "roster",
|
|
||||||
"builddir": true,
|
|
||||||
"buildsystem": "meson",
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.bugsy.cz/beval/roster.git",
|
|
||||||
"tag": "v0.8.3"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id" : "cz.bugsy.roster",
|
"id" : "cz.vesp.roster",
|
||||||
"runtime" : "org.gnome.Platform",
|
"runtime" : "org.gnome.Platform",
|
||||||
"runtime-version" : "50",
|
"runtime-version" : "49",
|
||||||
"sdk" : "org.gnome.Sdk",
|
"sdk" : "org.gnome.Sdk",
|
||||||
"command" : "roster",
|
"command" : "roster",
|
||||||
"finish-args" : [
|
"finish-args" : [
|
||||||
@ -28,20 +28,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"modules" : [
|
"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",
|
"name" : "roster",
|
||||||
"builddir" : true,
|
"builddir" : true,
|
||||||
@ -49,7 +35,7 @@
|
|||||||
"sources" : [
|
"sources" : [
|
||||||
{
|
{
|
||||||
"type" : "git",
|
"type" : "git",
|
||||||
"url" : "file:///home/pavelb/Projects/GnomeBuilderProjects/Roster"
|
"url" : "file:///home/pavelb/GnomeBuilderProjects/Roster"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"config-opts" : [
|
"config-opts" : [
|
||||||
@ -1,11 +0,0 @@
|
|||||||
[Desktop Entry]
|
|
||||||
Name=Roster
|
|
||||||
Comment=HTTP client for API testing
|
|
||||||
Exec=roster
|
|
||||||
Icon=cz.bugsy.roster
|
|
||||||
Terminal=false
|
|
||||||
Type=Application
|
|
||||||
Categories=Development;Network;
|
|
||||||
Keywords=HTTP;REST;API;Client;Request;JSON;
|
|
||||||
StartupNotify=true
|
|
||||||
DBusActivatable=true
|
|
||||||
@ -1,181 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<component type="desktop-application">
|
|
||||||
<id>cz.bugsy.roster</id>
|
|
||||||
<metadata_license>CC0-1.0</metadata_license>
|
|
||||||
<project_license>GPL-3.0-or-later</project_license>
|
|
||||||
|
|
||||||
<name>Roster</name>
|
|
||||||
<summary>HTTP client for API testing</summary>
|
|
||||||
<description>
|
|
||||||
<p>Roster is a modern HTTP client for testing and debugging REST APIs. It provides a clean, GNOME-native interface for making HTTP requests and inspecting responses.</p>
|
|
||||||
</description>
|
|
||||||
|
|
||||||
<developer id="cz.bugsy">
|
|
||||||
<name>Pavel Baksy</name>
|
|
||||||
</developer>
|
|
||||||
|
|
||||||
<url type="homepage">https://git.bugsy.cz/beval/roster</url>
|
|
||||||
<url type="vcs-browser">https://git.bugsy.cz/beval/roster</url>
|
|
||||||
<url type="bugtracker">https://git.bugsy.cz/beval/roster/issues</url>
|
|
||||||
|
|
||||||
<translation type="gettext">roster</translation>
|
|
||||||
<!-- All graphical applications having a desktop file must have this tag in the MetaInfo.
|
|
||||||
If this is present, appstreamcli compose will pull icons, keywords and categories from the desktop file. -->
|
|
||||||
<launchable type="desktop-id">cz.bugsy.roster.desktop</launchable>
|
|
||||||
<!-- 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>
|
|
||||||
<caption>Main application window with HTTP request and response</caption>
|
|
||||||
</screenshot>
|
|
||||||
<screenshot>
|
|
||||||
<image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/variables.png</image>
|
|
||||||
<caption>Environment variables configuration</caption>
|
|
||||||
</screenshot>
|
|
||||||
<screenshot>
|
|
||||||
<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>
|
|
||||||
<ul>
|
|
||||||
<li>Remove license note from app description</li>
|
|
||||||
</ul>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
<release version="0.8.0" date="2026-01-15">
|
|
||||||
<description translate="no">
|
|
||||||
<p>Version 0.8.0 release</p>
|
|
||||||
<ul>
|
|
||||||
<li>Shorten User-Agent version to major.minor format</li>
|
|
||||||
</ul>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
<release version="0.7.0" date="2026-01-14">
|
|
||||||
<description translate="no">
|
|
||||||
<p>Version 0.7.0 release</p>
|
|
||||||
<ul>
|
|
||||||
<li>Prepare for Flathub submission</li>
|
|
||||||
<li>Add real screenshots and release notes</li>
|
|
||||||
<li>Add Request creates new tab for project</li>
|
|
||||||
</ul>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
<release version="0.6.0" date="2026-01-10">
|
|
||||||
<description translate="no">
|
|
||||||
<p>Version 0.6.0 release</p>
|
|
||||||
<ul>
|
|
||||||
<li>Added keyboard shortcuts: Ctrl+L (focus URL), Ctrl+Return (send request)</li>
|
|
||||||
<li>Translation improvements</li>
|
|
||||||
</ul>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
<release version="0.5.0" date="2026-01-08">
|
|
||||||
<description translate="no">
|
|
||||||
<p>Version 0.5.0 release</p>
|
|
||||||
<ul>
|
|
||||||
<li>Authentication improvements</li>
|
|
||||||
<li>Bug fixes and stability improvements</li>
|
|
||||||
</ul>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
<release version="0.4.0" date="2026-01-05">
|
|
||||||
<description translate="no">
|
|
||||||
<p>Version 0.4.0 release</p>
|
|
||||||
<ul>
|
|
||||||
<li>Improved sidebar layout with reduced indentation</li>
|
|
||||||
<li>Enhanced panel resizing capabilities</li>
|
|
||||||
<li>Better project folder state management</li>
|
|
||||||
<li>Status icons fixes in panels</li>
|
|
||||||
</ul>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
<release version="0.2.0" date="2025-12-30">
|
|
||||||
<description translate="no">
|
|
||||||
<p>Version 0.2.0 release</p>
|
|
||||||
<ul>
|
|
||||||
<li>Environment variable support improvements</li>
|
|
||||||
<li>UI refinements and bug fixes</li>
|
|
||||||
</ul>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
</releases>
|
|
||||||
|
|
||||||
</component>
|
|
||||||
10
data/cz.vesp.roster.desktop.in
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Name=roster
|
||||||
|
Exec=roster
|
||||||
|
Icon=cz.vesp.roster
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Categories=Utility;
|
||||||
|
Keywords=GTK;
|
||||||
|
StartupNotify=true
|
||||||
|
DBusActivatable=true
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<schemalist gettext-domain="roster">
|
<schemalist gettext-domain="roster">
|
||||||
<schema id="cz.bugsy.roster" path="/cz/bugsy/roster/">
|
<schema id="cz.vesp.roster" path="/cz/vesp/roster/">
|
||||||
<key name="force-tls-verification" type="b">
|
<key name="force-tls-verification" type="b">
|
||||||
<default>true</default>
|
<default>true</default>
|
||||||
<summary>Force TLS verification</summary>
|
<summary>Force TLS verification</summary>
|
||||||
@ -11,15 +11,5 @@
|
|||||||
<summary>Request timeout</summary>
|
<summary>Request timeout</summary>
|
||||||
<description>Timeout for HTTP requests in seconds</description>
|
<description>Timeout for HTTP requests in seconds</description>
|
||||||
</key>
|
</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>
|
</schema>
|
||||||
</schemalist>
|
</schemalist>
|
||||||
73
data/cz.vesp.roster.metainfo.xml.in
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>cz.vesp.roster</id>
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<project_license>GPL-3.0-or-later</project_license>
|
||||||
|
|
||||||
|
<name>Roster</name>
|
||||||
|
<summary>Keep the summary shorter, between 10 and 35 characters</summary>
|
||||||
|
<description>
|
||||||
|
<p>No description</p>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<developer id="tld.vendor">
|
||||||
|
<name>Developer name</name>
|
||||||
|
</developer>
|
||||||
|
|
||||||
|
<!-- Required: Should be a link to the upstream homepage for the component -->
|
||||||
|
<url type="homepage">https://example.org/</url>
|
||||||
|
<!-- Recommended: It is highly recommended for open-source projects to display the source code repository -->
|
||||||
|
<url type="vcs-browser">https://example.org/repository</url>
|
||||||
|
<!-- Should point to the software's bug tracking system, for users to report new bugs -->
|
||||||
|
<url type="bugtracker">https://example.org/issues</url>
|
||||||
|
<!-- Should link a FAQ page for this software, to answer some of the most-asked questions in detail -->
|
||||||
|
<!-- URLs of this type should point to a webpage where users can submit or modify translations of the upstream project -->
|
||||||
|
<url type="translate">https://example.org/translate</url>
|
||||||
|
<url type="faq">https://example.org/faq</url>
|
||||||
|
<!-- Should provide a web link to an online user's reference, a software manual or help page -->
|
||||||
|
<url type="help">https://example.org/help</url>
|
||||||
|
<!-- URLs of this type should point to a webpage showing information on how to donate to the described software project -->
|
||||||
|
<url type="donation">https://example.org/donate</url>
|
||||||
|
<!-- This could for example be an HTTPS URL to an online form or a page describing how to contact the developer -->
|
||||||
|
<url type="contact">https://example.org/contact</url>
|
||||||
|
<!-- URLs of this type should point to a webpage showing information on how to contribute to the described software project -->
|
||||||
|
<url type="contribute">https://example.org/contribute</url>
|
||||||
|
|
||||||
|
<translation type="gettext">roster</translation>
|
||||||
|
<!-- All graphical applications having a desktop file must have this tag in the MetaInfo.
|
||||||
|
If this is present, appstreamcli compose will pull icons, keywords and categories from the desktop file. -->
|
||||||
|
<launchable type="desktop-id">cz.vesp.roster.desktop</launchable>
|
||||||
|
<!-- 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" />
|
||||||
|
|
||||||
|
<!-- Applications should set a brand color in both light and dark variants like so -->
|
||||||
|
<branding>
|
||||||
|
<color type="primary" scheme_preference="light">#ff00ff</color>
|
||||||
|
<color type="primary" scheme_preference="dark">#993d3d</color>
|
||||||
|
</branding>
|
||||||
|
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<image>https://example.org/example1.png</image>
|
||||||
|
<caption>A caption</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://example.org/example2.png</image>
|
||||||
|
<caption>A caption</caption>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="1.0.1" date="2024-01-18">
|
||||||
|
<url type="details">https://example.org/changelog.html#version_1.0.1</url>
|
||||||
|
<description translate="no">
|
||||||
|
<p>Release description</p>
|
||||||
|
<ul>
|
||||||
|
<li>List of changes</li>
|
||||||
|
<li>List of changes</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
|
|
||||||
|
</component>
|
||||||
@ -1,3 +1,3 @@
|
|||||||
[D-BUS Service]
|
[D-BUS Service]
|
||||||
Name=cz.bugsy.roster
|
Name=cz.vesp.roster
|
||||||
Exec=@bindir@/roster --gapplication-service
|
Exec=@bindir@/roster --gapplication-service
|
||||||
@ -1,29 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<linearGradient id="a" gradientUnits="userSpaceOnUse" spreadMethod="reflect" x1="63.93500143502" x2="116.66087219391" y1="114.25781884399" y2="113.87787042471">
|
|
||||||
<stop offset="0" stop-color="#567cb9"/>
|
|
||||||
<stop offset="0.691254" stop-color="#567cb9" stop-opacity="0.74902"/>
|
|
||||||
<stop offset="0.738096" stop-color="#567cb9" stop-opacity="0.623529"/>
|
|
||||||
<stop offset="0.782391" stop-color="#567cb9" stop-opacity="0.498039"/>
|
|
||||||
<stop offset="0.821796" stop-color="#567cb9" stop-opacity="0.74902"/>
|
|
||||||
<stop offset="0.85385" stop-color="#567cb9" stop-opacity="0.87451"/>
|
|
||||||
<stop offset="0.955449" stop-color="#567cb9" stop-opacity="0.937255"/>
|
|
||||||
<stop offset="1" stop-color="#567cb9"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="b" gradientTransform="matrix(0.185607 0 0 0.185596 -41.369942 -39.816223)" gradientUnits="userSpaceOnUse" spreadMethod="reflect" x1="781.887085" x2="537.229431" y1="760.514282" y2="763.320618">
|
|
||||||
<stop offset="0" stop-color="#173c7a"/>
|
|
||||||
<stop offset="0.149359" stop-color="#5383d9"/>
|
|
||||||
<stop offset="0.246092" stop-color="#1f4a92"/>
|
|
||||||
<stop offset="0.337206" stop-color="#1b4386"/>
|
|
||||||
<stop offset="1" stop-color="#173c7a"/>
|
|
||||||
</linearGradient>
|
|
||||||
<path d="m 94.542969 18.238281 c 1.398437 0.253907 1.480469 0.511719 1.480469 4.527344 c 0 2.933594 0.132812 4.207031 0.589843 5.683594 c 1 3.242187 3.492188 6.097656 6.773438 7.769531 c 0.667969 0.339844 2.316406 0.605469 5.035156 0.808594 c 5.144531 0.386718 5.445313 0.519531 5.742187 2.585937 c 0.128907 0.875 0.195313 16.273438 0.152344 34.210938 l -0.078125 32.613281 l -0.964843 1.960938 c -1.171876 2.382812 -3.964844 5.121093 -6.488282 6.359374 l -1.726562 0.847657 h -40.589844 l -40.589844 -0.136719 c -0.402344 0.003906 -2.464844 -0.859375 -2.464844 -0.859375 c -2.792968 -1.207031 -4.558593 -3.820313 -6.070312 -6.554687 l -1.074219 -1.949219 l 0.160157 -39.21875 v -39.242188 l 0.992187 -2.027343 c 1.871094 -3.824219 5.535156 -6.671876 9.390625 -7.304688 c 1.742188 -0.285156 68.199219 -0.355469 69.730469 -0.074219 z m 0 0" fill="url(#a)"/>
|
|
||||||
<path d="m 94.382812 13.8125 c 1.394532 0.257812 1.480469 0.515625 1.480469 4.527344 c 0 2.9375 0.128907 4.210937 0.585938 5.683594 c 1 3.242187 3.492187 6.101562 6.773437 7.773437 c 0.667969 0.339844 2.316406 0.605469 5.035156 0.808594 c 5.148438 0.382812 5.449219 0.519531 5.746094 2.582031 c 0.125 0.878906 0.195313 16.273438 0.152344 34.210938 l -0.078125 32.617187 l -0.96875 1.960937 c -1.171875 2.382813 -3.964844 5.121094 -6.484375 6.359376 l -1.730469 0.847656 h -40.589843 l -40.71875 0.078125 c -0.941407 -0.027344 -2.34375 -0.996094 -2.34375 -0.996094 c -2.597657 -1.457031 -4.71875 -3.8125 -6.058594 -6.632813 l -0.914063 -1.925781 v -78.484375 l 0.992188 -2.027344 c 1.867187 -3.824218 5.53125 -6.675781 9.386719 -7.304687 c 1.742187 -0.285156 68.199218 -0.359375 69.734374 -0.078125 z m 0 0" fill="#96bcf9"/>
|
|
||||||
<path d="m 12.089844 26.011719 h 79.894531 c 0.824219 0 1.492187 0.273437 1.492187 0.613281 v 51.039062 c 0 0.339844 -0.667968 0.617188 -1.492187 0.617188 h -79.894531 c -0.820313 0 -1.488282 -0.277344 -1.488282 -0.617188 v -51.039062 c 0 -0.339844 0.667969 -0.613281 1.488282 -0.613281 z m 0 0" fill="#96bcf9"/>
|
|
||||||
<path d="m 111.046875 56.371094 c 0.945313 0.621094 10.683594 7.730468 14.746094 11.3125 c 0 0 -0.011719 1.800781 -0.011719 2.089844 c 0 0.316406 -2.621094 2.820312 -6.546875 6.25 c -9.179687 8.019531 -8.914063 7.832031 -9.84375 6.804687 c -0.40625 -0.445313 -0.488281 -1.238281 -0.488281 -4.589844 v -4.046875 h -2.804688 c -2.46875 0 -2.839844 -0.070312 -3.109375 -0.574218 c -0.167969 -0.316407 -0.304687 -2.238282 -0.304687 -4.273438 c 0 -4.363281 0.164062 -4.695312 2.335937 -4.625 c 0.75 0.027344 1.929688 0.035156 2.621094 0.019531 l 1.261719 -0.023437 v -3.722656 c 0 -4.699219 0.472656 -5.71875 2.144531 -4.621094 z m 0 0" fill="#173c7a"/>
|
|
||||||
<path d="m 23.355469 103.21875 c -1.398438 -0.257812 -1.480469 -0.511719 -1.480469 -4.527344 c 0 -2.933594 -0.132812 -4.207031 -0.585938 -5.683594 c -1.003906 -3.242187 -3.492187 -6.101562 -6.777343 -7.773437 c -0.667969 -0.339844 -2.316407 -0.605469 -5.035157 -0.808594 c -5.144531 -0.382812 -5.445312 -0.519531 -5.742187 -2.582031 c -0.128906 -0.878906 -0.195313 -16.273438 -0.152344 -34.210938 l 0.078125 -32.617187 l 0.964844 -1.960937 c 1.171875 -2.382813 3.964844 -5.117188 6.488281 -6.355469 l 1.726563 -0.851563 h 81.1875 l 2.25 1.113282 c 2.730468 1.347656 5.996094 3.667968 6.835937 6.675781 l 0.703125 2.527343 l -0.382812 38.402344 s 1.335937 36.335938 0.039062 39.242188 l -0.992187 2.03125 c -1.867188 3.820312 -5.53125 6.671875 -9.386719 7.304687 c -1.746094 0.285157 -68.207031 0.355469 -69.738281 0.074219 z m 28.640625 -22.28125 c 1.199218 -0.730469 1.867187 -2.007812 1.867187 -3.566406 c 0 -1.574219 -0.628906 -2.75 -1.878906 -3.523438 c -0.984375 -0.609375 -1.296875 -0.621094 -15.808594 -0.621094 h -14.804687 l -1.003906 0.675782 c -1.257813 0.847656 -1.742188 1.789062 -1.746094 3.410156 c -0.003906 1.523438 0.570312 2.738281 1.691406 3.574219 c 0.804688 0.601562 1.117188 0.613281 15.777344 0.621093 c 14.324218 0.007813 15 -0.015624 15.90625 -0.570312 z m 24.613281 -20.390625 c 1.144531 -0.652344 1.835937 -2.109375 1.835937 -3.871094 c 0 -1.488281 -0.085937 -1.679687 -1.292968 -2.886719 l -1.292969 -1.292968 h -54.753906 l -1.242188 1.195312 c -0.824219 0.789063 -1.257812 1.46875 -1.277343 2 c -0.101563 2.636719 0.472656 3.964844 2.105468 4.855469 c 1 0.546875 1.929688 0.5625 28.136719 0.476563 c 21.710937 -0.070313 27.234375 -0.164063 27.78125 -0.476563 z m 3.613281 -21.28125 l 6.070313 -0.195313 l 0.9375 -0.929687 c 0.515625 -0.515625 1.089843 -1.5 1.277343 -2.195313 c 0.292969 -1.097656 0.261719 -1.421874 -0.269531 -2.515624 c -0.335937 -0.691407 -1.007812 -1.550782 -1.492187 -1.910157 l -0.882813 -0.652343 l -64.867187 0.15625 l -1.246094 1.246093 c -1.121094 1.117188 -1.246094 1.390625 -1.246094 2.671875 c 0 1.542969 0.664063 2.96875 1.703125 3.65625 c 0.515625 0.34375 3.328125 0.449219 15.292969 0.578125 c 24.835938 0.265625 38.390625 0.292969 44.722656 0.089844 z m 0 0" fill="url(#b)"/>
|
|
||||||
<path d="m 23.847656 99.390625 c -1.398437 -0.253906 -1.480468 -0.511719 -1.480468 -4.523437 c 0 -2.9375 -0.132813 -4.210938 -0.585938 -5.683594 c -1.003906 -3.242188 -3.492188 -6.101563 -6.777344 -7.773438 c -0.667968 -0.339844 -2.316406 -0.605468 -5.03125 -0.808594 c -5.148437 -0.382812 -5.449218 -0.519531 -5.746094 -2.585937 c -0.125 -0.875 -0.195312 -16.269531 -0.152343 -34.210937 l 0.078125 -32.613282 l 0.964844 -1.960937 c 1.175781 -2.382813 3.96875 -5.121094 6.488281 -6.359375 l 1.726562 -0.847656 h 81.179688 l 2.253906 1.113281 c 2.730469 1.347656 4.941406 3.613281 6.28125 6.4375 l 0.914063 1.925781 v 39.242188 s 0.109374 38.625 0 39.242187 l -0.992188 2.027344 c -1.871094 3.824219 -5.53125 6.671875 -9.390625 7.304687 c -1.742187 0.285156 -68.199219 0.359375 -69.730469 0.074219 z m 28.640625 -22.277344 c 1.195313 -0.730469 1.863281 -2.007812 1.863281 -3.566406 c 0 -1.574219 -0.625 -2.753906 -1.878906 -3.527344 c -0.984375 -0.605469 -1.292968 -0.621093 -15.808594 -0.621093 h -14.800781 l -1.003906 0.675781 c -1.257813 0.851562 -1.742187 1.789062 -1.746094 3.414062 c -0.003906 1.519531 0.570313 2.738281 1.691407 3.570313 c 0.804687 0.601562 1.117187 0.617187 15.777343 0.625 c 14.320313 0.003906 14.996094 -0.019532 15.90625 -0.570313 z m 24.609375 -20.394531 c 1.144532 -0.648438 1.835938 -2.105469 1.835938 -3.867188 c 0 -1.488281 -0.085938 -1.679687 -1.292969 -2.890624 l -1.292969 -1.292969 h -54.75 l -1.246094 1.195312 c -0.820312 0.792969 -1.253906 1.46875 -1.273437 2 c -0.101563 2.636719 0.472656 3.964844 2.105469 4.855469 c 1 0.546875 1.929687 0.5625 28.132812 0.476562 c 21.710938 -0.070312 27.234375 -0.164062 27.78125 -0.476562 z m 3.613282 -21.277344 l 6.070312 -0.195312 l 0.9375 -0.933594 c 0.515625 -0.511719 1.089844 -1.5 1.277344 -2.191406 c 0.292968 -1.101563 0.261718 -1.421875 -0.269532 -2.519532 c -0.335937 -0.691406 -1.007812 -1.550781 -1.492187 -1.90625 l -0.882813 -0.65625 l -32.433593 0.078126 l -32.429688 0.078124 l -1.246093 1.246094 c -1.121094 1.121094 -1.246094 1.394532 -1.246094 2.671875 c 0 1.542969 0.664062 2.96875 1.703125 3.660157 c 0.515625 0.34375 3.328125 0.449218 15.292969 0.578124 c 24.832031 0.265626 38.386718 0.292969 44.71875 0.089844 z m 0 0" fill="#2e7af4"/>
|
|
||||||
<path d="m 111.0625 54.285156 c 0.945312 0.617188 10.394531 8.785156 12.878906 11.125 c 1.019532 0.964844 1.855469 1.988282 1.855469 2.277344 c 0 0.3125 -2.625 2.816406 -6.546875 6.246094 c -9.179688 8.019531 -8.914062 7.835937 -9.84375 6.808594 c -0.40625 -0.449219 -0.492188 -1.238282 -0.492188 -4.589844 v -4.050782 h -2.800781 c -2.46875 0 -2.839843 -0.066406 -3.109375 -0.570312 c -0.167968 -0.316406 -0.308594 -2.238281 -0.308594 -4.273438 c 0 -4.367187 0.167969 -4.699218 2.335938 -4.625 c 0.753906 0.023438 1.933594 0.03125 2.625 0.019532 l 1.257812 -0.027344 v -3.722656 c 0 -4.699219 0.476563 -5.71875 2.148438 -4.617188 z m 0 0" fill="#2e7af4"/>
|
|
||||||
<path d="m 98.59375 70.117188 c 0.375 0.375 0.464844 1.210937 0.464844 4.296874 v 3.828126 h 2.753906 c 1.75 0 1.835938 0.121093 1.855469 0.4375 c 0.046875 0.773437 0.410156 7.398437 0.332031 8.183593 c -0.015625 0.160157 -1.058594 0.332031 -2.1875 0.484375 l -2.753906 0.371094 v 3.964844 c 0 3.441406 -0.074219 4.058594 -0.554688 4.65625 c -0.746094 0.917968 -1.207031 0.683594 -4.816406 -2.445313 c -1.566406 -1.359375 -4.605469 -3.992187 -6.75 -5.847656 c -4.023438 -3.484375 -4.914062 -4.570313 -4.4375 -5.417969 c 0.25 -0.445312 10.828125 -9.90625 13.441406 -12.023437 c 1.3125 -1.058594 1.960938 -1.179688 2.652344 -0.488281 z m 0 0" fill="#173c7a"/>
|
|
||||||
<path d="m 98.734375 68.582031 c 0.375 0.375 0.464844 1.207031 0.464844 4.292969 v 3.832031 h 2.757812 c 1.746094 0 3.996094 0.128907 4.222657 0.351563 c 0.492187 0.496094 0.492187 8.273437 0 8.769531 c -0.226563 0.222656 -2.476563 0.355469 -4.222657 0.355469 h -2.757812 v 3.964844 c 0 3.441406 -0.070313 4.054687 -0.554688 4.652343 c -0.746093 0.917969 -1.203125 0.6875 -4.8125 -2.441406 c -1.570312 -1.363281 -4.609375 -3.992187 -6.753906 -5.851563 c -4.023437 -3.480468 -4.914063 -4.566406 -4.4375 -5.414062 c 0.25 -0.449219 10.828125 -9.910156 13.445313 -12.023438 c 1.308593 -1.0625 1.957031 -1.179687 2.648437 -0.488281 z m 0 0" fill="#96bcf9"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 10 KiB |
130
data/icons/hicolor/scalable/apps/cz.vesp.roster.svg
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="33.750061mm"
|
||||||
|
height="33.750061mm"
|
||||||
|
viewBox="0 0 33.750061 33.750061"
|
||||||
|
version="1.1"
|
||||||
|
id="svg974">
|
||||||
|
<defs
|
||||||
|
id="defs968">
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath18689">
|
||||||
|
<rect
|
||||||
|
clip-path="none"
|
||||||
|
transform="rotate(45)"
|
||||||
|
ry="32.000008"
|
||||||
|
rx="32.000008"
|
||||||
|
y="123.9986"
|
||||||
|
x="486.03726"
|
||||||
|
height="362.94299"
|
||||||
|
width="362.94299"
|
||||||
|
id="rect18691"
|
||||||
|
style="display:inline;opacity:1;vector-effect:none;fill:#4a86cf;fill-opacity:1;stroke:none;stroke-width:26.0669;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath18689-3">
|
||||||
|
<rect
|
||||||
|
clip-path="none"
|
||||||
|
transform="rotate(45)"
|
||||||
|
ry="32.000008"
|
||||||
|
rx="32.000008"
|
||||||
|
y="123.9986"
|
||||||
|
x="486.03726"
|
||||||
|
height="362.94299"
|
||||||
|
width="362.94299"
|
||||||
|
id="rect18691-6"
|
||||||
|
style="display:inline;opacity:1;vector-effect:none;fill:#4a86cf;fill-opacity:1;stroke:none;stroke-width:26.0669;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<metadata
|
||||||
|
id="metadata971">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title></dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-61.819823,-103.94395)">
|
||||||
|
<g
|
||||||
|
transform="matrix(0.26367235,0,0,0.26367235,61.819823,-529.39703)"
|
||||||
|
style="display:inline;stroke-width:0.25;enable-background:new"
|
||||||
|
id="g1836">
|
||||||
|
<title
|
||||||
|
id="title1838">application-x-executable</title>
|
||||||
|
<g
|
||||||
|
transform="matrix(0.25,0,0,0.25,0,2295)"
|
||||||
|
id="g18818"
|
||||||
|
style="stroke-width:0.25">
|
||||||
|
<g
|
||||||
|
style="stroke-width:0.269963"
|
||||||
|
transform="matrix(0.92605186,0,0,0.92605186,18.930729,50.876335)"
|
||||||
|
id="g18590">
|
||||||
|
<g
|
||||||
|
style="stroke-width:0.269963"
|
||||||
|
id="g18681"
|
||||||
|
clip-path="url(#clipPath18689-3)">
|
||||||
|
<rect
|
||||||
|
style="opacity:1;vector-effect:none;fill:#3584e4;fill-opacity:1;stroke:none;stroke-width:8.22095;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||||
|
id="rect18571"
|
||||||
|
width="424"
|
||||||
|
height="424"
|
||||||
|
x="458.33722"
|
||||||
|
y="90.641701"
|
||||||
|
rx="10.092117"
|
||||||
|
ry="10.092117"
|
||||||
|
transform="matrix(0.60528171,0.60528171,-0.60528171,0.60528171,33.440632,99.073632)"
|
||||||
|
clip-path="none" />
|
||||||
|
<circle
|
||||||
|
style="opacity:1;vector-effect:none;fill:#f66151;fill-opacity:1;stroke:none;stroke-width:7.03712;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||||
|
id="path18706"
|
||||||
|
cx="0"
|
||||||
|
cy="0"
|
||||||
|
r="0"
|
||||||
|
transform="translate(0,-212)" />
|
||||||
|
<circle
|
||||||
|
style="opacity:1;vector-effect:none;fill:#f66151;fill-opacity:1;stroke:none;stroke-width:7.03712;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||||
|
id="path18708"
|
||||||
|
cx="0"
|
||||||
|
cy="0"
|
||||||
|
r="0"
|
||||||
|
transform="translate(0,-212)" />
|
||||||
|
<path
|
||||||
|
style="display:inline;opacity:1;vector-effect:none;fill:#98c1f1;fill-opacity:1;stroke:none;stroke-width:7.03712;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"
|
||||||
|
d="m 408.91993,561.9183 -9.8861,29.82892 a 172.97099,172.97099 0 0 0 -1.42693,-0.0713 172.97099,172.97099 0 0 0 -23.92891,1.85189 l -13.80082,-28.50125 a 203.29325,203.29325 0 0 0 -29.40085,7.97217 l 2.28619,31.40474 a 172.97099,172.97099 0 0 0 -22.73152,11.31923 l -23.71796,-21.103 a 203.29325,203.29325 0 0 0 -24.05918,18.6741 l 14.09863,28.07319 a 172.97099,172.97099 0 0 0 -16.63608,19.21074 l -30.05845,-10.44758 a 203.29325,203.29325 0 0 0 -15.01683,26.48807 l 23.73035,20.50738 a 172.97099,172.97099 0 0 0 -7.98456,24.14293 l -31.73044,1.84879 a 203.29325,203.29325 0 0 0 -3.77825,30.21664 l 29.82892,9.8861 a 172.97099,172.97099 0 0 0 -0.0713,1.42693 172.97099,172.97099 0 0 0 1.85188,23.92889 l -28.50125,13.80084 a 203.29325,203.29325 0 0 0 7.97215,29.40084 l 31.40475,-2.28619 a 172.97099,172.97099 0 0 0 11.31922,22.73152 l -21.10296,23.71797 a 203.29325,203.29325 0 0 0 18.67409,24.05918 l 28.07319,-14.09863 a 172.97099,172.97099 0 0 0 19.21074,16.63606 l -10.44758,30.05847 a 203.29325,203.29325 0 0 0 26.48806,15.01683 l 20.50739,-23.73036 a 172.97099,172.97099 0 0 0 24.14293,7.98457 l 1.8488,31.73043 a 203.29325,203.29325 0 0 0 30.21666,3.77826 l 9.8861,-29.82892 a 172.97099,172.97099 0 0 0 1.42693,0.0713 172.97099,172.97099 0 0 0 23.9289,-1.85188 l 13.80084,28.50125 a 203.29325,203.29325 0 0 0 29.40084,-7.97217 l -2.2862,-31.40474 a 172.97099,172.97099 0 0 0 22.73153,-11.31922 l 23.71796,21.10297 A 203.29325,203.29325 0 0 0 532.96,916.00016 l -14.09864,-28.07319 a 172.97099,172.97099 0 0 0 16.63607,-19.21073 l 30.05846,10.44757 a 203.29325,203.29325 0 0 0 15.01683,-26.48807 l -23.73036,-20.50738 a 172.97099,172.97099 0 0 0 7.98457,-24.14293 l 31.73044,-1.84879 a 203.29325,203.29325 0 0 0 3.77825,-30.21667 l -29.82892,-9.8861 a 172.97099,172.97099 0 0 0 0.0713,-1.42692 172.97099,172.97099 0 0 0 -1.85189,-23.9289 l 28.50124,-13.80084 a 203.29325,203.29325 0 0 0 -7.97215,-29.40084 l -31.40474,2.2862 a 172.97099,172.97099 0 0 0 -11.31923,-22.73153 l 21.10297,-23.71797 a 203.29325,203.29325 0 0 0 -18.67409,-24.05918 l -28.07319,14.09863 a 172.97099,172.97099 0 0 0 -19.21074,-16.63606 l 10.44757,-30.05847 A 203.29325,203.29325 0 0 0 485.6357,581.68117 l -20.50738,23.73035 a 172.97099,172.97099 0 0 0 -24.14293,-7.98455 l -1.84879,-31.73044 a 203.29325,203.29325 0 0 0 -30.21667,-3.77826 z M 397.6069,637.72208 A 126.92605,126.92605 0 0 1 524.5318,764.64699 126.92605,126.92605 0 0 1 397.6069,891.57189 126.92605,126.92605 0 0 1 270.682,764.64699 126.92605,126.92605 0 0 1 397.6069,637.72208 Z"
|
||||||
|
id="path18717-4" />
|
||||||
|
<path
|
||||||
|
id="path18758"
|
||||||
|
d="m 51.748325,401.28402 -9.8861,29.82892 c -0.475543,-0.0257 -0.951191,-0.0495 -1.42693,-0.0713 -8.00956,0.0625 -16.005106,0.6813 -23.92891,1.85189 L 2.7055639,404.39228 c -9.9858697,1.91835 -19.8137359,4.58322 -29.4008489,7.97217 l 2.28619,31.40474 c -7.844275,3.21103 -15.441918,6.9943 -22.73152,11.31923 l -23.71796,-21.103 c -8.475372,5.61437 -16.517661,11.85658 -24.05918,18.6741 l 14.09863,28.07319 c -6.008901,5.98701 -11.569263,12.40791 -16.636077,19.21074 l -30.058438,-10.44758 c -5.66072,8.44155 -10.68041,17.29574 -15.01683,26.48807 l 23.73035,20.50738 c -3.25027,7.84084 -5.919,15.91028 -7.98456,24.14293 l -31.73044,1.84879 c -2.01308,9.96359 -3.27604,20.06413 -3.77825,30.21664 l 29.82892,9.8861 c -0.0257,0.47554 -0.0495,0.95119 -0.0713,1.42693 0.0625,8.00955 0.68129,16.00509 1.85188,23.92889 l -28.50125,13.80084 c 1.91835,9.98587 4.58321,19.81373 7.97215,29.40084 l 31.40475,-2.28619 c 3.21102,7.84427 6.99429,15.44192 11.31922,22.73152 l -21.10296,23.71797 c 5.61437,8.47537 11.85658,16.51766 18.67409,24.05918 l 28.073175,-14.09863 c 5.987006,6.00889 12.407911,11.56925 19.21074,16.63606 l -10.44758,30.05847 c 8.441549,5.66072 17.295731,10.68041 26.48806,15.01683 l 20.50739,-23.73036 c 7.840839,3.25027 15.910282,5.91901 24.142929,7.98457 l 1.8488,31.73043 c 9.9635962,2.01309 20.064147,3.27605 30.216661,3.77826 l 9.8861,-29.82892 c 0.475543,0.0257 0.951191,0.0495 1.42693,0.0713 8.009557,-0.0625 16.005099,-0.68129 23.9289,-1.85188 l 13.80084,28.50125 c 9.985867,-1.91835 19.813731,-4.58322 29.400855,-7.97217 l -2.2862,-31.40474 c 7.84428,-3.21102 15.44192,-6.99429 22.73153,-11.31922 l 23.71796,21.10297 c 8.47538,-5.61437 16.51767,-11.85658 24.05919,-18.6741 l -14.09864,-28.07319 c 6.0089,-5.987 11.56926,-12.4079 16.63607,-19.21073 l 30.05846,10.44757 c 5.66072,-8.44155 10.68041,-17.29574 15.01683,-26.48807 l -23.73036,-20.50738 c 3.25027,-7.84084 5.91901,-15.91028 7.98457,-24.14293 l 31.73044,-1.84879 c 2.01308,-9.9636 3.27604,-20.06415 3.77825,-30.21667 l -29.82892,-9.8861 c 0.0257,-0.47554 0.0495,-0.95118 0.0713,-1.42692 -0.0625,-8.00956 -0.6813,-16.0051 -1.85189,-23.9289 l 28.50124,-13.80084 c -1.91835,-9.98587 -4.58321,-19.81373 -7.97215,-29.40084 l -31.40474,2.2862 c -3.21103,-7.84428 -6.9943,-15.44192 -11.31923,-22.73153 l 21.10297,-23.71797 c -5.61437,-8.47537 -11.85658,-16.51766 -18.67409,-24.05918 l -28.07319,14.09863 c -5.98701,-6.00889 -12.40791,-11.56925 -19.21074,-16.63606 l 10.44757,-30.05847 c -8.44155,-5.66072 -17.29572,-10.6804 -26.48805,-15.01682 l -20.50738,23.73035 c -7.84085,-3.25027 -15.910298,-5.91899 -24.142945,-7.98455 l -1.84879,-31.73044 c -9.9636,-2.01309 -20.064152,-3.27605 -30.21667,-3.77826 z"
|
||||||
|
style="display:inline;opacity:1;vector-effect:none;fill:#1a5fb4;fill-opacity:1;stroke:none;stroke-width:7.03712;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
style="display:inline;opacity:0.534;vector-effect:none;fill:#1a5fb4;fill-opacity:1;stroke:none;stroke-width:1.62918;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"
|
||||||
|
clip-path="none"
|
||||||
|
d="m 8.4765625,2676 c -1.1711695,2.8866 -0.5827763,6.3078 1.7656255,8.6562 l 48.101562,48.1016 c 3.133898,3.1339 8.178602,3.1339 11.3125,0 l 48.10156,-48.1016 c 2.3484,-2.3484 2.9368,-5.7696 1.76563,-8.6562 -0.39174,0.9655 -0.98013,1.8708 -1.76563,2.6562 l -48.10156,48.1016 c -3.133898,3.1339 -8.178602,3.1339 -11.3125,0 L 10.242188,2678.6562 C 9.4566904,2677.8708 8.8682972,2676.9655 8.4765625,2676 Z"
|
||||||
|
transform="matrix(4,0,0,4,0,-10028)"
|
||||||
|
id="rect18571-6" />
|
||||||
|
</g>
|
||||||
|
<rect
|
||||||
|
y="2402"
|
||||||
|
x="-1.5000001e-06"
|
||||||
|
height="128"
|
||||||
|
width="128"
|
||||||
|
id="rect9125-7-2"
|
||||||
|
style="display:inline;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:none;stroke-width:1.25;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="m 1.53125 0.0429688 l -0.277344 0.1328122 c -0.402344 0.195313 -0.847656 0.632813 -1.035156 1.011719 l -0.1523438 0.3125 l -0.0117187 5.195312 c -0.0078125 2.859376 0.0039063 5.3125 0.0234375 5.449219 c 0.046875 0.332031 0.097656 0.351563 0.914063 0.410157 c 0.433593 0.035156 0.695312 0.078124 0.800781 0.132812 c 0.523437 0.265625 0.921875 0.71875 1.082031 1.238281 c 0.074219 0.234375 0.09375 0.4375 0.09375 0.90625 c 0 0.636719 0.011719 0.679688 0.234375 0.71875 c 0.242187 0.042969 10.832031 0.035157 11.109375 -0.011719 c 0.613281 -0.101562 1.199219 -0.554687 1.496094 -1.164062 l 0.15625 -0.320312 c 0.015625 -0.101563 0 -6.253907 0 -6.253907 v -6.25 l -0.144532 -0.304687 c -0.214843 -0.453125 -0.566406 -0.816406 -1 -1.027344 l -0.359374 -0.1757812 z m 5.242188 2.9140622 c 0.09375 -0.003906 0.207031 0.042969 0.34375 0.136719 c 0.3125 0.203125 3.441406 2.90625 4.265624 3.679688 c 0.335938 0.320312 0.609376 0.65625 0.609376 0.753906 c 0 0.105468 -0.867188 0.933594 -2.164063 2.066406 c -3.035156 2.652344 -2.949219 2.59375 -3.253906 2.25 c -0.136719 -0.144531 -0.164063 -0.40625 -0.164063 -1.515625 v -1.339844 h -0.929687 c -0.816407 0 -0.9375 -0.023437 -1.027344 -0.1875 c -0.054687 -0.105469 -0.101563 -0.742187 -0.101563 -1.414062 c 0 -1.445313 0.054688 -1.558594 0.773438 -1.53125 c 0.25 0.007812 0.636719 0.011719 0.867188 0.007812 l 0.417968 -0.011719 v -1.230468 c 0 -1.167969 0.085938 -1.648438 0.363282 -1.664063 z m 0 0"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><g color="#000" fill="#2e3436"><path d="M7.188 2.281c-.094.056-.192.125-.29.19L5.566 3.803a1.684 1.684 0 11-2.17 2.17L2.332 7.037c.506-.069 1.017-.136 1.2.026.242.214.139 1.031.155 1.656.213.088.427.171.657.219.04.008.085-.007.125 0 .337-.525.683-1.288 1-1.344.322-.057.905.562 1.406.937a3.67 3.67 0 00.656-.468c-.195-.595-.594-1.369-.437-1.657.158-.29 1.019-.37 1.625-.531.028-.183.062-.371.062-.562 0-.075-.027-.146-.031-.22-.587-.217-1.435-.385-1.562-.687-.128-.302.34-1.021.593-1.593a3.722 3.722 0 00-.593-.532zm3.875 3.25c-.165.475-.305 1.086-.47 1.563-.43.047-.84.14-1.218.312-.38-.322-.787-.773-1.156-1.093a5.562 5.562 0 00-.688.468c.177.46.453 1.001.625 1.469-.298.309-.531.67-.719 1.063-.494 0-1.102-.084-1.593-.094a5.68 5.68 0 00-.219.812c.435.24 1.006.468 1.438.72-.006.093-.032.185-.032.28 0 .333.049.66.125.97-.382.304-.898.63-1.28.937.015.044.04.083.058.127l.613.613c.417-.1.868-.223 1.266-.303.248.343.532.626.875.875-.027.135-.068.283-.104.428.174-.063.34-.155.482-.297l1.432-1.432a1.994 1.994 0 01.533-3.918c.919 0 1.684.623 1.918 1.467l1.338-1.338c.06-.06.11-.124.156-.191-.035-.062-.06-.13-.1-.188.096-.152.205-.31.315-.47.017-.348-.1-.7-.37-.971l-.177-.176c-.28.192-.561.387-.83.555-.345-.233-.746-.383-1.156-.5-.077-.507-.107-1.132-.187-1.625a5.44 5.44 0 00-.875-.063zm-9.247.608c-.087.068-.173.138-.254.205l.014.035z" style="marker:none" overflow="visible"/><path d="M8.707.293a1 1 0 00-1.415 0l-6.999 7a1 1 0 000 1.413l7 7.001a1 1 0 001.415 0l7-7a1 1 0 000-1.413zm-.708 2.121l5.587 5.587L8 13.586 2.414 7.999z" style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;shape-padding:0;isolation:auto;mix-blend-mode:normal;marker:none" font-weight="400" font-family="sans-serif" overflow="visible"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@ -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"><g fill="#222222"><path d="m 4 5.992188 h 1 c 0.257812 0 0.527344 -0.128907 0.71875 -0.3125 l 1.28125 -1.28125 v 6.59375 h 2 v -6.59375 l 1.28125 1.28125 c 0.191406 0.183593 0.410156 0.3125 0.71875 0.3125 h 1 v -1 c 0 -0.308594 -0.089844 -0.550782 -0.28125 -0.75 l -3.71875 -3.65625 l -3.71875 3.65625 c -0.191406 0.199218 -0.28125 0.441406 -0.28125 0.75 z m 0 0"/><path d="m 4.039062 6.957031 c -1.664062 0 -3.03125 1.371094 -3.03125 3.035157 v 1.972656 c 0 1.664062 1.367188 3.035156 3.03125 3.035156 h 7.917969 c 1.664063 0 3.03125 -1.371094 3.03125 -3.035156 v -1.972656 c 0 -1.664063 -1.367187 -3.035157 -3.03125 -3.035157 h -0.957031 v 2 h 0.957031 c 0.589844 0 1.03125 0.445313 1.03125 1.035157 v 1.972656 c 0 0.589844 -0.441406 1.035156 -1.03125 1.035156 h -7.917969 c -0.589843 0 -1.03125 -0.445312 -1.03125 -1.035156 v -1.972656 c 0 -0.589844 0.441407 -1.035157 1.03125 -1.035157 h 0.960938 v -2 z m 0 0"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@ -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,4 +1,4 @@
|
|||||||
application_id = 'cz.bugsy.roster'
|
application_id = 'cz.vesp.roster'
|
||||||
|
|
||||||
scalable_dir = 'hicolor' / 'scalable' / 'apps'
|
scalable_dir = 'hicolor' / 'scalable' / 'apps'
|
||||||
install_data(
|
install_data(
|
||||||
@ -11,9 +11,3 @@ install_data(
|
|||||||
symbolic_dir / ('@0@-symbolic.svg').format(application_id),
|
symbolic_dir / ('@0@-symbolic.svg').format(application_id),
|
||||||
install_dir: get_option('datadir') / 'icons' / symbolic_dir
|
install_dir: get_option('datadir') / 'icons' / symbolic_dir
|
||||||
)
|
)
|
||||||
|
|
||||||
# Install custom export icon
|
|
||||||
install_data(
|
|
||||||
symbolic_dir / 'export-symbolic.svg',
|
|
||||||
install_dir: get_option('datadir') / 'icons' / symbolic_dir
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
desktop_file = i18n.merge_file(
|
desktop_file = i18n.merge_file(
|
||||||
input: 'cz.bugsy.roster.desktop.in',
|
input: 'cz.vesp.roster.desktop.in',
|
||||||
output: 'cz.bugsy.roster.desktop',
|
output: 'cz.vesp.roster.desktop',
|
||||||
type: 'desktop',
|
type: 'desktop',
|
||||||
po_dir: '../po',
|
po_dir: '../po',
|
||||||
install: true,
|
install: true,
|
||||||
@ -13,8 +13,8 @@ if desktop_utils.found()
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
appstream_file = i18n.merge_file(
|
appstream_file = i18n.merge_file(
|
||||||
input: 'cz.bugsy.roster.metainfo.xml.in',
|
input: 'cz.vesp.roster.metainfo.xml.in',
|
||||||
output: 'cz.bugsy.roster.metainfo.xml',
|
output: 'cz.vesp.roster.metainfo.xml',
|
||||||
po_dir: '../po',
|
po_dir: '../po',
|
||||||
install: true,
|
install: true,
|
||||||
install_dir: get_option('datadir') / 'metainfo'
|
install_dir: get_option('datadir') / 'metainfo'
|
||||||
@ -24,7 +24,7 @@ appstreamcli = find_program('appstreamcli', required: false, disabler: true)
|
|||||||
test('Validate appstream file', appstreamcli,
|
test('Validate appstream file', appstreamcli,
|
||||||
args: ['validate', '--no-net', '--explain', appstream_file])
|
args: ['validate', '--no-net', '--explain', appstream_file])
|
||||||
|
|
||||||
install_data('cz.bugsy.roster.gschema.xml',
|
install_data('cz.vesp.roster.gschema.xml',
|
||||||
install_dir: get_option('datadir') / 'glib-2.0' / 'schemas'
|
install_dir: get_option('datadir') / 'glib-2.0' / 'schemas'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -37,8 +37,8 @@ test('Validate schema file',
|
|||||||
service_conf = configuration_data()
|
service_conf = configuration_data()
|
||||||
service_conf.set('bindir', get_option('prefix') / get_option('bindir'))
|
service_conf.set('bindir', get_option('prefix') / get_option('bindir'))
|
||||||
configure_file(
|
configure_file(
|
||||||
input: 'cz.bugsy.roster.service.in',
|
input: 'cz.vesp.roster.service.in',
|
||||||
output: 'cz.bugsy.roster.service',
|
output: 'cz.vesp.roster.service',
|
||||||
configuration: service_conf,
|
configuration: service_conf,
|
||||||
install_dir: get_option('datadir') / 'dbus-1' / 'services'
|
install_dir: get_option('datadir') / 'dbus-1' / 'services'
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,158 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Example script demonstrating sensitive variable usage.
|
|
||||||
|
|
||||||
This script shows how to:
|
|
||||||
1. Create a project with variables
|
|
||||||
2. Mark some variables as sensitive
|
|
||||||
3. Set values for both regular and sensitive variables
|
|
||||||
4. Retrieve values from both storages
|
|
||||||
5. Use variables in request substitution
|
|
||||||
|
|
||||||
Run this from the project root after building the app.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Add src to path for testing
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
|
||||||
|
|
||||||
from roster.project_manager import ProjectManager
|
|
||||||
from roster.models import HttpRequest
|
|
||||||
from roster.variable_substitution import VariableSubstitution
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("=" * 60)
|
|
||||||
print("Sensitive Variables Example")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
pm = ProjectManager()
|
|
||||||
|
|
||||||
# Create a test project
|
|
||||||
print("\n1. Creating test project...")
|
|
||||||
project = pm.add_project("API Test Project")
|
|
||||||
project_id = project.id
|
|
||||||
print(f" Created project: {project.name} (ID: {project_id})")
|
|
||||||
|
|
||||||
# Add environments
|
|
||||||
print("\n2. Adding environments...")
|
|
||||||
env_prod = pm.add_environment(project_id, "Production")
|
|
||||||
env_dev = pm.add_environment(project_id, "Development")
|
|
||||||
print(f" - Production (ID: {env_prod.id})")
|
|
||||||
print(f" - Development (ID: {env_dev.id})")
|
|
||||||
|
|
||||||
# Add variables
|
|
||||||
print("\n3. Adding variables...")
|
|
||||||
pm.add_variable(project_id, "base_url")
|
|
||||||
pm.add_variable(project_id, "api_key")
|
|
||||||
pm.add_variable(project_id, "timeout")
|
|
||||||
print(" - base_url (regular)")
|
|
||||||
print(" - api_key (regular for now)")
|
|
||||||
print(" - timeout (regular)")
|
|
||||||
|
|
||||||
# Set values for regular variables
|
|
||||||
print("\n4. Setting values for regular variables...")
|
|
||||||
pm.set_variable_value(project_id, env_prod.id, "base_url", "https://api.example.com")
|
|
||||||
pm.set_variable_value(project_id, env_dev.id, "base_url", "https://dev.api.example.com")
|
|
||||||
pm.set_variable_value(project_id, env_prod.id, "timeout", "30")
|
|
||||||
pm.set_variable_value(project_id, env_dev.id, "timeout", "60")
|
|
||||||
print(" ✓ Set base_url for both environments")
|
|
||||||
print(" ✓ Set timeout for both environments")
|
|
||||||
|
|
||||||
# Set API key values (still regular)
|
|
||||||
print("\n5. Setting API key values (in JSON - NOT SECURE YET)...")
|
|
||||||
pm.set_variable_value(project_id, env_prod.id, "api_key", "prod-secret-key-12345")
|
|
||||||
pm.set_variable_value(project_id, env_dev.id, "api_key", "dev-secret-key-67890")
|
|
||||||
print(" ⚠ API keys stored in plain JSON file!")
|
|
||||||
|
|
||||||
# Mark API key as sensitive
|
|
||||||
print("\n6. Marking api_key as SENSITIVE...")
|
|
||||||
success = pm.mark_variable_as_sensitive(project_id, "api_key")
|
|
||||||
if success:
|
|
||||||
print(" ✓ api_key is now stored in GNOME Keyring (encrypted)")
|
|
||||||
print(" ✓ Values moved from JSON to keyring")
|
|
||||||
print(" ✓ JSON now contains empty placeholders")
|
|
||||||
else:
|
|
||||||
print(" ✗ Failed to mark as sensitive")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Verify storage
|
|
||||||
print("\n7. Verifying storage...")
|
|
||||||
is_sensitive = pm.is_variable_sensitive(project_id, "api_key")
|
|
||||||
print(f" - api_key is sensitive: {is_sensitive}")
|
|
||||||
|
|
||||||
# Retrieve values
|
|
||||||
print("\n8. Retrieving values...")
|
|
||||||
base_url_prod = pm.get_variable_value(project_id, env_prod.id, "base_url")
|
|
||||||
api_key_prod = pm.get_variable_value(project_id, env_prod.id, "api_key")
|
|
||||||
api_key_dev = pm.get_variable_value(project_id, env_dev.id, "api_key")
|
|
||||||
|
|
||||||
print(f" - Production base_url: {base_url_prod}")
|
|
||||||
print(f" - Production api_key: {api_key_prod} (from keyring)")
|
|
||||||
print(f" - Development api_key: {api_key_dev} (from keyring)")
|
|
||||||
|
|
||||||
# Use in request substitution
|
|
||||||
print("\n9. Using in request substitution...")
|
|
||||||
request = HttpRequest(
|
|
||||||
method="GET",
|
|
||||||
url="{{base_url}}/users",
|
|
||||||
headers={
|
|
||||||
"Authorization": "Bearer {{api_key}}",
|
|
||||||
"X-Timeout": "{{timeout}}"
|
|
||||||
},
|
|
||||||
body=""
|
|
||||||
)
|
|
||||||
print(f" Original request URL: {request.url}")
|
|
||||||
print(f" Original request headers: {request.headers}")
|
|
||||||
|
|
||||||
# Get environment with secrets
|
|
||||||
env_with_secrets = pm.get_environment_with_secrets(project_id, env_prod.id)
|
|
||||||
|
|
||||||
# Substitute variables
|
|
||||||
vs = VariableSubstitution()
|
|
||||||
substituted_request, undefined = vs.substitute_request(request, env_with_secrets)
|
|
||||||
|
|
||||||
print(f"\n Substituted request URL: {substituted_request.url}")
|
|
||||||
print(f" Substituted headers: {substituted_request.headers}")
|
|
||||||
print(f" Undefined variables: {undefined}")
|
|
||||||
|
|
||||||
# Demonstrate file storage
|
|
||||||
print("\n10. Checking file storage...")
|
|
||||||
projects = pm.load_projects()
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
print(f" Variables: {p.variable_names}")
|
|
||||||
print(f" Sensitive variables: {p.sensitive_variables}")
|
|
||||||
for env in p.environments:
|
|
||||||
if env.id == env_prod.id:
|
|
||||||
print(f" Production variables in JSON: {env.variables}")
|
|
||||||
print(f" Notice: api_key is empty (value in keyring)")
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
print("\n11. Cleanup...")
|
|
||||||
response = input(" Delete test project? (y/n): ")
|
|
||||||
if response.lower() == 'y':
|
|
||||||
pm.delete_project(project_id)
|
|
||||||
print(" ✓ Project deleted (secrets also removed from keyring)")
|
|
||||||
else:
|
|
||||||
print(f" Project kept. ID: {project_id}")
|
|
||||||
print(" You can view secrets in 'Passwords and Keys' app")
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("Example completed!")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
main()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\nInterrupted by user")
|
|
||||||
sys.exit(1)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n\nError: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
sys.exit(1)
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
project('roster',
|
project('roster',
|
||||||
version: '0.11.0',
|
version: '0.1.0',
|
||||||
meson_version: '>= 1.0.0',
|
meson_version: '>= 1.0.0',
|
||||||
default_options: [ 'warning_level=2', 'werror=false', ],
|
default_options: [ 'warning_level=2', 'werror=false', ],
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,21 +1,8 @@
|
|||||||
# List of source files containing translatable strings.
|
# List of source files containing translatable strings.
|
||||||
# Please keep this file sorted alphabetically.
|
# Please keep this file sorted alphabetically.
|
||||||
data/cz.bugsy.roster.desktop.in
|
data/cz.vesp.roster.desktop.in
|
||||||
data/cz.bugsy.roster.metainfo.xml.in
|
data/cz.vesp.roster.metainfo.xml.in
|
||||||
data/cz.bugsy.roster.gschema.xml
|
data/cz.vesp.roster.gschema.xml
|
||||||
src/environments_dialog.py
|
|
||||||
src/environments-dialog.ui
|
|
||||||
src/export_dialog.py
|
|
||||||
src/export-dialog.ui
|
|
||||||
src/icon_picker_dialog.py
|
|
||||||
src/icon-picker-dialog.ui
|
|
||||||
src/main.py
|
src/main.py
|
||||||
src/main-window.ui
|
|
||||||
src/preferences_dialog.py
|
|
||||||
src/preferences-dialog.ui
|
|
||||||
src/project_manager.py
|
|
||||||
src/request_tab_widget.py
|
|
||||||
src/secret_manager.py
|
|
||||||
src/shortcuts-dialog.ui
|
|
||||||
src/tab_manager.py
|
|
||||||
src/window.py
|
src/window.py
|
||||||
|
src/window.ui
|
||||||
|
|||||||
182
po/roster.pot
@ -1,182 +0,0 @@
|
|||||||
# SOME DESCRIPTIVE TITLE.
|
|
||||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
|
||||||
# This file is distributed under the same license as the roster package.
|
|
||||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
|
||||||
#
|
|
||||||
#, fuzzy
|
|
||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: roster\n"
|
|
||||||
"Report-Msgid-Bugs-To: \n"
|
|
||||||
"POT-Creation-Date: 2026-01-13 17:55+0100\n"
|
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
|
||||||
"Language: \n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=CHARSET\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
|
|
||||||
#: data/cz.bugsy.roster.desktop.in:2 data/cz.bugsy.roster.metainfo.xml.in:7
|
|
||||||
msgid "Roster"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: data/cz.bugsy.roster.desktop.in:3 data/cz.bugsy.roster.metainfo.xml.in:8
|
|
||||||
msgid "HTTP client for API testing"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: data/cz.bugsy.roster.desktop.in:9
|
|
||||||
msgid "HTTP;REST;API;Client;Request;JSON;"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: data/cz.bugsy.roster.metainfo.xml.in:10
|
|
||||||
msgid ""
|
|
||||||
"Roster is a modern HTTP client for testing and debugging REST APIs. It "
|
|
||||||
"provides a clean, GNOME-native interface for making HTTP requests and "
|
|
||||||
"inspecting responses."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: data/cz.bugsy.roster.metainfo.xml.in:11
|
|
||||||
msgid ""
|
|
||||||
"Note: Application-specific icons included with this project are licensed "
|
|
||||||
"under CC-BY-SA-3.0. GNOME system icons are provided by the system and are "
|
|
||||||
"not distributed with this application. All other components are licensed "
|
|
||||||
"under GPL-3.0-or-later."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: data/cz.bugsy.roster.metainfo.xml.in:15
|
|
||||||
msgid "Pavel Baksy"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: data/cz.bugsy.roster.metainfo.xml.in:32
|
|
||||||
#: data/cz.bugsy.roster.metainfo.xml.in:36
|
|
||||||
msgid "A caption"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: data/cz.bugsy.roster.gschema.xml:6
|
|
||||||
msgid "Force TLS verification"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: data/cz.bugsy.roster.gschema.xml:7
|
|
||||||
msgid ""
|
|
||||||
"When enabled, TLS certificates will be verified. Disable to test endpoints "
|
|
||||||
"with self-signed or invalid certificates."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: data/cz.bugsy.roster.gschema.xml:11
|
|
||||||
msgid "Request timeout"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: data/cz.bugsy.roster.gschema.xml:12
|
|
||||||
msgid "Timeout for HTTP requests in seconds"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#. Translators: Replace "translator-credits" with your name/username, and optionally an email or URL.
|
|
||||||
#: src/main.py:79
|
|
||||||
msgid "translator-credits"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/main-window.ui:132
|
|
||||||
msgid "Main Menu"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/main-window.ui:234
|
|
||||||
msgid "_Preferences"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/main-window.ui:238
|
|
||||||
msgid "_Keyboard Shortcuts"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/main-window.ui:242
|
|
||||||
msgid "_About Roster"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/preferences-dialog.ui:7
|
|
||||||
msgid "Preferences"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/preferences-dialog.ui:14
|
|
||||||
msgid "General"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/preferences-dialog.ui:19
|
|
||||||
msgid "Network"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/preferences-dialog.ui:20
|
|
||||||
msgid "HTTP connection settings"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/preferences-dialog.ui:24
|
|
||||||
msgid "Force TLS Verification"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/preferences-dialog.ui:25
|
|
||||||
msgid ""
|
|
||||||
"Verify TLS certificates for HTTPS connections. Disable to test endpoints "
|
|
||||||
"with self-signed certificates."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/preferences-dialog.ui:31
|
|
||||||
msgid "Request Timeout"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/preferences-dialog.ui:32
|
|
||||||
msgid "Maximum time to wait for a response (in seconds)"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/preferences-dialog.ui:49
|
|
||||||
msgid "History"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/preferences-dialog.ui:50
|
|
||||||
msgid "Manage request history"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/preferences-dialog.ui:54
|
|
||||||
msgid "Clear All History"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/preferences-dialog.ui:55
|
|
||||||
msgid "Remove all saved request and response history"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/preferences-dialog.ui:58
|
|
||||||
msgid "Clear"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/shortcuts-dialog.ui:13
|
|
||||||
msgctxt "shortcut window"
|
|
||||||
msgid "New Tab"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/shortcuts-dialog.ui:19
|
|
||||||
msgctxt "shortcut window"
|
|
||||||
msgid "Close Tab"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/shortcuts-dialog.ui:25
|
|
||||||
msgctxt "shortcut window"
|
|
||||||
msgid "Save Request"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/shortcuts-dialog.ui:31
|
|
||||||
msgctxt "shortcut window"
|
|
||||||
msgid "Focus URL Field"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/shortcuts-dialog.ui:37
|
|
||||||
msgctxt "shortcut window"
|
|
||||||
msgid "Send Request"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/shortcuts-dialog.ui:43
|
|
||||||
msgctxt "shortcut window"
|
|
||||||
msgid "Show Shortcuts"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/shortcuts-dialog.ui:49
|
|
||||||
msgctxt "shortcut window"
|
|
||||||
msgid "Quit"
|
|
||||||
msgstr ""
|
|
||||||
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 67 KiB |
@ -7,7 +7,6 @@
|
|||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
@ -18,38 +17,6 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
# Application Version
|
|
||||||
# Set at runtime by main.py from the version passed by the launcher script
|
|
||||||
VERSION = "0.0.0" # Fallback for development/testing
|
|
||||||
|
|
||||||
# UI Layout Constants
|
|
||||||
UI_PANE_REQUEST_RESPONSE_POSITION = 510 # Initial position for request/response split
|
|
||||||
UI_PANE_RESPONSE_DETAILS_POSITION = 400 # Initial position for response body/headers split
|
|
||||||
UI_PANE_RESULTS_PANEL_POSITION = 120 # Initial position for script results panel
|
|
||||||
UI_PANE_SCRIPTS_POSITION = 300 # Initial position for scripts tab panes
|
|
||||||
UI_SCRIPT_RESULTS_PANEL_HEIGHT = 150 # Height reserved for script results panel
|
|
||||||
UI_TOAST_TIMEOUT_SECONDS = 3 # Duration to show toast notifications
|
|
||||||
|
|
||||||
# Debounce/Delay Timeouts (milliseconds)
|
|
||||||
DEBOUNCE_VARIABLE_INDICATORS_MS = 500 # Delay before updating variable indicators
|
|
||||||
DELAY_TAB_CREATION_MS = 50 # Delay before creating new tab after last one closes
|
|
||||||
|
|
||||||
# Data Display Limits
|
|
||||||
DISPLAY_VARIABLE_MAX_LENGTH = 50 # Max characters to display for variable values
|
|
||||||
DISPLAY_VARIABLE_TRUNCATE_SUFFIX = "..." # Suffix for truncated variable values
|
|
||||||
DISPLAY_URL_NAME_MAX_LENGTH = 30 # Max characters for URL in generated tab names
|
|
||||||
|
|
||||||
# Data Storage Limits
|
|
||||||
HISTORY_MAX_ENTRIES = 100 # Maximum number of history entries to keep
|
|
||||||
PROJECT_NAME_MAX_LENGTH = 100 # Maximum length for project names
|
|
||||||
REQUEST_NAME_MAX_LENGTH = 200 # Maximum length for request names
|
|
||||||
|
|
||||||
# HTTP Client Settings
|
|
||||||
HTTP_DEFAULT_TIMEOUT_SECONDS = 30 # Default timeout for HTTP requests
|
|
||||||
|
|
||||||
# Script Execution Settings
|
|
||||||
SCRIPT_EXECUTION_TIMEOUT_SECONDS = 5 # Maximum execution time for scripts
|
|
||||||
|
|
||||||
# 36 symbolic icons for projects (6x6 grid)
|
# 36 symbolic icons for projects (6x6 grid)
|
||||||
PROJECT_ICONS = [
|
PROJECT_ICONS = [
|
||||||
# Row 1: Folders & Organization
|
# Row 1: Folders & Organization
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<interface>
|
|
||||||
<requires lib="gtk" version="4.0"/>
|
|
||||||
<requires lib="libadwaita" version="1.0"/>
|
|
||||||
|
|
||||||
<template class="EnvironmentsDialog" parent="AdwDialog">
|
|
||||||
<property name="title">Manage Environments</property>
|
|
||||||
<property name="content-width">1200</property>
|
|
||||||
<property name="content-height">600</property>
|
|
||||||
<property name="follows-content-size">false</property>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="AdwToolbarView">
|
|
||||||
<child type="top">
|
|
||||||
<object class="AdwHeaderBar">
|
|
||||||
<property name="show-title">true</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<property name="content">
|
|
||||||
<object class="GtkScrolledWindow" id="scrolled_window">
|
|
||||||
<property name="vexpand">true</property>
|
|
||||||
<child>
|
|
||||||
<object class="AdwClamp">
|
|
||||||
<property name="maximum-size">1200</property>
|
|
||||||
<property name="tightening-threshold">800</property>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox">
|
|
||||||
<property name="orientation">vertical</property>
|
|
||||||
<property name="margin-start">12</property>
|
|
||||||
<property name="margin-end">12</property>
|
|
||||||
<property name="margin-top">12</property>
|
|
||||||
<property name="margin-bottom">12</property>
|
|
||||||
<property name="spacing">12</property>
|
|
||||||
|
|
||||||
<!-- Header Section -->
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">12</property>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkLabel">
|
|
||||||
<property name="label">Environments</property>
|
|
||||||
<property name="xalign">0</property>
|
|
||||||
<property name="hexpand">True</property>
|
|
||||||
<style>
|
|
||||||
<class name="title-4"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkButton" id="add_environment_button">
|
|
||||||
<property name="label">Add Environment</property>
|
|
||||||
<property name="icon-name">list-add-symbolic</property>
|
|
||||||
<property name="tooltip-text">Add environment</property>
|
|
||||||
<signal name="clicked" handler="on_add_environment_clicked"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<!-- Table Frame -->
|
|
||||||
<child>
|
|
||||||
<object class="GtkFrame">
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox" id="table_container">
|
|
||||||
<property name="orientation">vertical</property>
|
|
||||||
<style>
|
|
||||||
<class name="boxed-list"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</template>
|
|
||||||
</interface>
|
|
||||||
@ -1,344 +0,0 @@
|
|||||||
# environments_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 re
|
|
||||||
from gi.repository import Adw, Gtk, GObject
|
|
||||||
from .widgets.variable_row import VariableRow
|
|
||||||
from .widgets.environment_row import EnvironmentRow
|
|
||||||
from .widgets.environment_header_row import EnvironmentHeaderRow
|
|
||||||
from .widgets.variable_data_row import VariableDataRow
|
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/environments-dialog.ui')
|
|
||||||
class EnvironmentsDialog(Adw.Dialog):
|
|
||||||
"""Dialog for managing project environments and variables."""
|
|
||||||
|
|
||||||
__gtype_name__ = 'EnvironmentsDialog'
|
|
||||||
|
|
||||||
table_container = Gtk.Template.Child()
|
|
||||||
add_environment_button = Gtk.Template.Child()
|
|
||||||
scrolled_window = Gtk.Template.Child()
|
|
||||||
|
|
||||||
__gsignals__ = {
|
|
||||||
'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, project, project_manager, recently_updated_variables=None):
|
|
||||||
super().__init__()
|
|
||||||
self.project = project
|
|
||||||
self.project_manager = project_manager
|
|
||||||
self.recently_updated_variables = recently_updated_variables or {}
|
|
||||||
self.size_group = Gtk.SizeGroup(mode=Gtk.SizeGroupMode.HORIZONTAL)
|
|
||||||
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."""
|
|
||||||
# Clear existing
|
|
||||||
while child := self.table_container.get_first_child():
|
|
||||||
self.table_container.remove(child)
|
|
||||||
|
|
||||||
self.data_rows = []
|
|
||||||
|
|
||||||
# Create header row
|
|
||||||
self.header_row = EnvironmentHeaderRow(
|
|
||||||
self.project.environments,
|
|
||||||
self.size_group
|
|
||||||
)
|
|
||||||
self.header_row.connect('environment-edit-requested',
|
|
||||||
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
|
|
||||||
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
|
||||||
self.table_container.append(separator)
|
|
||||||
|
|
||||||
# Create data rows for each variable
|
|
||||||
for var_name in self.project.variable_names:
|
|
||||||
# Check if variable was recently updated
|
|
||||||
update_timestamp = self.recently_updated_variables.get(var_name)
|
|
||||||
|
|
||||||
row = VariableDataRow(
|
|
||||||
var_name,
|
|
||||||
self.project.environments,
|
|
||||||
self.size_group,
|
|
||||||
self.project,
|
|
||||||
self.project_manager,
|
|
||||||
update_timestamp=update_timestamp
|
|
||||||
)
|
|
||||||
row.connect('variable-changed',
|
|
||||||
self._on_variable_changed, var_name, row)
|
|
||||||
row.connect('variable-delete-requested',
|
|
||||||
self._on_variable_remove, var_name)
|
|
||||||
row.connect('value-changed',
|
|
||||||
self._on_value_changed, var_name)
|
|
||||||
row.connect('sensitivity-changed',
|
|
||||||
self._on_sensitivity_changed, var_name)
|
|
||||||
|
|
||||||
self.table_container.append(row)
|
|
||||||
self.data_rows.append(row)
|
|
||||||
|
|
||||||
# Add "Add Variable" button row at the bottom
|
|
||||||
add_var_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
|
||||||
add_var_row.set_margin_start(12)
|
|
||||||
add_var_row.set_margin_end(12)
|
|
||||||
add_var_row.set_margin_top(6)
|
|
||||||
add_var_row.set_margin_bottom(6)
|
|
||||||
|
|
||||||
# Variable cell with add button
|
|
||||||
var_cell = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
|
||||||
var_cell.set_size_request(150, -1)
|
|
||||||
if self.size_group:
|
|
||||||
self.size_group.add_widget(var_cell)
|
|
||||||
|
|
||||||
add_variable_button = Gtk.Button()
|
|
||||||
add_variable_button.set_icon_name("list-add-symbolic")
|
|
||||||
add_variable_button.set_tooltip_text("Add variable")
|
|
||||||
add_variable_button.connect('clicked', self.on_add_variable_clicked)
|
|
||||||
add_variable_button.add_css_class("flat")
|
|
||||||
add_variable_button.add_css_class("circular")
|
|
||||||
|
|
||||||
var_cell.append(add_variable_button)
|
|
||||||
add_var_row.append(var_cell)
|
|
||||||
|
|
||||||
# Empty space for value columns
|
|
||||||
empty_space = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
|
||||||
empty_space.set_hexpand(True)
|
|
||||||
add_var_row.append(empty_space)
|
|
||||||
|
|
||||||
self.table_container.append(add_var_row)
|
|
||||||
|
|
||||||
def on_add_variable_clicked(self, button):
|
|
||||||
"""Add new variable."""
|
|
||||||
dialog = Adw.AlertDialog()
|
|
||||||
dialog.set_heading("New Variable")
|
|
||||||
dialog.set_body("Enter variable name:")
|
|
||||||
|
|
||||||
entry = Gtk.Entry()
|
|
||||||
entry.set_placeholder_text("Variable name")
|
|
||||||
entry.set_activates_default(True)
|
|
||||||
dialog.set_extra_child(entry)
|
|
||||||
|
|
||||||
dialog.add_response("cancel", "Cancel")
|
|
||||||
dialog.add_response("add", "Add")
|
|
||||||
dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)
|
|
||||||
dialog.set_default_response("add")
|
|
||||||
dialog.set_close_response("cancel")
|
|
||||||
|
|
||||||
def on_response(dlg, response):
|
|
||||||
if response == "add":
|
|
||||||
name = entry.get_text().strip()
|
|
||||||
if name and name not in self.project.variable_names:
|
|
||||||
self.project_manager.add_variable(self.project.id, name)
|
|
||||||
# Reload project data
|
|
||||||
self._reload_project()
|
|
||||||
self._populate_table()
|
|
||||||
self.emit('environments-updated')
|
|
||||||
|
|
||||||
dialog.connect("response", on_response)
|
|
||||||
dialog.present(self)
|
|
||||||
|
|
||||||
def _on_variable_remove(self, widget, var_name):
|
|
||||||
"""Remove variable with confirmation."""
|
|
||||||
dialog = Adw.AlertDialog()
|
|
||||||
dialog.set_heading("Delete Variable?")
|
|
||||||
dialog.set_body(f"Delete variable '{var_name}' from all environments? This cannot be undone.")
|
|
||||||
|
|
||||||
dialog.add_response("cancel", "Cancel")
|
|
||||||
dialog.add_response("delete", "Delete")
|
|
||||||
dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE)
|
|
||||||
dialog.set_close_response("cancel")
|
|
||||||
|
|
||||||
def on_response(dlg, response):
|
|
||||||
if response == "delete":
|
|
||||||
def on_delete_complete():
|
|
||||||
self._reload_project()
|
|
||||||
self._populate_table()
|
|
||||||
self.emit('environments-updated')
|
|
||||||
|
|
||||||
self.project_manager.delete_variable(self.project.id, var_name, on_delete_complete)
|
|
||||||
|
|
||||||
dialog.connect("response", on_response)
|
|
||||||
dialog.present(self)
|
|
||||||
|
|
||||||
def _on_variable_changed(self, widget, old_name, row):
|
|
||||||
"""Handle variable name change."""
|
|
||||||
new_name = row.get_variable_name()
|
|
||||||
if new_name and new_name != old_name and new_name not in self.project.variable_names:
|
|
||||||
def on_rename_complete():
|
|
||||||
self._reload_project()
|
|
||||||
self._populate_table()
|
|
||||||
self.emit('environments-updated')
|
|
||||||
|
|
||||||
self.project_manager.rename_variable(self.project.id, old_name, new_name, on_rename_complete)
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_add_environment_clicked(self, button):
|
|
||||||
"""Add new environment."""
|
|
||||||
dialog = Adw.AlertDialog()
|
|
||||||
dialog.set_heading("New Environment")
|
|
||||||
dialog.set_body("Enter environment name:")
|
|
||||||
|
|
||||||
entry = Gtk.Entry()
|
|
||||||
entry.set_placeholder_text("Environment name (e.g., Production)")
|
|
||||||
entry.set_activates_default(True)
|
|
||||||
dialog.set_extra_child(entry)
|
|
||||||
|
|
||||||
dialog.add_response("cancel", "Cancel")
|
|
||||||
dialog.add_response("add", "Add")
|
|
||||||
dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)
|
|
||||||
dialog.set_default_response("add")
|
|
||||||
dialog.set_close_response("cancel")
|
|
||||||
|
|
||||||
def on_response(dlg, response):
|
|
||||||
if response == "add":
|
|
||||||
name = entry.get_text().strip()
|
|
||||||
if name:
|
|
||||||
self.project_manager.add_environment(self.project.id, name)
|
|
||||||
self._reload_project()
|
|
||||||
self._populate_table()
|
|
||||||
self.emit('environments-updated')
|
|
||||||
|
|
||||||
dialog.connect("response", on_response)
|
|
||||||
dialog.present(self)
|
|
||||||
|
|
||||||
def _on_environment_edit(self, widget, environment):
|
|
||||||
"""Edit environment name."""
|
|
||||||
dialog = Adw.AlertDialog()
|
|
||||||
dialog.set_heading("Edit Environment")
|
|
||||||
dialog.set_body("Enter new name:")
|
|
||||||
|
|
||||||
entry = Gtk.Entry()
|
|
||||||
entry.set_text(environment.name)
|
|
||||||
entry.set_activates_default(True)
|
|
||||||
dialog.set_extra_child(entry)
|
|
||||||
|
|
||||||
dialog.add_response("cancel", "Cancel")
|
|
||||||
dialog.add_response("save", "Save")
|
|
||||||
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
|
|
||||||
dialog.set_default_response("save")
|
|
||||||
dialog.set_close_response("cancel")
|
|
||||||
|
|
||||||
def on_response(dlg, response):
|
|
||||||
if response == "save":
|
|
||||||
new_name = entry.get_text().strip()
|
|
||||||
if new_name:
|
|
||||||
self.project_manager.update_environment(self.project.id, environment.id, name=new_name)
|
|
||||||
self._reload_project()
|
|
||||||
self._populate_table()
|
|
||||||
self.emit('environments-updated')
|
|
||||||
|
|
||||||
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
|
|
||||||
if len(self.project.environments) <= 1:
|
|
||||||
dialog = Adw.AlertDialog()
|
|
||||||
dialog.set_heading("Cannot Delete")
|
|
||||||
dialog.set_body("Each project must have at least one environment.")
|
|
||||||
dialog.add_response("ok", "OK")
|
|
||||||
dialog.present(self)
|
|
||||||
return
|
|
||||||
|
|
||||||
dialog = Adw.AlertDialog()
|
|
||||||
dialog.set_heading("Delete Environment?")
|
|
||||||
dialog.set_body(f"Delete environment '{environment.name}'? This cannot be undone.")
|
|
||||||
|
|
||||||
dialog.add_response("cancel", "Cancel")
|
|
||||||
dialog.add_response("delete", "Delete")
|
|
||||||
dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE)
|
|
||||||
dialog.set_close_response("cancel")
|
|
||||||
|
|
||||||
def on_response(dlg, response):
|
|
||||||
if response == "delete":
|
|
||||||
def on_delete_complete():
|
|
||||||
self._reload_project()
|
|
||||||
self._populate_table()
|
|
||||||
self.emit('environments-updated')
|
|
||||||
|
|
||||||
self.project_manager.delete_environment(self.project.id, environment.id, on_delete_complete)
|
|
||||||
|
|
||||||
dialog.connect("response", on_response)
|
|
||||||
dialog.present(self)
|
|
||||||
|
|
||||||
def _on_value_changed(self, widget, env_id, value, var_name):
|
|
||||||
"""Handle value change in table cell."""
|
|
||||||
# Note: value is already stored by VariableDataRow using set_variable_value
|
|
||||||
# This handler is just for emitting the update signal
|
|
||||||
self.emit('environments-updated')
|
|
||||||
|
|
||||||
def _on_sensitivity_changed(self, widget, is_sensitive, var_name):
|
|
||||||
"""Handle variable sensitivity toggle."""
|
|
||||||
def on_complete(success):
|
|
||||||
if success:
|
|
||||||
# Reload and refresh UI
|
|
||||||
self._reload_project()
|
|
||||||
self._populate_table()
|
|
||||||
self.emit('environments-updated')
|
|
||||||
|
|
||||||
if is_sensitive:
|
|
||||||
# Mark as sensitive (move to keyring)
|
|
||||||
self.project_manager.mark_variable_as_sensitive(self.project.id, var_name, on_complete)
|
|
||||||
else:
|
|
||||||
# Mark as non-sensitive (move to JSON)
|
|
||||||
self.project_manager.mark_variable_as_nonsensitive(self.project.id, var_name, on_complete)
|
|
||||||
|
|
||||||
def _reload_project(self):
|
|
||||||
"""Reload project data from manager."""
|
|
||||||
projects = self.project_manager.load_projects()
|
|
||||||
for p in projects:
|
|
||||||
if p.id == self.project.id:
|
|
||||||
self.project = p
|
|
||||||
break
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<interface>
|
|
||||||
<requires lib="gtk" version="4.0"/>
|
|
||||||
<requires lib="Adw" version="1.0"/>
|
|
||||||
|
|
||||||
<template class="ExportDialog" parent="AdwDialog">
|
|
||||||
<property name="title">Export Request</property>
|
|
||||||
<property name="content-width">600</property>
|
|
||||||
<property name="content-height">400</property>
|
|
||||||
|
|
||||||
<property name="child">
|
|
||||||
<object class="AdwToolbarView">
|
|
||||||
<child type="top">
|
|
||||||
<object class="AdwHeaderBar">
|
|
||||||
<property name="show-title">true</property>
|
|
||||||
<child type="end">
|
|
||||||
<object class="GtkButton" id="copy_button">
|
|
||||||
<property name="label">Copy</property>
|
|
||||||
<property name="tooltip-text">Copy to Clipboard</property>
|
|
||||||
<signal name="clicked" handler="on_copy_clicked"/>
|
|
||||||
<style>
|
|
||||||
<class name="suggested-action"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<property name="content">
|
|
||||||
<object class="GtkBox">
|
|
||||||
<property name="orientation">vertical</property>
|
|
||||||
<property name="spacing">12</property>
|
|
||||||
<property name="margin-start">12</property>
|
|
||||||
<property name="margin-end">12</property>
|
|
||||||
<property name="margin-top">12</property>
|
|
||||||
<property name="margin-bottom">12</property>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkScrolledWindow">
|
|
||||||
<property name="vexpand">true</property>
|
|
||||||
<property name="hexpand">true</property>
|
|
||||||
<child>
|
|
||||||
<object class="GtkTextView" id="export_textview">
|
|
||||||
<property name="editable">false</property>
|
|
||||||
<property name="monospace">true</property>
|
|
||||||
<property name="wrap-mode">none</property>
|
|
||||||
<property name="left-margin">12</property>
|
|
||||||
<property name="right-margin">12</property>
|
|
||||||
<property name="top-margin">12</property>
|
|
||||||
<property name="bottom-margin">12</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</template>
|
|
||||||
</interface>
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
# export_dialog.py
|
|
||||||
#
|
|
||||||
# Copyright 2025 Pavel Baksy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
from gi.repository import Adw, Gtk, Gdk, GLib
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/export-dialog.ui')
|
|
||||||
class ExportDialog(Adw.Dialog):
|
|
||||||
"""Dialog for exporting HTTP requests in various formats."""
|
|
||||||
|
|
||||||
__gtype_name__ = 'ExportDialog'
|
|
||||||
|
|
||||||
export_textview = Gtk.Template.Child()
|
|
||||||
copy_button = Gtk.Template.Child()
|
|
||||||
|
|
||||||
def __init__(self, request, **kwargs):
|
|
||||||
"""
|
|
||||||
Initialize export dialog.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: HttpRequest to export (should have variables substituted)
|
|
||||||
"""
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self.request = request
|
|
||||||
self._populate_export()
|
|
||||||
|
|
||||||
def _populate_export(self):
|
|
||||||
"""Generate and display the export content."""
|
|
||||||
try:
|
|
||||||
# Get cURL exporter
|
|
||||||
from .exporters import ExporterRegistry
|
|
||||||
exporter = ExporterRegistry.get_exporter('curl')
|
|
||||||
|
|
||||||
# Generate cURL command
|
|
||||||
curl_command = exporter.export(self.request)
|
|
||||||
|
|
||||||
# Display in textview
|
|
||||||
buffer = self.export_textview.get_buffer()
|
|
||||||
buffer.set_text(curl_command)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Export generation failed: {e}")
|
|
||||||
buffer = self.export_textview.get_buffer()
|
|
||||||
buffer.set_text(f"Error generating export: {str(e)}")
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_copy_clicked(self, button):
|
|
||||||
"""Copy export content to clipboard."""
|
|
||||||
# Get text from buffer
|
|
||||||
buffer = self.export_textview.get_buffer()
|
|
||||||
start = buffer.get_start_iter()
|
|
||||||
end = buffer.get_end_iter()
|
|
||||||
text = buffer.get_text(start, end, False)
|
|
||||||
|
|
||||||
# Copy to clipboard using Gdk.Clipboard API
|
|
||||||
display = Gdk.Display.get_default()
|
|
||||||
clipboard = display.get_clipboard()
|
|
||||||
clipboard.set(text)
|
|
||||||
|
|
||||||
# Visual feedback: temporarily change button label
|
|
||||||
original_label = button.get_label()
|
|
||||||
button.set_label("Copied!")
|
|
||||||
|
|
||||||
# Reset after 2 seconds
|
|
||||||
def reset_label():
|
|
||||||
button.set_label(original_label)
|
|
||||||
return False # Don't repeat
|
|
||||||
|
|
||||||
GLib.timeout_add(2000, reset_label)
|
|
||||||
|
|
||||||
logger.debug("Export copied to clipboard")
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
# exporters package
|
|
||||||
#
|
|
||||||
# Copyright 2025 Pavel Baksy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
from .registry import ExporterRegistry
|
|
||||||
from .base_exporter import BaseExporter
|
|
||||||
from .curl_exporter import CurlExporter
|
|
||||||
|
|
||||||
__all__ = ['ExporterRegistry', 'BaseExporter', 'CurlExporter']
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
# base_exporter.py
|
|
||||||
#
|
|
||||||
# Copyright 2025 Pavel Baksy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from ..models import HttpRequest
|
|
||||||
|
|
||||||
|
|
||||||
class BaseExporter(ABC):
|
|
||||||
"""Abstract base class for request exporters."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def export(self, request: HttpRequest) -> str:
|
|
||||||
"""
|
|
||||||
Export HTTP request to target format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: HttpRequest with method, url, headers, body
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted export string
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def format_name(self) -> str:
|
|
||||||
"""Human-readable format name (e.g., 'cURL', 'Insomnia')."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def file_extension(self) -> str:
|
|
||||||
"""File extension for export (e.g., 'sh', 'json')."""
|
|
||||||
pass
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
# curl_exporter.py
|
|
||||||
#
|
|
||||||
# Copyright 2025 Pavel Baksy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
import shlex
|
|
||||||
from .base_exporter import BaseExporter
|
|
||||||
from ..models import HttpRequest
|
|
||||||
|
|
||||||
|
|
||||||
class CurlExporter(BaseExporter):
|
|
||||||
"""Export HTTP requests as cURL commands."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def format_name(self) -> str:
|
|
||||||
return "cURL"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def file_extension(self) -> str:
|
|
||||||
return "sh"
|
|
||||||
|
|
||||||
def export(self, request: HttpRequest) -> str:
|
|
||||||
"""
|
|
||||||
Generate cURL command with proper shell escaping.
|
|
||||||
|
|
||||||
Format:
|
|
||||||
curl -X POST \
|
|
||||||
'https://api.example.com/endpoint' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
--data '{"key": "value"}'
|
|
||||||
"""
|
|
||||||
parts = ["curl"]
|
|
||||||
|
|
||||||
# Add HTTP method (skip if GET - it's default)
|
|
||||||
if request.method != "GET":
|
|
||||||
parts.append(f"-X {request.method}")
|
|
||||||
|
|
||||||
# Add URL (always quoted for safety)
|
|
||||||
parts.append(shlex.quote(request.url))
|
|
||||||
|
|
||||||
# Add headers
|
|
||||||
for key, value in request.headers.items():
|
|
||||||
header_str = f"{key}: {value}"
|
|
||||||
parts.append(f"-H {shlex.quote(header_str)}")
|
|
||||||
|
|
||||||
# Add body for POST/PUT/PATCH/DELETE methods
|
|
||||||
if request.body and request.method in ["POST", "PUT", "PATCH", "DELETE"]:
|
|
||||||
parts.append(f"--data {shlex.quote(request.body)}")
|
|
||||||
|
|
||||||
# Format as multiline with backslash continuations
|
|
||||||
return " \\\n ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
# Auto-register on import
|
|
||||||
from .registry import ExporterRegistry
|
|
||||||
ExporterRegistry.register('curl', CurlExporter)
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
# registry.py
|
|
||||||
#
|
|
||||||
# Copyright 2025 Pavel Baksy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
from typing import Dict, List, Tuple, Type
|
|
||||||
from .base_exporter import BaseExporter
|
|
||||||
|
|
||||||
|
|
||||||
class ExporterRegistry:
|
|
||||||
"""Registry for managing export formats."""
|
|
||||||
|
|
||||||
_exporters: Dict[str, Type[BaseExporter]] = {}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def register(cls, format_id: str, exporter_class: Type[BaseExporter]):
|
|
||||||
"""Register an exporter class."""
|
|
||||||
cls._exporters[format_id] = exporter_class
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_exporter(cls, format_id: str) -> BaseExporter:
|
|
||||||
"""Get exporter instance by format ID."""
|
|
||||||
exporter_class = cls._exporters.get(format_id)
|
|
||||||
if not exporter_class:
|
|
||||||
raise ValueError(f"Unknown export format: {format_id}")
|
|
||||||
return exporter_class()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_all_formats(cls) -> List[Tuple[str, str]]:
|
|
||||||
"""Get all registered formats as [(id, name), ...]."""
|
|
||||||
return [(format_id, cls._exporters[format_id]().format_name)
|
|
||||||
for format_id in cls._exporters.keys()]
|
|
||||||
@ -7,7 +7,6 @@
|
|||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
@ -20,16 +19,12 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List
|
||||||
import gi
|
import gi
|
||||||
gi.require_version('GLib', '2.0')
|
gi.require_version('GLib', '2.0')
|
||||||
from gi.repository import GLib
|
from gi.repository import GLib
|
||||||
from .models import HistoryEntry
|
from .models import HistoryEntry
|
||||||
from .constants import HISTORY_MAX_ENTRIES
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class HistoryManager:
|
class HistoryManager:
|
||||||
@ -37,9 +32,9 @@ class HistoryManager:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Use XDG config directory (works for both Flatpak and native)
|
# Use XDG config directory (works for both Flatpak and native)
|
||||||
# Flatpak: ~/.var/app/cz.bugsy.roster/config/cz.bugsy.roster
|
# Flatpak: ~/.var/app/cz.vesp.roster/config/cz.vesp.roster
|
||||||
# Native: ~/.config/cz.bugsy.roster
|
# Native: ~/.config/cz.vesp.roster
|
||||||
self.config_dir = Path(GLib.get_user_config_dir()) / 'cz.bugsy.roster'
|
self.config_dir = Path(GLib.get_user_config_dir()) / 'cz.vesp.roster'
|
||||||
self.history_file = self.config_dir / 'history.json'
|
self.history_file = self.config_dir / 'history.json'
|
||||||
self._ensure_config_dir()
|
self._ensure_config_dir()
|
||||||
|
|
||||||
@ -58,7 +53,7 @@ class HistoryManager:
|
|||||||
|
|
||||||
return [HistoryEntry.from_dict(entry) for entry in data.get('entries', [])]
|
return [HistoryEntry.from_dict(entry) for entry in data.get('entries', [])]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading history: {e}")
|
print(f"Error loading history: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def save_history(self, entries: List[HistoryEntry]):
|
def save_history(self, entries: List[HistoryEntry]):
|
||||||
@ -72,25 +67,14 @@ class HistoryManager:
|
|||||||
with open(self.history_file, 'w') as f:
|
with open(self.history_file, 'w') as f:
|
||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving history: {e}")
|
print(f"Error saving history: {e}")
|
||||||
|
|
||||||
def add_entry(self, entry: HistoryEntry):
|
def add_entry(self, entry: HistoryEntry):
|
||||||
"""Add new entry to history and save."""
|
"""Add new entry to history and save."""
|
||||||
entries = self.load_history()
|
entries = self.load_history()
|
||||||
entries.insert(0, entry) # Most recent first
|
entries.insert(0, entry) # Most recent first
|
||||||
|
|
||||||
# Limit history size
|
# Limit history size to 100 entries
|
||||||
entries = entries[:HISTORY_MAX_ENTRIES]
|
entries = entries[:100]
|
||||||
|
|
||||||
self.save_history(entries)
|
self.save_history(entries)
|
||||||
|
|
||||||
def delete_entry(self, entry: HistoryEntry):
|
|
||||||
"""Delete a specific entry from history by ID."""
|
|
||||||
entries = self.load_history()
|
|
||||||
# Filter out the entry by comparing unique IDs
|
|
||||||
entries = [e for e in entries if e.id != entry.id]
|
|
||||||
self.save_history(entries)
|
|
||||||
|
|
||||||
def clear_history(self):
|
|
||||||
"""Clear all history entries."""
|
|
||||||
self.save_history([])
|
|
||||||
|
|||||||
@ -34,7 +34,7 @@ class HttpClient:
|
|||||||
self.session = Soup.Session.new()
|
self.session = Soup.Session.new()
|
||||||
|
|
||||||
# Load settings
|
# Load settings
|
||||||
self.settings = Gio.Settings.new('cz.bugsy.roster')
|
self.settings = Gio.Settings.new('cz.vesp.roster')
|
||||||
|
|
||||||
# Initialize TLS verification flag
|
# Initialize TLS verification flag
|
||||||
self.force_tls_verification = True
|
self.force_tls_verification = True
|
||||||
@ -150,32 +150,23 @@ class HttpClient:
|
|||||||
status_code = msg.get_status()
|
status_code = msg.get_status()
|
||||||
status_text = msg.get_reason_phrase()
|
status_text = msg.get_reason_phrase()
|
||||||
|
|
||||||
# Format headers in HTTPie-style output (HTTP/1.1 200 OK\nHeader: value\n...)
|
# Format headers like HTTPie output (HTTP/1.1 200 OK\nHeader: value\n...)
|
||||||
headers_text = self._format_headers(msg, status_code, status_text)
|
headers_text = self._format_headers(msg, status_code, status_text)
|
||||||
|
|
||||||
# Get raw body data for size calculation
|
|
||||||
body_bytes = bytes_data.get_data()
|
|
||||||
|
|
||||||
# Calculate total response size (headers + body)
|
|
||||||
headers_size = len(headers_text.encode('utf-8'))
|
|
||||||
body_size = len(body_bytes)
|
|
||||||
total_size = headers_size + body_size
|
|
||||||
|
|
||||||
# Decode body with error handling
|
# Decode body with error handling
|
||||||
body = body_bytes.decode('utf-8', errors='replace')
|
body = bytes_data.get_data().decode('utf-8', errors='replace')
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
status_text=status_text,
|
status_text=status_text,
|
||||||
headers=headers_text,
|
headers=headers_text,
|
||||||
body=body.strip(),
|
body=body.strip(),
|
||||||
response_time_ms=response_time,
|
response_time_ms=response_time
|
||||||
response_size_bytes=total_size
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _format_headers(self, msg: Soup.Message, status_code: int, status_text: str) -> str:
|
def _format_headers(self, msg: Soup.Message, status_code: int, status_text: str) -> str:
|
||||||
"""
|
"""
|
||||||
Format response headers in HTTPie-style output format.
|
Format response headers to match HTTPie output format.
|
||||||
|
|
||||||
Format:
|
Format:
|
||||||
HTTP/1.1 200 OK
|
HTTP/1.1 200 OK
|
||||||
@ -235,8 +226,7 @@ class HttpClient:
|
|||||||
error_str = str(error).lower()
|
error_str = str(error).lower()
|
||||||
|
|
||||||
if "timeout" in error_str:
|
if "timeout" in error_str:
|
||||||
timeout_seconds = self.session.get_timeout()
|
return "Request timeout (30 seconds)"
|
||||||
return f"Request timeout ({timeout_seconds} seconds)"
|
|
||||||
elif "resolve" in error_str:
|
elif "resolve" in error_str:
|
||||||
return f"Could not resolve hostname: {error}"
|
return f"Could not resolve hostname: {error}"
|
||||||
elif "connect" in error_str:
|
elif "connect" in error_str:
|
||||||
|
|||||||
@ -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] + '…'
|
|
||||||
@ -7,7 +7,6 @@
|
|||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
@ -22,7 +21,7 @@ from gi.repository import Adw, Gtk, GObject
|
|||||||
from .constants import PROJECT_ICONS
|
from .constants import PROJECT_ICONS
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/icon-picker-dialog.ui')
|
@Gtk.Template(resource_path='/cz/vesp/roster/icon-picker-dialog.ui')
|
||||||
class IconPickerDialog(Adw.Dialog):
|
class IconPickerDialog(Adw.Dialog):
|
||||||
"""Dialog for selecting a project icon."""
|
"""Dialog for selecting a project icon."""
|
||||||
|
|
||||||
|
|||||||
@ -3,82 +3,43 @@
|
|||||||
<requires lib="gtk" version="4.0"/>
|
<requires lib="gtk" version="4.0"/>
|
||||||
<requires lib="Adw" version="1.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">
|
<template class="RosterWindow" parent="AdwApplicationWindow">
|
||||||
<property name="default-width">1200</property>
|
<property name="default-width">1200</property>
|
||||||
<property name="default-height">800</property>
|
<property name="default-height">800</property>
|
||||||
<property name="width-request">470</property>
|
|
||||||
<property name="content">
|
<property name="content">
|
||||||
<object class="AdwToastOverlay" id="toast_overlay">
|
<object class="GtkPaned" id="main_pane">
|
||||||
<property name="child">
|
<property name="orientation">horizontal</property>
|
||||||
<object class="AdwOverlaySplitView" id="split_view">
|
<property name="position">300</property>
|
||||||
<property name="min-sidebar-width">200</property>
|
<property name="shrink-start-child">False</property>
|
||||||
<property name="max-sidebar-width">320</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 -->
|
<!-- LEFT: Sidebar Panel with AdwToolbarView -->
|
||||||
<property name="sidebar">
|
<property name="start-child">
|
||||||
<object class="AdwToolbarView">
|
<object class="AdwToolbarView">
|
||||||
|
<property name="width-request">200</property>
|
||||||
|
|
||||||
<!-- Sidebar Header Bar -->
|
<!-- Sidebar Header Bar -->
|
||||||
<child type="top">
|
<child type="top">
|
||||||
<object class="AdwHeaderBar">
|
<object class="AdwHeaderBar">
|
||||||
|
<property name="show-title">False</property>
|
||||||
<property name="show-end-title-buttons">False</property>
|
<property name="show-end-title-buttons">False</property>
|
||||||
<property name="show-start-title-buttons">False</property>
|
<property name="show-start-title-buttons">False</property>
|
||||||
<property name="title-widget">
|
<child type="start">
|
||||||
<object class="GtkLabel">
|
<object class="GtkLabel">
|
||||||
<property name="label">Projects</property>
|
<property name="label">Projects</property>
|
||||||
<style>
|
<style>
|
||||||
<class name="title"/>
|
<class name="heading"/>
|
||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</property>
|
|
||||||
<child type="start">
|
|
||||||
<object class="GtkWindowControls">
|
|
||||||
<property name="side">start</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
</child>
|
||||||
<child type="end">
|
<child type="end">
|
||||||
<object class="GtkMenuButton" id="sidebar_hamburger_button">
|
<object class="GtkButton" id="add_project_button">
|
||||||
<property name="primary">True</property>
|
<property name="icon-name">list-add-symbolic</property>
|
||||||
<property name="icon-name">open-menu-symbolic</property>
|
<property name="tooltip-text">Add Project</property>
|
||||||
<property name="tooltip-text">Menu</property>
|
<signal name="clicked" handler="on_add_project_clicked"/>
|
||||||
<property name="menu-model">sidebar_menu</property>
|
|
||||||
<style>
|
<style>
|
||||||
<class name="flat"/>
|
<class name="flat"/>
|
||||||
</style>
|
</style>
|
||||||
@ -105,13 +66,27 @@
|
|||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
|
<!-- Save Current Request Button -->
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="save_request_button">
|
||||||
|
<property name="label">Save Current Request</property>
|
||||||
|
<property name="margin-start">12</property>
|
||||||
|
<property name="margin-end">12</property>
|
||||||
|
<property name="margin-bottom">12</property>
|
||||||
|
<signal name="clicked" handler="on_save_request_clicked"/>
|
||||||
|
<style>
|
||||||
|
<class name="suggested-action"/>
|
||||||
|
</style>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
</object>
|
</object>
|
||||||
</property>
|
</property>
|
||||||
</object>
|
</object>
|
||||||
</property>
|
</property>
|
||||||
|
|
||||||
<!-- RIGHT: Main Content Panel with AdwToolbarView -->
|
<!-- RIGHT: Main Content Panel with AdwToolbarView -->
|
||||||
<property name="content">
|
<property name="end-child">
|
||||||
<object class="AdwToolbarView">
|
<object class="AdwToolbarView">
|
||||||
|
|
||||||
<!-- Main Header Bar -->
|
<!-- Main Header Bar -->
|
||||||
@ -121,51 +96,13 @@
|
|||||||
<property name="show-start-title-buttons">False</property>
|
<property name="show-start-title-buttons">False</property>
|
||||||
<property name="show-end-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">
|
|
||||||
<property name="icon-name">document-save-symbolic</property>
|
|
||||||
<property name="tooltip-text">Save Current Request (Ctrl+S)</property>
|
|
||||||
<signal name="clicked" handler="on_save_request_clicked"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child type="start">
|
|
||||||
<object class="GtkButton" id="export_request_button">
|
|
||||||
<property name="icon-name">export-symbolic</property>
|
|
||||||
<property name="tooltip-text">Export as cURL</property>
|
|
||||||
<signal name="clicked" handler="on_export_request_clicked"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
</style>
|
|
||||||
</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 -->
|
<!-- Right side buttons -->
|
||||||
<child type="end">
|
<child type="end">
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
|
||||||
|
<!-- New Request Button -->
|
||||||
|
<child>
|
||||||
<object class="GtkButton" id="new_request_button">
|
<object class="GtkButton" id="new_request_button">
|
||||||
<property name="icon-name">list-add-symbolic</property>
|
<property name="icon-name">list-add-symbolic</property>
|
||||||
<property name="tooltip-text">New Request (Ctrl+T)</property>
|
<property name="tooltip-text">New Request (Ctrl+T)</property>
|
||||||
@ -174,6 +111,25 @@
|
|||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</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>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
@ -192,8 +148,7 @@
|
|||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
<property name="position">600</property>
|
<property name="position">600</property>
|
||||||
<property name="shrink-start-child">False</property>
|
<property name="shrink-start-child">False</property>
|
||||||
<!-- Don't allow history panel to shrink below its minimum size -->
|
<property name="shrink-end-child">True</property>
|
||||||
<property name="shrink-end-child">False</property>
|
|
||||||
<property name="resize-start-child">True</property>
|
<property name="resize-start-child">True</property>
|
||||||
<property name="resize-end-child">True</property>
|
<property name="resize-end-child">True</property>
|
||||||
|
|
||||||
@ -208,8 +163,6 @@
|
|||||||
<property name="end-child">
|
<property name="end-child">
|
||||||
<object class="GtkBox">
|
<object class="GtkBox">
|
||||||
<property name="orientation">vertical</property>
|
<property name="orientation">vertical</property>
|
||||||
<!-- Set minimum height to ensure header is always visible -->
|
|
||||||
<property name="height-request">50</property>
|
|
||||||
|
|
||||||
<!-- History Header -->
|
<!-- History Header -->
|
||||||
<child>
|
<child>
|
||||||
@ -237,7 +190,7 @@
|
|||||||
<child>
|
<child>
|
||||||
<object class="GtkScrolledWindow">
|
<object class="GtkScrolledWindow">
|
||||||
<property name="vexpand">True</property>
|
<property name="vexpand">True</property>
|
||||||
<property name="min-content-height">50</property>
|
<property name="min-content-height">150</property>
|
||||||
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkListBox" id="history_listbox">
|
<object class="GtkListBox" id="history_listbox">
|
||||||
@ -256,16 +209,22 @@
|
|||||||
</property>
|
</property>
|
||||||
</object>
|
</object>
|
||||||
</property>
|
</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>
|
</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>
|
</interface>
|
||||||
|
|||||||
37
src/main.py
@ -26,17 +26,15 @@ gi.require_version('Adw', '1')
|
|||||||
from gi.repository import Gtk, Gio, Adw
|
from gi.repository import Gtk, Gio, Adw
|
||||||
from .window import RosterWindow
|
from .window import RosterWindow
|
||||||
from .preferences_dialog import PreferencesDialog
|
from .preferences_dialog import PreferencesDialog
|
||||||
from . import constants
|
|
||||||
|
|
||||||
|
|
||||||
class RosterApplication(Adw.Application):
|
class RosterApplication(Adw.Application):
|
||||||
"""The main application singleton class."""
|
"""The main application singleton class."""
|
||||||
|
|
||||||
def __init__(self, version='0.0.0'):
|
def __init__(self):
|
||||||
super().__init__(application_id='cz.bugsy.roster',
|
super().__init__(application_id='cz.vesp.roster',
|
||||||
flags=Gio.ApplicationFlags.DEFAULT_FLAGS,
|
flags=Gio.ApplicationFlags.DEFAULT_FLAGS,
|
||||||
resource_base_path='/cz/bugsy/roster')
|
resource_base_path='/cz/vesp/roster')
|
||||||
self.version = version
|
|
||||||
self.create_action('quit', lambda *_: self.quit(), ['<control>q'])
|
self.create_action('quit', lambda *_: self.quit(), ['<control>q'])
|
||||||
self.create_action('about', self.on_about_action)
|
self.create_action('about', self.on_about_action)
|
||||||
self.create_action('preferences', self.on_preferences_action)
|
self.create_action('preferences', self.on_preferences_action)
|
||||||
@ -56,40 +54,27 @@ class RosterApplication(Adw.Application):
|
|||||||
def on_about_action(self, *args):
|
def on_about_action(self, *args):
|
||||||
"""Callback for the app.about action."""
|
"""Callback for the app.about action."""
|
||||||
about = Adw.AboutDialog(application_name='Roster',
|
about = Adw.AboutDialog(application_name='Roster',
|
||||||
application_icon='cz.bugsy.roster',
|
application_icon='cz.vesp.roster',
|
||||||
developer_name='Pavel Baksy',
|
developer_name='Pavel Baksy',
|
||||||
version=self.version,
|
version='0.1.0',
|
||||||
developers=['Pavel Baksy'],
|
developers=['Pavel Baksy'],
|
||||||
copyright='© 2025 Pavel Baksy',
|
copyright='© 2025 Pavel Baksy',
|
||||||
comments='HTTP client for testing APIs')
|
comments='HTTP client for testing APIs')
|
||||||
about.set_website('https://git.bugsy.cz/beval/roster')
|
about.set_website('https://github.com/pavelb/roster')
|
||||||
about.set_issue_url('https://git.bugsy.cz/beval/roster/issues')
|
|
||||||
about.set_license_type(Gtk.License.GPL_3_0)
|
about.set_license_type(Gtk.License.GPL_3_0)
|
||||||
|
|
||||||
# Add legal notice about icon licensing
|
|
||||||
about.add_legal_section(
|
|
||||||
'Application Icons',
|
|
||||||
None,
|
|
||||||
Gtk.License.CUSTOM,
|
|
||||||
'Application icons are licensed under CC-BY-SA-3.0\n'
|
|
||||||
'https://creativecommons.org/licenses/by-sa/3.0/'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Translators: Replace "translator-credits" with your name/username, and optionally an email or URL.
|
# Translators: Replace "translator-credits" with your name/username, and optionally an email or URL.
|
||||||
about.set_translator_credits(_('translator-credits'))
|
about.set_translator_credits(_('translator-credits'))
|
||||||
about.present(self.props.active_window)
|
about.present(self.props.active_window)
|
||||||
|
|
||||||
def on_preferences_action(self, widget, _):
|
def on_preferences_action(self, widget, _):
|
||||||
"""Callback for the app.preferences action."""
|
"""Callback for the app.preferences action."""
|
||||||
window = self.props.active_window
|
preferences = PreferencesDialog()
|
||||||
preferences = PreferencesDialog(history_manager=window.history_manager, project_manager=window.project_manager)
|
preferences.set_transient_for(self.props.active_window)
|
||||||
preferences.set_transient_for(window)
|
|
||||||
preferences.connect('history-cleared', lambda d: window._load_history())
|
|
||||||
preferences.present()
|
preferences.present()
|
||||||
|
|
||||||
def on_shortcuts_action(self, widget, _):
|
def on_shortcuts_action(self, widget, _):
|
||||||
"""Callback for the app.shortcuts action."""
|
"""Callback for the app.shortcuts action."""
|
||||||
builder = Gtk.Builder.new_from_resource('/cz/bugsy/roster/shortcuts-dialog.ui')
|
builder = Gtk.Builder.new_from_resource('/cz/vesp/roster/shortcuts-dialog.ui')
|
||||||
shortcuts_window = builder.get_object('shortcuts_dialog')
|
shortcuts_window = builder.get_object('shortcuts_dialog')
|
||||||
shortcuts_window.set_transient_for(self.props.active_window)
|
shortcuts_window.set_transient_for(self.props.active_window)
|
||||||
shortcuts_window.present()
|
shortcuts_window.present()
|
||||||
@ -112,7 +97,5 @@ class RosterApplication(Adw.Application):
|
|||||||
|
|
||||||
def main(version):
|
def main(version):
|
||||||
"""The application's entry point."""
|
"""The application's entry point."""
|
||||||
# Set the version constant for use throughout the application
|
app = RosterApplication()
|
||||||
constants.VERSION = version
|
|
||||||
app = RosterApplication(version=version)
|
|
||||||
return app.run(sys.argv)
|
return app.run(sys.argv)
|
||||||
|
|||||||
@ -31,40 +31,18 @@ roster_sources = [
|
|||||||
'main.py',
|
'main.py',
|
||||||
'window.py',
|
'window.py',
|
||||||
'models.py',
|
'models.py',
|
||||||
'variable_substitution.py',
|
|
||||||
'http_client.py',
|
'http_client.py',
|
||||||
'history_manager.py',
|
'history_manager.py',
|
||||||
'migration.py',
|
|
||||||
'project_manager.py',
|
'project_manager.py',
|
||||||
'secret_manager.py',
|
|
||||||
'tab_manager.py',
|
'tab_manager.py',
|
||||||
'constants.py',
|
'constants.py',
|
||||||
'icon_picker_dialog.py',
|
'icon_picker_dialog.py',
|
||||||
'environments_dialog.py',
|
|
||||||
'export_dialog.py',
|
|
||||||
'preferences_dialog.py',
|
'preferences_dialog.py',
|
||||||
'request_tab_widget.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)
|
install_data(roster_sources, install_dir: moduledir)
|
||||||
|
|
||||||
# Install exporters submodule
|
|
||||||
exporters_sources = [
|
|
||||||
'exporters/__init__.py',
|
|
||||||
'exporters/base_exporter.py',
|
|
||||||
'exporters/curl_exporter.py',
|
|
||||||
'exporters/registry.py',
|
|
||||||
]
|
|
||||||
|
|
||||||
install_data(exporters_sources, install_dir: moduledir / 'exporters')
|
|
||||||
|
|
||||||
# Install widgets submodule
|
# Install widgets submodule
|
||||||
widgets_sources = [
|
widgets_sources = [
|
||||||
'widgets/__init__.py',
|
'widgets/__init__.py',
|
||||||
@ -72,11 +50,6 @@ widgets_sources = [
|
|||||||
'widgets/history_item.py',
|
'widgets/history_item.py',
|
||||||
'widgets/project_item.py',
|
'widgets/project_item.py',
|
||||||
'widgets/request_item.py',
|
'widgets/request_item.py',
|
||||||
'widgets/variable_row.py',
|
|
||||||
'widgets/environment_row.py',
|
|
||||||
'widgets/environment_column_header.py',
|
|
||||||
'widgets/environment_header_row.py',
|
|
||||||
'widgets/variable_data_row.py',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
install_data(widgets_sources, install_dir: moduledir / 'widgets')
|
install_data(widgets_sources, install_dir: moduledir / 'widgets')
|
||||||
|
|||||||
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
|
|
||||||
125
src/models.py
@ -7,7 +7,6 @@
|
|||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
@ -20,7 +19,6 @@
|
|||||||
|
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from typing import Dict, Optional, List
|
from typing import Dict, Optional, List
|
||||||
from . import constants
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -32,16 +30,6 @@ class HttpRequest:
|
|||||||
body: str # Raw text body
|
body: str # Raw text body
|
||||||
syntax: str = "RAW" # Syntax highlighting: "RAW", "JSON", or "XML"
|
syntax: str = "RAW" # Syntax highlighting: "RAW", "JSON", or "XML"
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def default_headers(cls) -> Dict[str, str]:
|
|
||||||
"""Return default headers for new requests."""
|
|
||||||
# Use only major.minor version (without patch number)
|
|
||||||
version_parts = constants.VERSION.split(".")
|
|
||||||
short_version = ".".join(version_parts[:2])
|
|
||||||
return {
|
|
||||||
"User-Agent": f"Roster/{short_version}"
|
|
||||||
}
|
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Convert to dictionary for JSON serialization."""
|
"""Convert to dictionary for JSON serialization."""
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
@ -60,10 +48,9 @@ class HttpResponse:
|
|||||||
"""Represents an HTTP response."""
|
"""Represents an HTTP response."""
|
||||||
status_code: int
|
status_code: int
|
||||||
status_text: str # e.g., "OK"
|
status_text: str # e.g., "OK"
|
||||||
headers: str # Raw header text from libsoup3
|
headers: str # Raw header text from HTTPie
|
||||||
body: str # Raw body text
|
body: str # Raw body text
|
||||||
response_time_ms: float
|
response_time_ms: float
|
||||||
response_size_bytes: int = 0 # Total response size in bytes
|
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Convert to dictionary for JSON serialization."""
|
"""Convert to dictionary for JSON serialization."""
|
||||||
@ -82,36 +69,24 @@ class HistoryEntry:
|
|||||||
request: HttpRequest
|
request: HttpRequest
|
||||||
response: Optional[HttpResponse]
|
response: Optional[HttpResponse]
|
||||||
error: Optional[str] # Error message if request failed
|
error: Optional[str] # Error message if request failed
|
||||||
id: str = None # Unique identifier for the entry
|
|
||||||
has_redacted_variables: bool = False # True if sensitive variables were redacted
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
"""Generate UUID if id not provided."""
|
|
||||||
if self.id is None:
|
|
||||||
import uuid
|
|
||||||
self.id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Convert to dictionary for JSON serialization."""
|
"""Convert to dictionary for JSON serialization."""
|
||||||
return {
|
return {
|
||||||
'id': self.id,
|
|
||||||
'timestamp': self.timestamp,
|
'timestamp': self.timestamp,
|
||||||
'request': self.request.to_dict(),
|
'request': self.request.to_dict(),
|
||||||
'response': self.response.to_dict() if self.response else None,
|
'response': self.response.to_dict() if self.response else None,
|
||||||
'error': self.error,
|
'error': self.error
|
||||||
'has_redacted_variables': self.has_redacted_variables
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data):
|
def from_dict(cls, data):
|
||||||
"""Create instance from dictionary."""
|
"""Create instance from dictionary."""
|
||||||
return cls(
|
return cls(
|
||||||
id=data.get('id'), # Backwards compatible - will generate if missing
|
|
||||||
timestamp=data['timestamp'],
|
timestamp=data['timestamp'],
|
||||||
request=HttpRequest.from_dict(data['request']),
|
request=HttpRequest.from_dict(data['request']),
|
||||||
response=HttpResponse.from_dict(data['response']) if data.get('response') else None,
|
response=HttpResponse.from_dict(data['response']) if data.get('response') else None,
|
||||||
error=data.get('error'),
|
error=data.get('error')
|
||||||
has_redacted_variables=data.get('has_redacted_variables', False)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -123,7 +98,6 @@ class SavedRequest:
|
|||||||
request: HttpRequest
|
request: HttpRequest
|
||||||
created_at: str # ISO format
|
created_at: str # ISO format
|
||||||
modified_at: str # ISO format
|
modified_at: str # ISO format
|
||||||
scripts: Optional['Scripts'] = None # Scripts for preprocessing and postprocessing
|
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Convert to dictionary for JSON serialization."""
|
"""Convert to dictionary for JSON serialization."""
|
||||||
@ -132,8 +106,7 @@ class SavedRequest:
|
|||||||
'name': self.name,
|
'name': self.name,
|
||||||
'request': self.request.to_dict(),
|
'request': self.request.to_dict(),
|
||||||
'created_at': self.created_at,
|
'created_at': self.created_at,
|
||||||
'modified_at': self.modified_at,
|
'modified_at': self.modified_at
|
||||||
'scripts': self.scripts.to_dict() if self.scripts else None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -144,59 +117,18 @@ class SavedRequest:
|
|||||||
name=data['name'],
|
name=data['name'],
|
||||||
request=HttpRequest.from_dict(data['request']),
|
request=HttpRequest.from_dict(data['request']),
|
||||||
created_at=data['created_at'],
|
created_at=data['created_at'],
|
||||||
modified_at=data['modified_at'],
|
modified_at=data['modified_at']
|
||||||
scripts=Scripts.from_dict(data['scripts']) if data.get('scripts') else None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Environment:
|
|
||||||
"""An environment with variable values."""
|
|
||||||
id: str # UUID
|
|
||||||
name: str # e.g., "production", "integration", "stage"
|
|
||||||
variables: Dict[str, str] # variable_name -> value
|
|
||||||
created_at: str # ISO format
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
"""Convert to dictionary for JSON serialization."""
|
|
||||||
return {
|
|
||||||
'id': self.id,
|
|
||||||
'name': self.name,
|
|
||||||
'variables': self.variables,
|
|
||||||
'created_at': self.created_at
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data):
|
|
||||||
"""Create instance from dictionary."""
|
|
||||||
return cls(
|
|
||||||
id=data['id'],
|
|
||||||
name=data['name'],
|
|
||||||
variables=data.get('variables', {}),
|
|
||||||
created_at=data['created_at']
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Project:
|
class Project:
|
||||||
"""A project containing saved requests and environments."""
|
"""A project containing saved requests."""
|
||||||
id: str # UUID
|
id: str # UUID
|
||||||
name: str
|
name: str
|
||||||
requests: List[SavedRequest]
|
requests: List[SavedRequest]
|
||||||
created_at: str # ISO format
|
created_at: str # ISO format
|
||||||
icon: str = "folder-symbolic" # Icon name
|
icon: str = "folder-symbolic" # Icon name
|
||||||
variable_names: List[str] = None # List of variable names defined for this project
|
|
||||||
environments: List[Environment] = None # List of environments
|
|
||||||
sensitive_variables: List[str] = None # List of variable names marked as sensitive (stored in keyring)
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
"""Initialize optional fields."""
|
|
||||||
if self.variable_names is None:
|
|
||||||
self.variable_names = []
|
|
||||||
if self.environments is None:
|
|
||||||
self.environments = []
|
|
||||||
if self.sensitive_variables is None:
|
|
||||||
self.sensitive_variables = []
|
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Convert to dictionary for JSON serialization."""
|
"""Convert to dictionary for JSON serialization."""
|
||||||
@ -205,10 +137,7 @@ class Project:
|
|||||||
'name': self.name,
|
'name': self.name,
|
||||||
'requests': [req.to_dict() for req in self.requests],
|
'requests': [req.to_dict() for req in self.requests],
|
||||||
'created_at': self.created_at,
|
'created_at': self.created_at,
|
||||||
'icon': self.icon,
|
'icon': self.icon
|
||||||
'variable_names': self.variable_names,
|
|
||||||
'environments': [env.to_dict() for env in self.environments],
|
|
||||||
'sensitive_variables': self.sensitive_variables
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -219,10 +148,7 @@ class Project:
|
|||||||
name=data['name'],
|
name=data['name'],
|
||||||
requests=[SavedRequest.from_dict(r) for r in data.get('requests', [])],
|
requests=[SavedRequest.from_dict(r) for r in data.get('requests', [])],
|
||||||
created_at=data['created_at'],
|
created_at=data['created_at'],
|
||||||
icon=data.get('icon', 'folder-symbolic'), # Default for old data
|
icon=data.get('icon', 'folder-symbolic') # Default for old data
|
||||||
variable_names=data.get('variable_names', []),
|
|
||||||
environments=[Environment.from_dict(e) for e in data.get('environments', [])],
|
|
||||||
sensitive_variables=data.get('sensitive_variables', []) # Default for old data
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -236,9 +162,6 @@ class RequestTab:
|
|||||||
saved_request_id: Optional[str] = None # ID if from SavedRequest
|
saved_request_id: Optional[str] = None # ID if from SavedRequest
|
||||||
modified: bool = False # True if has unsaved changes
|
modified: bool = False # True if has unsaved changes
|
||||||
original_request: Optional[HttpRequest] = None # For change detection
|
original_request: Optional[HttpRequest] = None # For change detection
|
||||||
project_id: Optional[str] = None # Project association for environment variables
|
|
||||||
selected_environment_id: Optional[str] = None # Selected environment for variable substitution
|
|
||||||
scripts: Optional['Scripts'] = None # Scripts for preprocessing and postprocessing
|
|
||||||
|
|
||||||
def is_modified(self) -> bool:
|
def is_modified(self) -> bool:
|
||||||
"""Check if current request differs from original."""
|
"""Check if current request differs from original."""
|
||||||
@ -263,10 +186,7 @@ class RequestTab:
|
|||||||
'response': self.response.to_dict() if self.response else None,
|
'response': self.response.to_dict() if self.response else None,
|
||||||
'saved_request_id': self.saved_request_id,
|
'saved_request_id': self.saved_request_id,
|
||||||
'modified': self.modified,
|
'modified': self.modified,
|
||||||
'original_request': self.original_request.to_dict() if self.original_request else None,
|
'original_request': self.original_request.to_dict() if self.original_request else None
|
||||||
'project_id': self.project_id,
|
|
||||||
'selected_environment_id': self.selected_environment_id,
|
|
||||||
'scripts': self.scripts.to_dict() if self.scripts else None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -279,30 +199,5 @@ class RequestTab:
|
|||||||
response=HttpResponse.from_dict(data['response']) if data.get('response') else None,
|
response=HttpResponse.from_dict(data['response']) if data.get('response') else None,
|
||||||
saved_request_id=data.get('saved_request_id'),
|
saved_request_id=data.get('saved_request_id'),
|
||||||
modified=data.get('modified', False),
|
modified=data.get('modified', False),
|
||||||
original_request=HttpRequest.from_dict(data['original_request']) if data.get('original_request') else None,
|
original_request=HttpRequest.from_dict(data['original_request']) if data.get('original_request') else None
|
||||||
project_id=data.get('project_id'),
|
|
||||||
selected_environment_id=data.get('selected_environment_id'),
|
|
||||||
scripts=Scripts.from_dict(data['scripts']) if data.get('scripts') else None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Scripts:
|
|
||||||
"""Scripts for request preprocessing and postprocessing."""
|
|
||||||
preprocessing: str = ""
|
|
||||||
postprocessing: str = ""
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
"""Convert to dictionary for JSON serialization."""
|
|
||||||
return {
|
|
||||||
'preprocessing': self.preprocessing,
|
|
||||||
'postprocessing': self.postprocessing
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data):
|
|
||||||
"""Create instance from dictionary."""
|
|
||||||
return cls(
|
|
||||||
preprocessing=data.get('preprocessing', ''),
|
|
||||||
postprocessing=data.get('postprocessing', '')
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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="title" translatable="yes">Preferences</property>
|
||||||
<property name="modal">True</property>
|
<property name="modal">True</property>
|
||||||
<property name="default-width">600</property>
|
<property name="default-width">600</property>
|
||||||
|
<property name="default-height">400</property>
|
||||||
|
|
||||||
<child>
|
<child>
|
||||||
<object class="AdwPreferencesPage">
|
<object class="AdwPreferencesPage">
|
||||||
@ -42,89 +43,6 @@
|
|||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="AdwPreferencesGroup">
|
|
||||||
<property name="title" translatable="yes">History</property>
|
|
||||||
<property name="description" translatable="yes">Manage request history</property>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="AdwActionRow">
|
|
||||||
<property name="title" translatable="yes">Clear All History</property>
|
|
||||||
<property name="subtitle" translatable="yes">Remove all saved request and response history</property>
|
|
||||||
<child>
|
|
||||||
<object class="GtkButton" id="clear_history_button">
|
|
||||||
<property name="label" translatable="yes">Clear</property>
|
|
||||||
<property name="valign">center</property>
|
|
||||||
<signal name="clicked" handler="on_clear_history_clicked" swapped="no"/>
|
|
||||||
<style>
|
|
||||||
<class name="destructive-action"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</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>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -17,148 +17,33 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
import logging
|
from gi.repository import Adw, Gtk, Gio
|
||||||
from gi.repository import Adw, Gtk, Gio, GObject
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/preferences-dialog.ui')
|
@Gtk.Template(resource_path='/cz/vesp/roster/preferences-dialog.ui')
|
||||||
class PreferencesDialog(Adw.PreferencesWindow):
|
class PreferencesDialog(Adw.PreferencesWindow):
|
||||||
__gtype_name__ = 'PreferencesDialog'
|
__gtype_name__ = 'PreferencesDialog'
|
||||||
|
|
||||||
tls_verification_row = Gtk.Template.Child()
|
tls_verification_row = Gtk.Template.Child()
|
||||||
timeout_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__ = {
|
def __init__(self, **kwargs):
|
||||||
'history-cleared': (GObject.SIGNAL_RUN_FIRST, None, ())
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, history_manager=None, project_manager=None, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
self.history_manager = history_manager
|
# Get settings
|
||||||
self.project_manager = project_manager
|
self.settings = Gio.Settings.new('cz.vesp.roster')
|
||||||
|
|
||||||
self.settings = Gio.Settings.new('cz.bugsy.roster')
|
|
||||||
|
|
||||||
|
# Bind settings to UI
|
||||||
self.settings.bind(
|
self.settings.bind(
|
||||||
'force-tls-verification',
|
'force-tls-verification',
|
||||||
self.tls_verification_row,
|
self.tls_verification_row,
|
||||||
'active',
|
'active',
|
||||||
Gio.SettingsBindFlags.DEFAULT
|
Gio.SettingsBindFlags.DEFAULT
|
||||||
)
|
)
|
||||||
|
|
||||||
self.settings.bind(
|
self.settings.bind(
|
||||||
'request-timeout',
|
'request-timeout',
|
||||||
self.timeout_row,
|
self.timeout_row,
|
||||||
'value',
|
'value',
|
||||||
Gio.SettingsBindFlags.DEFAULT
|
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):
|
|
||||||
if not self.history_manager:
|
|
||||||
return
|
|
||||||
|
|
||||||
dialog = Adw.AlertDialog.new(
|
|
||||||
"Clear All History?",
|
|
||||||
"This will permanently delete all saved request and response history. This action cannot be undone."
|
|
||||||
)
|
|
||||||
dialog.add_response("cancel", "Cancel")
|
|
||||||
dialog.add_response("clear", "Clear History")
|
|
||||||
dialog.set_response_appearance("clear", Adw.ResponseAppearance.DESTRUCTIVE)
|
|
||||||
dialog.set_default_response("cancel")
|
|
||||||
dialog.set_close_response("cancel")
|
|
||||||
|
|
||||||
def on_response(dialog, response):
|
|
||||||
if response == "clear":
|
|
||||||
self.history_manager.clear_history()
|
|
||||||
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)
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
@ -19,328 +18,71 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import shutil
|
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Callable
|
from typing import List, Optional
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import gi
|
import gi
|
||||||
gi.require_version('GLib', '2.0')
|
gi.require_version('GLib', '2.0')
|
||||||
from gi.repository import GLib
|
from gi.repository import GLib
|
||||||
from .models import Project, SavedRequest, HttpRequest, Environment
|
from .models import Project, SavedRequest, HttpRequest
|
||||||
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:
|
class ProjectManager:
|
||||||
"""Manages project and saved request persistence."""
|
"""Manages project and saved request persistence."""
|
||||||
|
|
||||||
_INDEX_FILE = '_index.json'
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.data_dir = Path(GLib.get_user_data_dir()) / 'cz.bugsy.roster'
|
# Use XDG data directory (works for both Flatpak and native)
|
||||||
self.projects_dir = self.data_dir / 'projects'
|
# Flatpak: ~/.var/app/cz.vesp.roster/data/cz.vesp.roster
|
||||||
self.legacy_file = self.data_dir / 'requests.json'
|
# Native: ~/.local/share/cz.vesp.roster
|
||||||
|
self.data_dir = Path(GLib.get_user_data_dir()) / 'cz.vesp.roster'
|
||||||
|
self.projects_file = self.data_dir / 'requests.json'
|
||||||
self._ensure_data_dir()
|
self._ensure_data_dir()
|
||||||
|
|
||||||
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):
|
def _ensure_data_dir(self):
|
||||||
|
"""Create data directory if it doesn't exist."""
|
||||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# ===== Loading =====
|
def load_projects(self) -> List[Project]:
|
||||||
|
"""Load projects from JSON file."""
|
||||||
def load_projects(self, force_reload: bool = False) -> List[Project]:
|
if not self.projects_file.exists():
|
||||||
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 []
|
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:
|
try:
|
||||||
with open(index_file, 'w') as f:
|
with open(self.projects_file, 'r') 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)
|
|
||||||
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:
|
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
self._projects_cache = [Project.from_dict(p) for p in data.get('projects', [])]
|
return [Project.from_dict(p) for p in data.get('projects', [])]
|
||||||
return self._projects_cache
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading legacy projects: %s", e)
|
print(f"Error loading projects: {e}")
|
||||||
self._projects_cache = []
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# ===== Saving =====
|
def save_projects(self, projects: List[Project]):
|
||||||
|
"""Save projects to JSON file."""
|
||||||
def _save_project(self, project: Project):
|
|
||||||
if self._legacy_mode:
|
|
||||||
self._save_all_legacy()
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
old_dir_name = self._project_dirs.get(project.id)
|
data = {
|
||||||
new_dir_name = _sanitize_name(project.name)
|
'version': 1,
|
||||||
|
'projects': [p.to_dict() for p in projects]
|
||||||
# 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,
|
|
||||||
}
|
}
|
||||||
with open(project_dir / '_project.json', 'w') as f:
|
with open(self.projects_file, '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:
|
|
||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error saving projects (legacy): %s", e)
|
print(f"Error saving projects: {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 =====
|
|
||||||
|
|
||||||
def add_project(self, name: str) -> Project:
|
def add_project(self, name: str) -> Project:
|
||||||
|
"""Create new project."""
|
||||||
projects = self.load_projects()
|
projects = self.load_projects()
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
default_env = Environment(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name="Default",
|
|
||||||
variables={},
|
|
||||||
created_at=now
|
|
||||||
)
|
|
||||||
project = Project(
|
project = Project(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
name=name,
|
name=name,
|
||||||
requests=[],
|
requests=[],
|
||||||
created_at=now,
|
created_at=datetime.now(timezone.utc).isoformat()
|
||||||
variable_names=[],
|
|
||||||
environments=[default_env]
|
|
||||||
)
|
)
|
||||||
projects.append(project)
|
projects.append(project)
|
||||||
self._save_project(project)
|
self.save_projects(projects)
|
||||||
if not self._legacy_mode:
|
|
||||||
order = self._load_project_order()
|
|
||||||
order.append(project.id)
|
|
||||||
self._save_project_order(order)
|
|
||||||
return project
|
return project
|
||||||
|
|
||||||
def update_project(self, project_id: str, new_name: str = None, new_icon: str = None):
|
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()
|
projects = self.load_projects()
|
||||||
for p in projects:
|
for p in projects:
|
||||||
if p.id == project_id:
|
if p.id == project_id:
|
||||||
@ -348,40 +90,17 @@ class ProjectManager:
|
|||||||
p.name = new_name
|
p.name = new_name
|
||||||
if new_icon is not None:
|
if new_icon is not None:
|
||||||
p.icon = new_icon
|
p.icon = new_icon
|
||||||
self._save_project(p)
|
|
||||||
break
|
break
|
||||||
|
self.save_projects(projects)
|
||||||
|
|
||||||
def delete_project(self, project_id: str,
|
def delete_project(self, project_id: str):
|
||||||
callback: Optional[Callable[[], None]] = None):
|
"""Delete a project and all its requests."""
|
||||||
projects = self.load_projects()
|
projects = self.load_projects()
|
||||||
secret_manager = get_secret_manager()
|
projects = [p for p in projects if p.id != project_id]
|
||||||
|
self.save_projects(projects)
|
||||||
|
|
||||||
for p in projects:
|
def add_request(self, project_id: str, name: str, request: HttpRequest) -> SavedRequest:
|
||||||
if p.id == project_id:
|
"""Add request to a project."""
|
||||||
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])
|
|
||||||
|
|
||||||
def on_secrets_deleted(success):
|
|
||||||
if callback:
|
|
||||||
callback()
|
|
||||||
|
|
||||||
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:
|
|
||||||
projects = self.load_projects()
|
projects = self.load_projects()
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
saved_request = SavedRequest(
|
saved_request = SavedRequest(
|
||||||
@ -389,17 +108,17 @@ class ProjectManager:
|
|||||||
name=name,
|
name=name,
|
||||||
request=request,
|
request=request,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
modified_at=now,
|
modified_at=now
|
||||||
scripts=scripts
|
|
||||||
)
|
)
|
||||||
for p in projects:
|
for p in projects:
|
||||||
if p.id == project_id:
|
if p.id == project_id:
|
||||||
p.requests.append(saved_request)
|
p.requests.append(saved_request)
|
||||||
self._save_project(p)
|
|
||||||
break
|
break
|
||||||
|
self.save_projects(projects)
|
||||||
return saved_request
|
return saved_request
|
||||||
|
|
||||||
def find_request_by_name(self, project_id: str, name: str) -> Optional[SavedRequest]:
|
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()
|
projects = self.load_projects()
|
||||||
for p in projects:
|
for p in projects:
|
||||||
if p.id == project_id:
|
if p.id == project_id:
|
||||||
@ -409,7 +128,8 @@ class ProjectManager:
|
|||||||
break
|
break
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_request(self, project_id: str, request_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest:
|
def update_request(self, project_id: str, request_id: str, name: str, request: HttpRequest) -> SavedRequest:
|
||||||
|
"""Update an existing request."""
|
||||||
projects = self.load_projects()
|
projects = self.load_projects()
|
||||||
updated_request = None
|
updated_request = None
|
||||||
for p in projects:
|
for p in projects:
|
||||||
@ -418,493 +138,19 @@ class ProjectManager:
|
|||||||
if req.id == request_id:
|
if req.id == request_id:
|
||||||
req.name = name
|
req.name = name
|
||||||
req.request = request
|
req.request = request
|
||||||
req.scripts = scripts
|
|
||||||
req.modified_at = datetime.now(timezone.utc).isoformat()
|
req.modified_at = datetime.now(timezone.utc).isoformat()
|
||||||
updated_request = req
|
updated_request = req
|
||||||
break
|
break
|
||||||
if updated_request:
|
|
||||||
self._save_project(p)
|
|
||||||
break
|
break
|
||||||
|
if updated_request:
|
||||||
|
self.save_projects(projects)
|
||||||
return updated_request
|
return updated_request
|
||||||
|
|
||||||
def delete_request(self, project_id: str, request_id: str):
|
def delete_request(self, project_id: str, request_id: str):
|
||||||
|
"""Delete a saved request."""
|
||||||
projects = self.load_projects()
|
projects = self.load_projects()
|
||||||
for p in projects:
|
for p in projects:
|
||||||
if p.id == project_id:
|
if p.id == project_id:
|
||||||
p.requests = [r for r in p.requests if r.id != request_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
|
break
|
||||||
|
self.save_projects(projects)
|
||||||
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)
|
|
||||||
|
|
||||||
def add_environment(self, project_id: str, name: str) -> Environment:
|
|
||||||
projects = self.load_projects()
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
environment = None
|
|
||||||
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
variables = {var_name: "" for var_name in p.variable_names}
|
|
||||||
environment = Environment(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name=name,
|
|
||||||
variables=variables,
|
|
||||||
created_at=now
|
|
||||||
)
|
|
||||||
p.environments.append(environment)
|
|
||||||
self._save_project(p)
|
|
||||||
break
|
|
||||||
|
|
||||||
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):
|
|
||||||
projects = self.load_projects()
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
for env in p.environments:
|
|
||||||
if env.id == env_id:
|
|
||||||
if name is not None:
|
|
||||||
env.name = name
|
|
||||||
if variables is not None:
|
|
||||||
env.variables = variables
|
|
||||||
break
|
|
||||||
self._save_project(p)
|
|
||||||
break
|
|
||||||
|
|
||||||
def delete_environment(self, project_id: str, env_id: str,
|
|
||||||
callback: Optional[Callable[[], None]] = None):
|
|
||||||
projects = self.load_projects()
|
|
||||||
secret_manager = get_secret_manager()
|
|
||||||
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
p.environments = [e for e in p.environments if e.id != env_id]
|
|
||||||
self._save_project(p)
|
|
||||||
break
|
|
||||||
|
|
||||||
def on_secrets_deleted(success):
|
|
||||||
if callback:
|
|
||||||
callback()
|
|
||||||
|
|
||||||
secret_manager.delete_all_environment_secrets(project_id, env_id, on_secrets_deleted)
|
|
||||||
|
|
||||||
def add_variable(self, project_id: str, variable_name: str):
|
|
||||||
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)
|
|
||||||
for env in p.environments:
|
|
||||||
env.variables[variable_name] = ""
|
|
||||||
self._save_project(p)
|
|
||||||
break
|
|
||||||
|
|
||||||
def rename_variable(self, project_id: str, old_name: str, new_name: str,
|
|
||||||
callback: Optional[Callable[[], None]] = None):
|
|
||||||
projects = self.load_projects()
|
|
||||||
secret_manager = get_secret_manager()
|
|
||||||
is_sensitive = False
|
|
||||||
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
if old_name in p.variable_names:
|
|
||||||
idx = p.variable_names.index(old_name)
|
|
||||||
p.variable_names[idx] = new_name
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if is_sensitive:
|
|
||||||
secret_manager.rename_variable_secrets(project_id, old_name, new_name, lambda success: callback() if callback else None)
|
|
||||||
elif callback:
|
|
||||||
callback()
|
|
||||||
|
|
||||||
def delete_variable(self, project_id: str, variable_name: str,
|
|
||||||
callback: Optional[Callable[[], None]] = None):
|
|
||||||
projects = self.load_projects()
|
|
||||||
secret_manager = get_secret_manager()
|
|
||||||
is_sensitive = False
|
|
||||||
environments_to_cleanup = []
|
|
||||||
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
if variable_name in p.variable_names:
|
|
||||||
p.variable_names.remove(variable_name)
|
|
||||||
|
|
||||||
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]
|
|
||||||
|
|
||||||
for env in p.environments:
|
|
||||||
env.variables.pop(variable_name, None)
|
|
||||||
self._save_project(p)
|
|
||||||
break
|
|
||||||
|
|
||||||
if is_sensitive and environments_to_cleanup:
|
|
||||||
pending_count = [len(environments_to_cleanup)]
|
|
||||||
|
|
||||||
def on_single_delete(success):
|
|
||||||
pending_count[0] -= 1
|
|
||||||
if pending_count[0] == 0 and callback:
|
|
||||||
callback()
|
|
||||||
|
|
||||||
for env_id in environments_to_cleanup:
|
|
||||||
secret_manager.delete_secret(project_id, env_id, variable_name, on_single_delete)
|
|
||||||
elif callback:
|
|
||||||
callback()
|
|
||||||
|
|
||||||
def update_environment_variable(self, project_id: str, env_id: str, variable_name: str, value: str):
|
|
||||||
projects = self.load_projects()
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
for env in p.environments:
|
|
||||||
if env.id == env_id:
|
|
||||||
env.variables[variable_name] = value
|
|
||||||
break
|
|
||||||
self._save_project(p)
|
|
||||||
break
|
|
||||||
|
|
||||||
def batch_update_environment_variables(self, project_id: str, env_id: str, variables: dict):
|
|
||||||
projects = self.load_projects()
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
for env in p.environments:
|
|
||||||
if env.id == env_id:
|
|
||||||
for var_name, var_value in variables.items():
|
|
||||||
env.variables[var_name] = var_value
|
|
||||||
break
|
|
||||||
self._save_project(p)
|
|
||||||
break
|
|
||||||
|
|
||||||
# ===== Sensitive Variable Management =====
|
|
||||||
|
|
||||||
def mark_variable_as_sensitive(self, project_id: str, variable_name: str,
|
|
||||||
callback: Optional[Callable[[bool], None]] = None):
|
|
||||||
projects = self.load_projects()
|
|
||||||
secret_manager = get_secret_manager()
|
|
||||||
secrets_to_store = []
|
|
||||||
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
if variable_name not in p.sensitive_variables:
|
|
||||||
p.sensitive_variables.append(variable_name)
|
|
||||||
|
|
||||||
for env in p.environments:
|
|
||||||
if variable_name in env.variables:
|
|
||||||
value = env.variables[variable_name]
|
|
||||||
secrets_to_store.append({
|
|
||||||
'environment_id': env.id,
|
|
||||||
'environment_name': env.name,
|
|
||||||
'value': value,
|
|
||||||
'project_name': p.name
|
|
||||||
})
|
|
||||||
env.variables[variable_name] = ""
|
|
||||||
|
|
||||||
self._save_project(p)
|
|
||||||
|
|
||||||
if not secrets_to_store:
|
|
||||||
if callback:
|
|
||||||
callback(True)
|
|
||||||
return
|
|
||||||
|
|
||||||
pending_count = [len(secrets_to_store)]
|
|
||||||
all_success = [True]
|
|
||||||
|
|
||||||
def on_single_store(success):
|
|
||||||
if not success:
|
|
||||||
all_success[0] = False
|
|
||||||
pending_count[0] -= 1
|
|
||||||
if pending_count[0] == 0 and callback:
|
|
||||||
callback(all_success[0])
|
|
||||||
|
|
||||||
for secret_info in secrets_to_store:
|
|
||||||
secret_manager.store_secret(
|
|
||||||
project_id=project_id,
|
|
||||||
environment_id=secret_info['environment_id'],
|
|
||||||
variable_name=variable_name,
|
|
||||||
value=secret_info['value'],
|
|
||||||
project_name=secret_info['project_name'],
|
|
||||||
environment_name=secret_info['environment_name'],
|
|
||||||
callback=on_single_store
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if callback:
|
|
||||||
callback(False)
|
|
||||||
|
|
||||||
def mark_variable_as_nonsensitive(self, project_id: str, variable_name: str,
|
|
||||||
callback: Optional[Callable[[bool], None]] = None):
|
|
||||||
projects = self.load_projects()
|
|
||||||
secret_manager = get_secret_manager()
|
|
||||||
|
|
||||||
target_project = None
|
|
||||||
environments_to_migrate = []
|
|
||||||
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
target_project = p
|
|
||||||
if variable_name in p.sensitive_variables:
|
|
||||||
p.sensitive_variables.remove(variable_name)
|
|
||||||
environments_to_migrate = list(p.environments)
|
|
||||||
break
|
|
||||||
|
|
||||||
if not target_project or not environments_to_migrate:
|
|
||||||
if callback:
|
|
||||||
callback(False)
|
|
||||||
return
|
|
||||||
|
|
||||||
pending_count = [len(environments_to_migrate)]
|
|
||||||
all_success = [True]
|
|
||||||
|
|
||||||
def on_single_migrate(env):
|
|
||||||
def on_retrieve(value):
|
|
||||||
env.variables[variable_name] = value or ""
|
|
||||||
self._save_project(target_project)
|
|
||||||
|
|
||||||
def on_delete(success):
|
|
||||||
if not success:
|
|
||||||
all_success[0] = False
|
|
||||||
pending_count[0] -= 1
|
|
||||||
if pending_count[0] == 0 and callback:
|
|
||||||
callback(all_success[0])
|
|
||||||
|
|
||||||
secret_manager.delete_secret(
|
|
||||||
project_id=project_id,
|
|
||||||
environment_id=env.id,
|
|
||||||
variable_name=variable_name,
|
|
||||||
callback=on_delete
|
|
||||||
)
|
|
||||||
return on_retrieve
|
|
||||||
|
|
||||||
for env in environments_to_migrate:
|
|
||||||
secret_manager.retrieve_secret(
|
|
||||||
project_id=project_id,
|
|
||||||
environment_id=env.id,
|
|
||||||
variable_name=variable_name,
|
|
||||||
callback=on_single_migrate(env)
|
|
||||||
)
|
|
||||||
|
|
||||||
def is_variable_sensitive(self, project_id: str, variable_name: str) -> bool:
|
|
||||||
projects = self.load_projects()
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
return variable_name in p.sensitive_variables
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_variable_value(self, project_id: str, env_id: str, variable_name: str,
|
|
||||||
callback: Callable[[str], None]):
|
|
||||||
projects = self.load_projects()
|
|
||||||
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
if variable_name in p.sensitive_variables:
|
|
||||||
secret_manager = get_secret_manager()
|
|
||||||
|
|
||||||
def on_retrieve(value):
|
|
||||||
callback(value or "")
|
|
||||||
|
|
||||||
secret_manager.retrieve_secret(
|
|
||||||
project_id=project_id,
|
|
||||||
environment_id=env_id,
|
|
||||||
variable_name=variable_name,
|
|
||||||
callback=on_retrieve
|
|
||||||
)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
for env in p.environments:
|
|
||||||
if env.id == env_id:
|
|
||||||
callback(env.variables.get(variable_name, ""))
|
|
||||||
return
|
|
||||||
|
|
||||||
callback("")
|
|
||||||
|
|
||||||
def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str,
|
|
||||||
callback: Optional[Callable[[bool], None]] = None):
|
|
||||||
projects = self.load_projects()
|
|
||||||
secret_manager = get_secret_manager()
|
|
||||||
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
if variable_name in p.sensitive_variables:
|
|
||||||
for env in p.environments:
|
|
||||||
if env.id == env_id:
|
|
||||||
secret_manager.store_secret(
|
|
||||||
project_id=project_id,
|
|
||||||
environment_id=env.id,
|
|
||||||
variable_name=variable_name,
|
|
||||||
value=value,
|
|
||||||
project_name=p.name,
|
|
||||||
environment_name=env.name,
|
|
||||||
callback=callback
|
|
||||||
)
|
|
||||||
env.variables[variable_name] = ""
|
|
||||||
self._save_project(p)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
for env in p.environments:
|
|
||||||
if env.id == env_id:
|
|
||||||
env.variables[variable_name] = value
|
|
||||||
break
|
|
||||||
self._save_project(p)
|
|
||||||
if callback:
|
|
||||||
callback(True)
|
|
||||||
return
|
|
||||||
|
|
||||||
if callback:
|
|
||||||
callback(False)
|
|
||||||
|
|
||||||
def get_environment_with_secrets(self, project_id: str, env_id: str,
|
|
||||||
callback: Callable[[Optional[Environment]], None]):
|
|
||||||
projects = self.load_projects()
|
|
||||||
secret_manager = get_secret_manager()
|
|
||||||
|
|
||||||
for p in projects:
|
|
||||||
if p.id == project_id:
|
|
||||||
for env in p.environments:
|
|
||||||
if env.id == env_id:
|
|
||||||
complete_env = Environment(
|
|
||||||
id=env.id,
|
|
||||||
name=env.name,
|
|
||||||
variables=env.variables.copy(),
|
|
||||||
created_at=env.created_at
|
|
||||||
)
|
|
||||||
|
|
||||||
if not p.sensitive_variables:
|
|
||||||
callback(complete_env)
|
|
||||||
return
|
|
||||||
|
|
||||||
def on_secrets_retrieved(secrets_dict):
|
|
||||||
for var_name, value in secrets_dict.items():
|
|
||||||
if value is not None:
|
|
||||||
complete_env.variables[var_name] = value
|
|
||||||
callback(complete_env)
|
|
||||||
|
|
||||||
secret_manager.retrieve_multiple_secrets(
|
|
||||||
project_id=project_id,
|
|
||||||
environment_id=env_id,
|
|
||||||
variable_names=list(p.sensitive_variables),
|
|
||||||
callback=on_secrets_retrieved
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
callback(None)
|
|
||||||
|
|||||||
@ -1,24 +1,13 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<gresources>
|
<gresources>
|
||||||
<gresource prefix="/cz/bugsy/roster">
|
<gresource prefix="/cz/vesp/roster">
|
||||||
<file preprocess="xml-stripblanks">main-window.ui</file>
|
<file preprocess="xml-stripblanks">main-window.ui</file>
|
||||||
<file preprocess="xml-stripblanks">shortcuts-dialog.ui</file>
|
<file preprocess="xml-stripblanks">shortcuts-dialog.ui</file>
|
||||||
<file preprocess="xml-stripblanks">preferences-dialog.ui</file>
|
<file preprocess="xml-stripblanks">preferences-dialog.ui</file>
|
||||||
<file preprocess="xml-stripblanks">icon-picker-dialog.ui</file>
|
<file preprocess="xml-stripblanks">icon-picker-dialog.ui</file>
|
||||||
<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/header-row.ui</file>
|
||||||
<file preprocess="xml-stripblanks">widgets/history-item.ui</file>
|
<file preprocess="xml-stripblanks">widgets/history-item.ui</file>
|
||||||
<file preprocess="xml-stripblanks">widgets/project-item.ui</file>
|
<file preprocess="xml-stripblanks">widgets/project-item.ui</file>
|
||||||
<file preprocess="xml-stripblanks">widgets/request-item.ui</file>
|
<file preprocess="xml-stripblanks">widgets/request-item.ui</file>
|
||||||
<file preprocess="xml-stripblanks">widgets/variable-row.ui</file>
|
|
||||||
<file preprocess="xml-stripblanks">widgets/environment-row.ui</file>
|
|
||||||
<file preprocess="xml-stripblanks">widgets/environment-column-header.ui</file>
|
|
||||||
<file preprocess="xml-stripblanks">widgets/environment-header-row.ui</file>
|
|
||||||
<file preprocess="xml-stripblanks">widgets/variable-data-row.ui</file>
|
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
|
|||||||
@ -1,446 +0,0 @@
|
|||||||
# script_executor.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 subprocess
|
|
||||||
import json
|
|
||||||
import tempfile
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Tuple, Optional, Dict, List, TYPE_CHECKING
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from .constants import SCRIPT_EXECUTION_TIMEOUT_SECONDS
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .project_manager import ProjectManager
|
|
||||||
from .models import HttpRequest
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ScriptResult:
|
|
||||||
"""Result of script execution."""
|
|
||||||
success: bool
|
|
||||||
output: str # console.log output or error message
|
|
||||||
error: Optional[str] = None
|
|
||||||
variable_updates: Dict[str, str] = field(default_factory=dict) # Variables set by script
|
|
||||||
warnings: List[str] = field(default_factory=list) # Warnings (e.g., no env selected)
|
|
||||||
modified_request: Optional['HttpRequest'] = None # Modified request from preprocessing
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ScriptContext:
|
|
||||||
"""Context passed to script executor for variable updates."""
|
|
||||||
project_id: Optional[str]
|
|
||||||
environment_id: Optional[str]
|
|
||||||
project_manager: 'ProjectManager'
|
|
||||||
|
|
||||||
|
|
||||||
class ScriptExecutor:
|
|
||||||
"""Executes JavaScript code using gjs (GNOME JavaScript)."""
|
|
||||||
|
|
||||||
TIMEOUT_SECONDS = SCRIPT_EXECUTION_TIMEOUT_SECONDS
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def execute_postprocessing_script(
|
|
||||||
script_code: str,
|
|
||||||
response,
|
|
||||||
context: Optional[ScriptContext] = None
|
|
||||||
) -> ScriptResult:
|
|
||||||
"""
|
|
||||||
Execute postprocessing script with response object.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
script_code: JavaScript code to execute
|
|
||||||
response: HttpResponse object
|
|
||||||
context: Optional context for variable updates
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ScriptResult with output, variable updates, or error
|
|
||||||
"""
|
|
||||||
if not script_code.strip():
|
|
||||||
return ScriptResult(success=True, output="")
|
|
||||||
|
|
||||||
# Create response object for JavaScript
|
|
||||||
response_obj = ScriptExecutor._create_response_object(response)
|
|
||||||
|
|
||||||
# Build complete script with context
|
|
||||||
script_with_context = f"""
|
|
||||||
const response = {json.dumps(response_obj)};
|
|
||||||
|
|
||||||
// Roster API for variable management
|
|
||||||
const roster = {{
|
|
||||||
__variables: {{}},
|
|
||||||
setVariable: function(name, value) {{
|
|
||||||
this.__variables[String(name)] = String(value);
|
|
||||||
}},
|
|
||||||
setVariables: function(obj) {{
|
|
||||||
for (let key in obj) {{
|
|
||||||
if (obj.hasOwnProperty(key)) {{
|
|
||||||
this.__variables[String(key)] = String(obj[key]);
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}};
|
|
||||||
|
|
||||||
// Capture console.log output
|
|
||||||
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(' '));
|
|
||||||
}}
|
|
||||||
}};
|
|
||||||
|
|
||||||
// User script
|
|
||||||
try {{
|
|
||||||
{script_code}
|
|
||||||
}} catch (error) {{
|
|
||||||
consoleOutput.push('Error: ' + error.message);
|
|
||||||
throw error;
|
|
||||||
}}
|
|
||||||
|
|
||||||
// Output results: console logs + separator + variables JSON
|
|
||||||
print(consoleOutput.join('\\n'));
|
|
||||||
print('___ROSTER_VARIABLES___');
|
|
||||||
print(JSON.stringify(roster.__variables));
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Execute with gjs
|
|
||||||
output, error, returncode = ScriptExecutor._run_gjs_script(
|
|
||||||
script_with_context,
|
|
||||||
timeout=ScriptExecutor.TIMEOUT_SECONDS
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse output and extract variables
|
|
||||||
console_output = ""
|
|
||||||
variable_updates = {}
|
|
||||||
warnings = []
|
|
||||||
|
|
||||||
if returncode == 0:
|
|
||||||
# Split output into console logs and variables JSON
|
|
||||||
parts = output.split('___ROSTER_VARIABLES___')
|
|
||||||
console_output = parts[0].strip()
|
|
||||||
|
|
||||||
if len(parts) > 1:
|
|
||||||
try:
|
|
||||||
variables_json = parts[1].strip()
|
|
||||||
raw_variables = json.loads(variables_json)
|
|
||||||
|
|
||||||
# Validate variable names and add to updates
|
|
||||||
for name, value in raw_variables.items():
|
|
||||||
if ScriptExecutor._is_valid_variable_name(name):
|
|
||||||
variable_updates[name] = value
|
|
||||||
else:
|
|
||||||
warnings.append(f"Invalid variable name '{name}' (must be alphanumeric + underscore)")
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
warnings.append("Failed to parse variable updates from script")
|
|
||||||
|
|
||||||
return ScriptResult(
|
|
||||||
success=True,
|
|
||||||
output=console_output,
|
|
||||||
variable_updates=variable_updates,
|
|
||||||
warnings=warnings
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
error_msg = error.strip() if error else "Script execution failed"
|
|
||||||
return ScriptResult(success=False, output="", error=error_msg)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def execute_preprocessing_script(
|
|
||||||
script_code: str,
|
|
||||||
request,
|
|
||||||
context: Optional[ScriptContext] = None
|
|
||||||
) -> ScriptResult:
|
|
||||||
"""
|
|
||||||
Execute preprocessing script with request object.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
script_code: JavaScript code to execute
|
|
||||||
request: HttpRequest object (will be modified)
|
|
||||||
context: Optional context for variable access and updates
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ScriptResult with output, modified request, variable updates, or error
|
|
||||||
"""
|
|
||||||
if not script_code.strip():
|
|
||||||
return ScriptResult(success=True, output="")
|
|
||||||
|
|
||||||
# Import here to avoid circular import
|
|
||||||
from .models import HttpRequest
|
|
||||||
|
|
||||||
# Create request object for JavaScript
|
|
||||||
request_obj = ScriptExecutor._create_request_object(request)
|
|
||||||
|
|
||||||
# Get environment variables (read-only)
|
|
||||||
env_vars = ScriptExecutor._get_environment_variables(context)
|
|
||||||
|
|
||||||
# Get project metadata
|
|
||||||
project_meta = ScriptExecutor._get_project_metadata(context)
|
|
||||||
|
|
||||||
# Build complete script with context
|
|
||||||
script_with_context = f"""
|
|
||||||
const request = {json.dumps(request_obj)};
|
|
||||||
|
|
||||||
// Roster API for variable management
|
|
||||||
const roster = {{
|
|
||||||
__variables: {{}}, // For setVariable
|
|
||||||
__envVariables: {json.dumps(env_vars)}, // Read-only current environment
|
|
||||||
|
|
||||||
getVariable: function(name) {{
|
|
||||||
return this.__envVariables[name] || null;
|
|
||||||
}},
|
|
||||||
|
|
||||||
setVariable: function(name, value) {{
|
|
||||||
this.__variables[String(name)] = String(value);
|
|
||||||
}},
|
|
||||||
|
|
||||||
setVariables: function(obj) {{
|
|
||||||
for (let key in obj) {{
|
|
||||||
if (obj.hasOwnProperty(key)) {{
|
|
||||||
this.__variables[String(key)] = String(obj[key]);
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}},
|
|
||||||
|
|
||||||
project: {json.dumps(project_meta)}
|
|
||||||
}};
|
|
||||||
|
|
||||||
// Capture console.log output
|
|
||||||
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(' '));
|
|
||||||
}}
|
|
||||||
}};
|
|
||||||
|
|
||||||
// User script
|
|
||||||
try {{
|
|
||||||
{script_code}
|
|
||||||
}} catch (error) {{
|
|
||||||
consoleOutput.push('Error: ' + error.message);
|
|
||||||
throw error;
|
|
||||||
}}
|
|
||||||
|
|
||||||
// Output results: modified request + separator + console logs + variables
|
|
||||||
print(JSON.stringify(request));
|
|
||||||
print('___ROSTER_SEPARATOR___');
|
|
||||||
print(consoleOutput.join('\\n'));
|
|
||||||
print('___ROSTER_VARIABLES___');
|
|
||||||
print(JSON.stringify(roster.__variables));
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Execute with gjs
|
|
||||||
output, error, returncode = ScriptExecutor._run_gjs_script(
|
|
||||||
script_with_context,
|
|
||||||
timeout=ScriptExecutor.TIMEOUT_SECONDS
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse output and extract results
|
|
||||||
console_output = ""
|
|
||||||
variable_updates = {}
|
|
||||||
warnings = []
|
|
||||||
modified_request = None
|
|
||||||
|
|
||||||
if returncode == 0:
|
|
||||||
# Split output into: request JSON + console logs + variables JSON
|
|
||||||
parts = output.split('___ROSTER_SEPARATOR___')
|
|
||||||
if len(parts) >= 2:
|
|
||||||
request_json_str = parts[0].strip()
|
|
||||||
rest = parts[1]
|
|
||||||
|
|
||||||
# Parse modified request
|
|
||||||
try:
|
|
||||||
request_data = json.loads(request_json_str)
|
|
||||||
|
|
||||||
# Validate required fields
|
|
||||||
if not request_data.get('url') or not request_data.get('url').strip():
|
|
||||||
warnings.append("Modified request has empty URL")
|
|
||||||
return ScriptResult(
|
|
||||||
success=False,
|
|
||||||
output="",
|
|
||||||
error="Script produced invalid request: URL is empty",
|
|
||||||
warnings=warnings
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create HttpRequest from modified data
|
|
||||||
modified_request = HttpRequest(
|
|
||||||
method=request_data.get('method', 'GET'),
|
|
||||||
url=request_data.get('url', ''),
|
|
||||||
headers=request_data.get('headers', {}),
|
|
||||||
body=request_data.get('body', ''),
|
|
||||||
syntax=request.syntax # Preserve syntax from original
|
|
||||||
)
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
warnings.append(f"Failed to parse modified request: {str(e)}")
|
|
||||||
return ScriptResult(
|
|
||||||
success=False,
|
|
||||||
output="",
|
|
||||||
error="Script produced invalid request JSON",
|
|
||||||
warnings=warnings
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse console output and variables
|
|
||||||
var_parts = rest.split('___ROSTER_VARIABLES___')
|
|
||||||
console_output = var_parts[0].strip()
|
|
||||||
|
|
||||||
if len(var_parts) > 1:
|
|
||||||
try:
|
|
||||||
variables_json = var_parts[1].strip()
|
|
||||||
raw_variables = json.loads(variables_json)
|
|
||||||
|
|
||||||
# Validate variable names and add to updates
|
|
||||||
for name, value in raw_variables.items():
|
|
||||||
if ScriptExecutor._is_valid_variable_name(name):
|
|
||||||
variable_updates[name] = value
|
|
||||||
else:
|
|
||||||
warnings.append(f"Invalid variable name '{name}' (must be alphanumeric + underscore)")
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
warnings.append("Failed to parse variable updates from script")
|
|
||||||
|
|
||||||
return ScriptResult(
|
|
||||||
success=True,
|
|
||||||
output=console_output,
|
|
||||||
variable_updates=variable_updates,
|
|
||||||
warnings=warnings,
|
|
||||||
modified_request=modified_request
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
error_msg = error.strip() if error else "Script execution failed"
|
|
||||||
return ScriptResult(success=False, output="", error=error_msg)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _create_response_object(response) -> dict:
|
|
||||||
"""Convert HttpResponse to JavaScript-compatible dict."""
|
|
||||||
# Parse headers text into dict
|
|
||||||
headers = {}
|
|
||||||
if response.headers:
|
|
||||||
for line in response.headers.split('\n')[1:]: # Skip status line
|
|
||||||
line = line.strip()
|
|
||||||
if ':' in line:
|
|
||||||
key, value = line.split(':', 1)
|
|
||||||
headers[key.strip()] = value.strip()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'body': response.body,
|
|
||||||
'headers': headers,
|
|
||||||
'statusCode': response.status_code,
|
|
||||||
'statusText': response.status_text,
|
|
||||||
'responseTime': response.response_time_ms
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _create_request_object(request) -> dict:
|
|
||||||
"""Convert HttpRequest to JavaScript-compatible dict."""
|
|
||||||
return {
|
|
||||||
'method': request.method,
|
|
||||||
'url': request.url,
|
|
||||||
'headers': dict(request.headers), # Convert to regular dict
|
|
||||||
'body': request.body
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_environment_variables(context: Optional[ScriptContext]) -> dict:
|
|
||||||
"""Get environment variables from context."""
|
|
||||||
if not context or not context.project_id or not context.environment_id:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# Load projects and find the environment
|
|
||||||
projects = context.project_manager.load_projects()
|
|
||||||
for project in projects:
|
|
||||||
if project.id == context.project_id:
|
|
||||||
for env in project.environments:
|
|
||||||
if env.id == context.environment_id:
|
|
||||||
return env.variables
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_project_metadata(context: Optional[ScriptContext]) -> dict:
|
|
||||||
"""Get project metadata (name and environments)."""
|
|
||||||
if not context or not context.project_id:
|
|
||||||
return {'name': '', 'environments': []}
|
|
||||||
|
|
||||||
# Load projects and find the project
|
|
||||||
projects = context.project_manager.load_projects()
|
|
||||||
for project in projects:
|
|
||||||
if project.id == context.project_id:
|
|
||||||
return {
|
|
||||||
'name': project.name,
|
|
||||||
'environments': [env.name for env in project.environments]
|
|
||||||
}
|
|
||||||
return {'name': '', 'environments': []}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_valid_variable_name(name: str) -> bool:
|
|
||||||
"""
|
|
||||||
Validate variable name (alphanumeric + underscore).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Variable name to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if valid, False otherwise
|
|
||||||
"""
|
|
||||||
return bool(re.match(r'^\w+$', name))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _run_gjs_script(script_code: str, timeout: int = 5) -> Tuple[str, str, int]:
|
|
||||||
"""
|
|
||||||
Run JavaScript code using gjs.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
script_code: Complete JavaScript code
|
|
||||||
timeout: Execution timeout in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (stdout, stderr, returncode)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Write script to temporary file
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False) as f:
|
|
||||||
f.write(script_code)
|
|
||||||
script_path = f.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Execute with gjs
|
|
||||||
result = subprocess.run(
|
|
||||||
['gjs', script_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.stdout, result.stderr, result.returncode
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up temp file
|
|
||||||
Path(script_path).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return "", f"Script execution timeout ({timeout}s)", 1
|
|
||||||
except FileNotFoundError:
|
|
||||||
return "", "gjs not found. Please ensure GNOME JavaScript is installed.", 1
|
|
||||||
except Exception as e:
|
|
||||||
return "", f"Script execution error: {str(e)}", 1
|
|
||||||
@ -1,455 +0,0 @@
|
|||||||
# secret_manager.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
|
|
||||||
|
|
||||||
"""
|
|
||||||
Manager for storing and retrieving sensitive variable values using GNOME Keyring.
|
|
||||||
|
|
||||||
This module provides a clean interface to libsecret for storing environment
|
|
||||||
variable values that contain sensitive data (API keys, passwords, tokens).
|
|
||||||
|
|
||||||
Uses async APIs for compatibility with the XDG Secrets Portal in sandboxed
|
|
||||||
environments (Flatpak).
|
|
||||||
|
|
||||||
Each secret is identified by:
|
|
||||||
- project_id: UUID of the project
|
|
||||||
- environment_id: UUID of the environment
|
|
||||||
- variable_name: Name of the variable
|
|
||||||
"""
|
|
||||||
|
|
||||||
import gi
|
|
||||||
gi.require_version('Secret', '1')
|
|
||||||
from gi.repository import Secret, GLib, Gio
|
|
||||||
import logging
|
|
||||||
from typing import Callable, Optional, Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Define schema for Roster environment variables
|
|
||||||
# This separates them from other secrets in GNOME Keyring
|
|
||||||
ROSTER_SCHEMA = Secret.Schema.new(
|
|
||||||
"cz.bugsy.roster.EnvironmentVariable",
|
|
||||||
Secret.SchemaFlags.NONE,
|
|
||||||
{
|
|
||||||
"project_id": Secret.SchemaAttributeType.STRING,
|
|
||||||
"environment_id": Secret.SchemaAttributeType.STRING,
|
|
||||||
"variable_name": Secret.SchemaAttributeType.STRING,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SecretManager:
|
|
||||||
"""Manages sensitive variable storage using GNOME Keyring via libsecret.
|
|
||||||
|
|
||||||
All methods use async APIs for compatibility with the XDG Secrets Portal.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize the secret manager."""
|
|
||||||
self.schema = ROSTER_SCHEMA
|
|
||||||
|
|
||||||
def store_secret(self, project_id: str, environment_id: str,
|
|
||||||
variable_name: str, value: str, project_name: str = "",
|
|
||||||
environment_name: str = "",
|
|
||||||
callback: Optional[Callable[[bool], None]] = None):
|
|
||||||
"""
|
|
||||||
Store a sensitive variable value in GNOME Keyring (async).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project
|
|
||||||
environment_id: UUID of the environment
|
|
||||||
variable_name: Name of the variable
|
|
||||||
value: The secret value to store
|
|
||||||
project_name: Optional human-readable project name (for display in Seahorse)
|
|
||||||
environment_name: Optional human-readable environment name (for display)
|
|
||||||
callback: Optional callback function called with success boolean
|
|
||||||
"""
|
|
||||||
# Create a descriptive label for the keyring UI
|
|
||||||
label = f"Roster: {project_name}/{environment_name}/{variable_name}" if project_name else \
|
|
||||||
f"Roster Variable: {variable_name}"
|
|
||||||
|
|
||||||
# Build attributes dictionary for the secret
|
|
||||||
attributes = {
|
|
||||||
"project_id": project_id,
|
|
||||||
"environment_id": environment_id,
|
|
||||||
"variable_name": variable_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
def on_store_complete(source, result, user_data):
|
|
||||||
try:
|
|
||||||
Secret.password_store_finish(result)
|
|
||||||
logger.info(f"Stored secret for {variable_name} in {environment_id}")
|
|
||||||
if callback:
|
|
||||||
callback(True)
|
|
||||||
except GLib.Error as e:
|
|
||||||
logger.error(f"Failed to store secret: {e.message}")
|
|
||||||
if callback:
|
|
||||||
callback(False)
|
|
||||||
|
|
||||||
# Store the secret asynchronously
|
|
||||||
Secret.password_store(
|
|
||||||
self.schema,
|
|
||||||
attributes,
|
|
||||||
Secret.COLLECTION_DEFAULT,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
None, # cancellable
|
|
||||||
on_store_complete,
|
|
||||||
None # user_data
|
|
||||||
)
|
|
||||||
|
|
||||||
def retrieve_secret(self, project_id: str, environment_id: str,
|
|
||||||
variable_name: str,
|
|
||||||
callback: Callable[[Optional[str]], None]):
|
|
||||||
"""
|
|
||||||
Retrieve a sensitive variable value from GNOME Keyring (async).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project
|
|
||||||
environment_id: UUID of the environment
|
|
||||||
variable_name: Name of the variable
|
|
||||||
callback: Callback function called with the secret value (or None if not found)
|
|
||||||
"""
|
|
||||||
attributes = {
|
|
||||||
"project_id": project_id,
|
|
||||||
"environment_id": environment_id,
|
|
||||||
"variable_name": variable_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
def on_lookup_complete(source, result, user_data):
|
|
||||||
try:
|
|
||||||
value = Secret.password_lookup_finish(result)
|
|
||||||
if value is None:
|
|
||||||
logger.debug(f"No secret found for {variable_name}")
|
|
||||||
else:
|
|
||||||
logger.debug(f"Retrieved secret for {variable_name}")
|
|
||||||
callback(value)
|
|
||||||
except GLib.Error as e:
|
|
||||||
logger.error(f"Failed to retrieve secret: {e.message}")
|
|
||||||
callback(None)
|
|
||||||
|
|
||||||
# Retrieve the secret asynchronously
|
|
||||||
Secret.password_lookup(
|
|
||||||
self.schema,
|
|
||||||
attributes,
|
|
||||||
None, # cancellable
|
|
||||||
on_lookup_complete,
|
|
||||||
None # user_data
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete_secret(self, project_id: str, environment_id: str,
|
|
||||||
variable_name: str,
|
|
||||||
callback: Optional[Callable[[bool], None]] = None):
|
|
||||||
"""
|
|
||||||
Delete a sensitive variable value from GNOME Keyring (async).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project
|
|
||||||
environment_id: UUID of the environment
|
|
||||||
variable_name: Name of the variable
|
|
||||||
callback: Optional callback function called with success boolean
|
|
||||||
"""
|
|
||||||
attributes = {
|
|
||||||
"project_id": project_id,
|
|
||||||
"environment_id": environment_id,
|
|
||||||
"variable_name": variable_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
def on_clear_complete(source, result, user_data):
|
|
||||||
try:
|
|
||||||
deleted = Secret.password_clear_finish(result)
|
|
||||||
if deleted:
|
|
||||||
logger.info(f"Deleted secret for {variable_name}")
|
|
||||||
else:
|
|
||||||
logger.debug(f"No secret to delete for {variable_name}")
|
|
||||||
if callback:
|
|
||||||
callback(True)
|
|
||||||
except GLib.Error as e:
|
|
||||||
logger.error(f"Failed to delete secret: {e.message}")
|
|
||||||
if callback:
|
|
||||||
callback(False)
|
|
||||||
|
|
||||||
# Delete the secret asynchronously
|
|
||||||
Secret.password_clear(
|
|
||||||
self.schema,
|
|
||||||
attributes,
|
|
||||||
None, # cancellable
|
|
||||||
on_clear_complete,
|
|
||||||
None # user_data
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete_all_project_secrets(self, project_id: str,
|
|
||||||
callback: Optional[Callable[[bool], None]] = None):
|
|
||||||
"""
|
|
||||||
Delete all secrets for a project (when project is deleted).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project
|
|
||||||
callback: Optional callback function called with success boolean
|
|
||||||
"""
|
|
||||||
attributes = {
|
|
||||||
"project_id": project_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
def on_clear_complete(source, result, user_data):
|
|
||||||
try:
|
|
||||||
Secret.password_clear_finish(result)
|
|
||||||
logger.info(f"Deleted all secrets for project {project_id}")
|
|
||||||
if callback:
|
|
||||||
callback(True)
|
|
||||||
except GLib.Error as e:
|
|
||||||
logger.error(f"Failed to delete project secrets: {e.message}")
|
|
||||||
if callback:
|
|
||||||
callback(False)
|
|
||||||
|
|
||||||
# Delete all matching secrets asynchronously
|
|
||||||
Secret.password_clear(
|
|
||||||
self.schema,
|
|
||||||
attributes,
|
|
||||||
None,
|
|
||||||
on_clear_complete,
|
|
||||||
None
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete_all_environment_secrets(self, project_id: str, environment_id: str,
|
|
||||||
callback: Optional[Callable[[bool], None]] = None):
|
|
||||||
"""
|
|
||||||
Delete all secrets for an environment (when environment is deleted).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project
|
|
||||||
environment_id: UUID of the environment
|
|
||||||
callback: Optional callback function called with success boolean
|
|
||||||
"""
|
|
||||||
attributes = {
|
|
||||||
"project_id": project_id,
|
|
||||||
"environment_id": environment_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
def on_clear_complete(source, result, user_data):
|
|
||||||
try:
|
|
||||||
Secret.password_clear_finish(result)
|
|
||||||
logger.info(f"Deleted all secrets for environment {environment_id}")
|
|
||||||
if callback:
|
|
||||||
callback(True)
|
|
||||||
except GLib.Error as e:
|
|
||||||
logger.error(f"Failed to delete environment secrets: {e.message}")
|
|
||||||
if callback:
|
|
||||||
callback(False)
|
|
||||||
|
|
||||||
Secret.password_clear(
|
|
||||||
self.schema,
|
|
||||||
attributes,
|
|
||||||
None,
|
|
||||||
on_clear_complete,
|
|
||||||
None
|
|
||||||
)
|
|
||||||
|
|
||||||
def rename_variable_secrets(self, project_id: str, old_name: str, new_name: str,
|
|
||||||
callback: Optional[Callable[[bool], None]] = None):
|
|
||||||
"""
|
|
||||||
Rename a variable across all environments (updates secret keys).
|
|
||||||
|
|
||||||
This is called when a variable is renamed. We need to:
|
|
||||||
1. Find all secrets with old_name
|
|
||||||
2. Store them with new_name
|
|
||||||
3. Delete old secrets
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project
|
|
||||||
old_name: Current variable name
|
|
||||||
new_name: New variable name
|
|
||||||
callback: Optional callback function called with success boolean
|
|
||||||
"""
|
|
||||||
attributes = {
|
|
||||||
"project_id": project_id,
|
|
||||||
"variable_name": old_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
def on_search_complete(source, result, user_data):
|
|
||||||
try:
|
|
||||||
secrets = Secret.password_search_finish(result)
|
|
||||||
|
|
||||||
if not secrets:
|
|
||||||
logger.debug(f"No secrets found for variable {old_name}")
|
|
||||||
if callback:
|
|
||||||
callback(True)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Track how many secrets we need to process
|
|
||||||
pending_count = [len(secrets)]
|
|
||||||
all_success = [True]
|
|
||||||
|
|
||||||
def on_rename_done(success):
|
|
||||||
pending_count[0] -= 1
|
|
||||||
if not success:
|
|
||||||
all_success[0] = False
|
|
||||||
if pending_count[0] == 0:
|
|
||||||
logger.info(f"Renamed variable secrets from {old_name} to {new_name}")
|
|
||||||
if callback:
|
|
||||||
callback(all_success[0])
|
|
||||||
|
|
||||||
# For each secret, retrieve value, store with new name, delete old
|
|
||||||
for item in secrets:
|
|
||||||
self._rename_single_secret(item, project_id, old_name, new_name, on_rename_done)
|
|
||||||
|
|
||||||
except GLib.Error as e:
|
|
||||||
logger.error(f"Failed to search for variable secrets: {e.message}")
|
|
||||||
if callback:
|
|
||||||
callback(False)
|
|
||||||
|
|
||||||
# Search for matching secrets asynchronously
|
|
||||||
Secret.password_search(
|
|
||||||
self.schema,
|
|
||||||
attributes,
|
|
||||||
Secret.SearchFlags.ALL,
|
|
||||||
None,
|
|
||||||
on_search_complete,
|
|
||||||
None
|
|
||||||
)
|
|
||||||
|
|
||||||
def _rename_single_secret(self, item, project_id: str, old_name: str,
|
|
||||||
new_name: str, callback: Callable[[bool], None]):
|
|
||||||
"""Helper to rename a single secret item."""
|
|
||||||
|
|
||||||
def on_load_complete(item, result, user_data):
|
|
||||||
try:
|
|
||||||
item.load_secret_finish(result)
|
|
||||||
secret = item.get_secret()
|
|
||||||
if secret is None:
|
|
||||||
callback(False)
|
|
||||||
return
|
|
||||||
|
|
||||||
value = secret.get_text()
|
|
||||||
attrs = item.get_attributes()
|
|
||||||
environment_id = attrs.get("environment_id")
|
|
||||||
|
|
||||||
if not environment_id:
|
|
||||||
logger.warning("Secret missing environment_id, skipping")
|
|
||||||
callback(False)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Store with new name, then delete old
|
|
||||||
def on_store_done(success):
|
|
||||||
if success:
|
|
||||||
self.delete_secret(project_id, environment_id, old_name, callback)
|
|
||||||
else:
|
|
||||||
callback(False)
|
|
||||||
|
|
||||||
self.store_secret(
|
|
||||||
project_id=project_id,
|
|
||||||
environment_id=environment_id,
|
|
||||||
variable_name=new_name,
|
|
||||||
value=value,
|
|
||||||
callback=on_store_done
|
|
||||||
)
|
|
||||||
|
|
||||||
except GLib.Error as e:
|
|
||||||
logger.error(f"Failed to load secret for rename: {e.message}")
|
|
||||||
callback(False)
|
|
||||||
|
|
||||||
item.load_secret(None, on_load_complete, None)
|
|
||||||
|
|
||||||
# ========== Batch Operations ==========
|
|
||||||
|
|
||||||
def retrieve_multiple_secrets(self, project_id: str, environment_id: str,
|
|
||||||
variable_names: list,
|
|
||||||
callback: Callable[[dict], None]):
|
|
||||||
"""
|
|
||||||
Retrieve multiple secrets at once (async).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project
|
|
||||||
environment_id: UUID of the environment
|
|
||||||
variable_names: List of variable names to retrieve
|
|
||||||
callback: Callback function called with dict of {variable_name: value}
|
|
||||||
"""
|
|
||||||
if not variable_names:
|
|
||||||
callback({})
|
|
||||||
return
|
|
||||||
|
|
||||||
results = {}
|
|
||||||
pending_count = [len(variable_names)]
|
|
||||||
|
|
||||||
def on_single_retrieve(var_name):
|
|
||||||
def handler(value):
|
|
||||||
results[var_name] = value
|
|
||||||
pending_count[0] -= 1
|
|
||||||
if pending_count[0] == 0:
|
|
||||||
callback(results)
|
|
||||||
return handler
|
|
||||||
|
|
||||||
for var_name in variable_names:
|
|
||||||
self.retrieve_secret(
|
|
||||||
project_id=project_id,
|
|
||||||
environment_id=environment_id,
|
|
||||||
variable_name=var_name,
|
|
||||||
callback=on_single_retrieve(var_name)
|
|
||||||
)
|
|
||||||
|
|
||||||
def store_multiple_secrets(self, project_id: str, environment_id: str,
|
|
||||||
variables: dict, project_name: str = "",
|
|
||||||
environment_name: str = "",
|
|
||||||
callback: Optional[Callable[[bool], None]] = None):
|
|
||||||
"""
|
|
||||||
Store multiple secrets at once (async).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project
|
|
||||||
environment_id: UUID of the environment
|
|
||||||
variables: Dict of {variable_name: value} to store
|
|
||||||
project_name: Optional project name for display
|
|
||||||
environment_name: Optional environment name for display
|
|
||||||
callback: Optional callback called with overall success boolean
|
|
||||||
"""
|
|
||||||
if not variables:
|
|
||||||
if callback:
|
|
||||||
callback(True)
|
|
||||||
return
|
|
||||||
|
|
||||||
pending_count = [len(variables)]
|
|
||||||
all_success = [True]
|
|
||||||
|
|
||||||
def on_single_store(success):
|
|
||||||
if not success:
|
|
||||||
all_success[0] = False
|
|
||||||
pending_count[0] -= 1
|
|
||||||
if pending_count[0] == 0 and callback:
|
|
||||||
callback(all_success[0])
|
|
||||||
|
|
||||||
for var_name, value in variables.items():
|
|
||||||
self.store_secret(
|
|
||||||
project_id=project_id,
|
|
||||||
environment_id=environment_id,
|
|
||||||
variable_name=var_name,
|
|
||||||
value=value,
|
|
||||||
project_name=project_name,
|
|
||||||
environment_name=environment_name,
|
|
||||||
callback=on_single_store
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
|
||||||
_secret_manager = None
|
|
||||||
|
|
||||||
def get_secret_manager() -> SecretManager:
|
|
||||||
"""Get the global SecretManager instance."""
|
|
||||||
global _secret_manager
|
|
||||||
if _secret_manager is None:
|
|
||||||
_secret_manager = SecretManager()
|
|
||||||
return _secret_manager
|
|
||||||
@ -26,18 +26,6 @@
|
|||||||
<property name="accelerator"><Control>s</property>
|
<property name="accelerator"><Control>s</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
|
||||||
<object class="GtkShortcutsShortcut">
|
|
||||||
<property name="title" translatable="yes" context="shortcut window">Focus URL Field</property>
|
|
||||||
<property name="accelerator"><Control>l</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkShortcutsShortcut">
|
|
||||||
<property name="title" translatable="yes" context="shortcut window">Send Request</property>
|
|
||||||
<property name="accelerator"><Control>Return</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkShortcutsShortcut">
|
<object class="GtkShortcutsShortcut">
|
||||||
<property name="title" translatable="yes" context="shortcut window">Show Shortcuts</property>
|
<property name="title" translatable="yes" context="shortcut window">Show Shortcuts</property>
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
@ -21,13 +20,10 @@
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .models import RequestTab, HttpRequest, HttpResponse
|
from .models import RequestTab, HttpRequest, HttpResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TabManager:
|
class TabManager:
|
||||||
"""Manages open request tabs."""
|
"""Manages open request tabs."""
|
||||||
@ -38,10 +34,7 @@ class TabManager:
|
|||||||
|
|
||||||
def create_tab(self, name: str, request: HttpRequest,
|
def create_tab(self, name: str, request: HttpRequest,
|
||||||
saved_request_id: Optional[str] = None,
|
saved_request_id: Optional[str] = None,
|
||||||
response: Optional[HttpResponse] = None,
|
response: Optional[HttpResponse] = None) -> RequestTab:
|
||||||
project_id: Optional[str] = None,
|
|
||||||
selected_environment_id: Optional[str] = None,
|
|
||||||
scripts=None) -> RequestTab:
|
|
||||||
"""Create a new tab and add it to the collection."""
|
"""Create a new tab and add it to the collection."""
|
||||||
tab_id = str(uuid.uuid4())
|
tab_id = str(uuid.uuid4())
|
||||||
|
|
||||||
@ -49,10 +42,7 @@ class TabManager:
|
|||||||
# Make a copy to avoid reference issues
|
# Make a copy to avoid reference issues
|
||||||
# Create original for saved requests or loaded history items (anything with content)
|
# Create original for saved requests or loaded history items (anything with content)
|
||||||
# Only skip for truly blank new requests
|
# Only skip for truly blank new requests
|
||||||
# Consider headers empty if they only contain default headers
|
has_content = bool(request.url or request.body or request.headers)
|
||||||
has_only_default_headers = request.headers == HttpRequest.default_headers()
|
|
||||||
has_non_default_headers = request.headers and not has_only_default_headers
|
|
||||||
has_content = bool(request.url or request.body or has_non_default_headers)
|
|
||||||
original_request = HttpRequest(
|
original_request = HttpRequest(
|
||||||
method=request.method,
|
method=request.method,
|
||||||
url=request.url,
|
url=request.url,
|
||||||
@ -68,10 +58,7 @@ class TabManager:
|
|||||||
response=response,
|
response=response,
|
||||||
saved_request_id=saved_request_id,
|
saved_request_id=saved_request_id,
|
||||||
modified=False,
|
modified=False,
|
||||||
original_request=original_request,
|
original_request=original_request
|
||||||
project_id=project_id,
|
|
||||||
selected_environment_id=selected_environment_id,
|
|
||||||
scripts=scripts
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.tabs.append(tab)
|
self.tabs.append(tab)
|
||||||
@ -154,6 +141,6 @@ class TabManager:
|
|||||||
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
||||||
# If session file is invalid, start fresh
|
# If session file is invalid, start fresh
|
||||||
logger.warning(f"Failed to load session: {e}")
|
print(f"Failed to load session: {e}")
|
||||||
self.tabs = []
|
self.tabs = []
|
||||||
self.active_tab_id = None
|
self.active_tab_id = None
|
||||||
|
|||||||
@ -1,244 +0,0 @@
|
|||||||
# variable_substitution.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 typing import Dict, List, Tuple, Set
|
|
||||||
from .models import HttpRequest, Environment
|
|
||||||
|
|
||||||
|
|
||||||
class VariableSubstitution:
|
|
||||||
"""Handles variable substitution with {{variable_name}} syntax."""
|
|
||||||
|
|
||||||
# Pattern to match {{variable_name}} where variable_name is alphanumeric + underscore
|
|
||||||
VARIABLE_PATTERN = re.compile(r'\{\{(\w+)\}\}')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def find_variables(text: str) -> List[str]:
|
|
||||||
"""
|
|
||||||
Extract all {{variable_name}} references from text.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: The text to search for variable references
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of variable names found (without the {{ }} delimiters)
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
return VariableSubstitution.VARIABLE_PATTERN.findall(text)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def substitute(text: str, variables: Dict[str, str]) -> Tuple[str, List[str]]:
|
|
||||||
"""
|
|
||||||
Replace {{variable_name}} with values from variables dict.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: The text containing variable placeholders
|
|
||||||
variables: Dictionary mapping variable names to their values
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (substituted_text, list_of_undefined_variables)
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return text, []
|
|
||||||
|
|
||||||
undefined_vars = []
|
|
||||||
|
|
||||||
def replace_var(match):
|
|
||||||
var_name = match.group(1)
|
|
||||||
if var_name in variables:
|
|
||||||
value = variables[var_name]
|
|
||||||
# Check if value is empty/None - treat as undefined for warning purposes
|
|
||||||
if not value:
|
|
||||||
undefined_vars.append(var_name)
|
|
||||||
return ""
|
|
||||||
return value
|
|
||||||
else:
|
|
||||||
# Variable not defined - track it and replace with empty string
|
|
||||||
undefined_vars.append(var_name)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
substituted = VariableSubstitution.VARIABLE_PATTERN.sub(replace_var, text)
|
|
||||||
return substituted, undefined_vars
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def substitute_request(request: HttpRequest, environment: Environment) -> Tuple[HttpRequest, Set[str]]:
|
|
||||||
"""
|
|
||||||
Substitute variables in all request fields (URL, headers, body).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The HTTP request with variable placeholders
|
|
||||||
environment: The environment containing variable values
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (new_request_with_substitutions, set_of_undefined_variables)
|
|
||||||
"""
|
|
||||||
all_undefined = set()
|
|
||||||
|
|
||||||
# Substitute URL
|
|
||||||
new_url, url_undefined = VariableSubstitution.substitute(
|
|
||||||
request.url, environment.variables
|
|
||||||
)
|
|
||||||
all_undefined.update(url_undefined)
|
|
||||||
|
|
||||||
# Substitute headers (both keys and values)
|
|
||||||
new_headers = {}
|
|
||||||
for key, value in request.headers.items():
|
|
||||||
new_key, key_undefined = VariableSubstitution.substitute(
|
|
||||||
key, environment.variables
|
|
||||||
)
|
|
||||||
new_value, value_undefined = VariableSubstitution.substitute(
|
|
||||||
value, environment.variables
|
|
||||||
)
|
|
||||||
all_undefined.update(key_undefined)
|
|
||||||
all_undefined.update(value_undefined)
|
|
||||||
new_headers[new_key] = new_value
|
|
||||||
|
|
||||||
# Substitute body
|
|
||||||
new_body, body_undefined = VariableSubstitution.substitute(
|
|
||||||
request.body, environment.variables
|
|
||||||
)
|
|
||||||
all_undefined.update(body_undefined)
|
|
||||||
|
|
||||||
# Create new HttpRequest with substituted values
|
|
||||||
new_request = HttpRequest(
|
|
||||||
method=request.method,
|
|
||||||
url=new_url,
|
|
||||||
headers=new_headers,
|
|
||||||
body=new_body,
|
|
||||||
syntax=request.syntax
|
|
||||||
)
|
|
||||||
|
|
||||||
return new_request, all_undefined
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def substitute_excluding_variables(text: str, variables: Dict[str, str], excluded_vars: List[str]) -> Tuple[str, List[str]]:
|
|
||||||
"""
|
|
||||||
Replace {{variable_name}} with values, but skip excluded variables (leave as {{var}}).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: The text containing variable placeholders
|
|
||||||
variables: Dictionary mapping variable names to their values
|
|
||||||
excluded_vars: List of variable names to skip (leave as placeholders)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (substituted_text, list_of_undefined_variables)
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return text, []
|
|
||||||
|
|
||||||
undefined_vars = []
|
|
||||||
|
|
||||||
def replace_var(match):
|
|
||||||
var_name = match.group(1)
|
|
||||||
|
|
||||||
# Skip excluded variables - leave as {{var_name}}
|
|
||||||
if var_name in excluded_vars:
|
|
||||||
return match.group(0) # Return original {{var_name}}
|
|
||||||
|
|
||||||
# Substitute non-excluded variables
|
|
||||||
if var_name in variables:
|
|
||||||
value = variables[var_name]
|
|
||||||
# Check if value is empty/None - treat as undefined for warning purposes
|
|
||||||
if not value:
|
|
||||||
undefined_vars.append(var_name)
|
|
||||||
return ""
|
|
||||||
return value
|
|
||||||
else:
|
|
||||||
# Variable not defined - track it and replace with empty string
|
|
||||||
undefined_vars.append(var_name)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
substituted = VariableSubstitution.VARIABLE_PATTERN.sub(replace_var, text)
|
|
||||||
return substituted, undefined_vars
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def substitute_request_excluding_sensitive(
|
|
||||||
request: HttpRequest,
|
|
||||||
environment: Environment,
|
|
||||||
sensitive_variables: List[str]
|
|
||||||
) -> Tuple[HttpRequest, Set[str], bool]:
|
|
||||||
"""
|
|
||||||
Substitute variables in request, but exclude sensitive variables (leave as {{var}}).
|
|
||||||
|
|
||||||
This is useful for saving requests to history without exposing sensitive values.
|
|
||||||
Sensitive variables remain in their {{variable_name}} placeholder form.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The HTTP request with variable placeholders
|
|
||||||
environment: The environment containing variable values
|
|
||||||
sensitive_variables: List of variable names to exclude from substitution
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (redacted_request, set_of_undefined_variables, has_sensitive_vars)
|
|
||||||
- redacted_request: Request with only non-sensitive variables substituted
|
|
||||||
- set_of_undefined_variables: Variables that were referenced but not defined
|
|
||||||
- has_sensitive_vars: True if any sensitive variables were found and redacted
|
|
||||||
"""
|
|
||||||
all_undefined = set()
|
|
||||||
|
|
||||||
# Substitute URL (excluding sensitive variables)
|
|
||||||
new_url, url_undefined = VariableSubstitution.substitute_excluding_variables(
|
|
||||||
request.url, environment.variables, sensitive_variables
|
|
||||||
)
|
|
||||||
all_undefined.update(url_undefined)
|
|
||||||
|
|
||||||
# Substitute headers (both keys and values, excluding sensitive variables)
|
|
||||||
new_headers = {}
|
|
||||||
for key, value in request.headers.items():
|
|
||||||
new_key, key_undefined = VariableSubstitution.substitute_excluding_variables(
|
|
||||||
key, environment.variables, sensitive_variables
|
|
||||||
)
|
|
||||||
new_value, value_undefined = VariableSubstitution.substitute_excluding_variables(
|
|
||||||
value, environment.variables, sensitive_variables
|
|
||||||
)
|
|
||||||
all_undefined.update(key_undefined)
|
|
||||||
all_undefined.update(value_undefined)
|
|
||||||
new_headers[new_key] = new_value
|
|
||||||
|
|
||||||
# Substitute body (excluding sensitive variables)
|
|
||||||
new_body, body_undefined = VariableSubstitution.substitute_excluding_variables(
|
|
||||||
request.body, environment.variables, sensitive_variables
|
|
||||||
)
|
|
||||||
all_undefined.update(body_undefined)
|
|
||||||
|
|
||||||
# Create new HttpRequest with redacted values
|
|
||||||
redacted_request = HttpRequest(
|
|
||||||
method=request.method,
|
|
||||||
url=new_url,
|
|
||||||
headers=new_headers,
|
|
||||||
body=new_body,
|
|
||||||
syntax=request.syntax
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if any sensitive variables were actually used in the request
|
|
||||||
all_vars_in_request = set()
|
|
||||||
all_vars_in_request.update(VariableSubstitution.find_variables(request.url))
|
|
||||||
all_vars_in_request.update(VariableSubstitution.find_variables(request.body))
|
|
||||||
for key, value in request.headers.items():
|
|
||||||
all_vars_in_request.update(VariableSubstitution.find_variables(key))
|
|
||||||
all_vars_in_request.update(VariableSubstitution.find_variables(value))
|
|
||||||
|
|
||||||
# Determine if any sensitive variables were actually present
|
|
||||||
has_sensitive_vars = bool(
|
|
||||||
set(sensitive_variables) & all_vars_in_request
|
|
||||||
)
|
|
||||||
|
|
||||||
return redacted_request, all_undefined, has_sensitive_vars
|
|
||||||
@ -7,7 +7,6 @@
|
|||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
@ -22,20 +21,5 @@ from .header_row import HeaderRow
|
|||||||
from .history_item import HistoryItem
|
from .history_item import HistoryItem
|
||||||
from .project_item import ProjectItem
|
from .project_item import ProjectItem
|
||||||
from .request_item import RequestItem
|
from .request_item import RequestItem
|
||||||
from .variable_row import VariableRow
|
|
||||||
from .environment_row import EnvironmentRow
|
|
||||||
from .environment_column_header import EnvironmentColumnHeader
|
|
||||||
from .environment_header_row import EnvironmentHeaderRow
|
|
||||||
from .variable_data_row import VariableDataRow
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ['HeaderRow', 'HistoryItem', 'ProjectItem', 'RequestItem']
|
||||||
'HeaderRow',
|
|
||||||
'HistoryItem',
|
|
||||||
'ProjectItem',
|
|
||||||
'RequestItem',
|
|
||||||
'VariableRow',
|
|
||||||
'EnvironmentRow',
|
|
||||||
'EnvironmentColumnHeader',
|
|
||||||
'EnvironmentHeaderRow',
|
|
||||||
'VariableDataRow',
|
|
||||||
]
|
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<interface>
|
|
||||||
<requires lib="gtk" version="4.0"/>
|
|
||||||
<template class="EnvironmentColumnHeader" parent="GtkBox">
|
|
||||||
<property name="orientation">vertical</property>
|
|
||||||
<property name="spacing">4</property>
|
|
||||||
<property name="width-request">200</property>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkLabel" id="name_label">
|
|
||||||
<property name="xalign">0.5</property>
|
|
||||||
<property name="ellipsize">end</property>
|
|
||||||
<property name="max-width-chars">20</property>
|
|
||||||
<style>
|
|
||||||
<class name="heading"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<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>
|
|
||||||
<property name="tooltip-text">Edit environment</property>
|
|
||||||
<signal name="clicked" handler="on_edit_clicked"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
<class name="circular"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkButton" id="delete_button">
|
|
||||||
<property name="icon-name">edit-delete-symbolic</property>
|
|
||||||
<property name="tooltip-text">Delete environment</property>
|
|
||||||
<signal name="clicked" handler="on_delete_clicked"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
<class name="circular"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</template>
|
|
||||||
</interface>
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<interface>
|
|
||||||
<requires lib="gtk" version="4.0"/>
|
|
||||||
<template class="EnvironmentHeaderRow" parent="GtkBox">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">12</property>
|
|
||||||
<property name="margin-start">12</property>
|
|
||||||
<property name="margin-end">12</property>
|
|
||||||
<property name="margin-top">12</property>
|
|
||||||
<property name="margin-bottom">6</property>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox" id="variable_cell">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">6</property>
|
|
||||||
<property name="width-request">150</property>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkLabel" id="variable_label">
|
|
||||||
<property name="label">Variable</property>
|
|
||||||
<property name="xalign">0</property>
|
|
||||||
<property name="hexpand">True</property>
|
|
||||||
<style>
|
|
||||||
<class name="heading"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox" id="columns_box">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">6</property>
|
|
||||||
<property name="hexpand">True</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</template>
|
|
||||||
</interface>
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<interface>
|
|
||||||
<requires lib="gtk" version="4.0"/>
|
|
||||||
<template class="EnvironmentRow" parent="GtkBox">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">12</property>
|
|
||||||
<property name="margin-start">12</property>
|
|
||||||
<property name="margin-end">12</property>
|
|
||||||
<property name="margin-top">6</property>
|
|
||||||
<property name="margin-bottom">6</property>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkLabel" id="name_label">
|
|
||||||
<property name="xalign">0</property>
|
|
||||||
<property name="width-chars">15</property>
|
|
||||||
<style>
|
|
||||||
<class name="heading"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox" id="values_box">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">6</property>
|
|
||||||
<property name="hexpand">True</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkButton" id="edit_button">
|
|
||||||
<property name="icon-name">document-edit-symbolic</property>
|
|
||||||
<property name="tooltip-text">Edit environment</property>
|
|
||||||
<signal name="clicked" handler="on_edit_clicked"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkButton" id="delete_button">
|
|
||||||
<property name="icon-name">edit-delete-symbolic</property>
|
|
||||||
<property name="tooltip-text">Delete environment</property>
|
|
||||||
<signal name="clicked" handler="on_delete_clicked"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</template>
|
|
||||||
</interface>
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
# environment_column_header.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 Gtk, GObject
|
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/environment-column-header.ui')
|
|
||||||
class EnvironmentColumnHeader(Gtk.Box):
|
|
||||||
"""Widget for displaying an environment column header with edit/delete buttons."""
|
|
||||||
|
|
||||||
__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):
|
|
||||||
super().__init__()
|
|
||||||
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."""
|
|
||||||
self.emit('edit-requested')
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_delete_clicked(self, button):
|
|
||||||
"""Handle delete button click."""
|
|
||||||
self.emit('delete-requested')
|
|
||||||
|
|
||||||
def update_name(self, name):
|
|
||||||
"""Update the environment name display."""
|
|
||||||
self.name_label.set_text(name)
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
# environment_header_row.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 Gtk, GObject
|
|
||||||
from .environment_column_header import EnvironmentColumnHeader
|
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/environment-header-row.ui')
|
|
||||||
class EnvironmentHeaderRow(Gtk.Box):
|
|
||||||
"""Widget for the header row containing all environment column headers."""
|
|
||||||
|
|
||||||
__gtype_name__ = 'EnvironmentHeaderRow'
|
|
||||||
|
|
||||||
variable_cell = Gtk.Template.Child()
|
|
||||||
variable_label = Gtk.Template.Child()
|
|
||||||
columns_box = Gtk.Template.Child()
|
|
||||||
|
|
||||||
__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):
|
|
||||||
super().__init__()
|
|
||||||
self.environments = environments
|
|
||||||
self.size_group = size_group
|
|
||||||
self.column_headers = {}
|
|
||||||
|
|
||||||
# Add variable cell to size group for alignment
|
|
||||||
if size_group:
|
|
||||||
size_group.add_widget(self.variable_cell)
|
|
||||||
|
|
||||||
self._populate()
|
|
||||||
|
|
||||||
def _populate(self):
|
|
||||||
"""Populate with environment column headers."""
|
|
||||||
# Clear existing
|
|
||||||
while child := self.columns_box.get_first_child():
|
|
||||||
self.columns_box.remove(child)
|
|
||||||
|
|
||||||
self.column_headers = {}
|
|
||||||
|
|
||||||
# Create column header for each environment
|
|
||||||
for env in self.environments:
|
|
||||||
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:
|
|
||||||
self.size_group.add_widget(header)
|
|
||||||
|
|
||||||
self.columns_box.append(header)
|
|
||||||
self.column_headers[env.id] = header
|
|
||||||
|
|
||||||
def _on_edit_requested(self, widget, environment):
|
|
||||||
"""Handle edit request from column header."""
|
|
||||||
self.emit('environment-edit-requested', environment)
|
|
||||||
|
|
||||||
def _on_delete_requested(self, widget, environment):
|
|
||||||
"""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
|
|
||||||
self._populate()
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
# environment_row.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 Gtk, GObject
|
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/environment-row.ui')
|
|
||||||
class EnvironmentRow(Gtk.Box):
|
|
||||||
"""Widget for displaying and editing an environment with its variables."""
|
|
||||||
|
|
||||||
__gtype_name__ = 'EnvironmentRow'
|
|
||||||
|
|
||||||
name_label = Gtk.Template.Child()
|
|
||||||
values_box = 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, ()),
|
|
||||||
'value-changed': (GObject.SIGNAL_RUN_FIRST, None, (str, str)) # variable_name, value
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, environment, variable_names):
|
|
||||||
super().__init__()
|
|
||||||
self.environment = environment
|
|
||||||
self.variable_names = variable_names
|
|
||||||
self.value_entries = {}
|
|
||||||
self._populate()
|
|
||||||
|
|
||||||
def _populate(self):
|
|
||||||
"""Populate with environment data."""
|
|
||||||
self.name_label.set_text(self.environment.name)
|
|
||||||
|
|
||||||
# Clear values box
|
|
||||||
while child := self.values_box.get_first_child():
|
|
||||||
self.values_box.remove(child)
|
|
||||||
|
|
||||||
# Create entry for each variable
|
|
||||||
for var_name in self.variable_names:
|
|
||||||
entry = Gtk.Entry()
|
|
||||||
entry.set_placeholder_text(var_name)
|
|
||||||
entry.set_text(self.environment.variables.get(var_name, ""))
|
|
||||||
entry.set_hexpand(True)
|
|
||||||
entry.connect('changed', self._on_value_changed, var_name)
|
|
||||||
self.values_box.append(entry)
|
|
||||||
self.value_entries[var_name] = entry
|
|
||||||
|
|
||||||
def _on_value_changed(self, entry, var_name):
|
|
||||||
"""Handle value entry changes."""
|
|
||||||
self.emit('value-changed', var_name, entry.get_text())
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_edit_clicked(self, button):
|
|
||||||
"""Handle edit button click."""
|
|
||||||
self.emit('edit-requested')
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_delete_clicked(self, button):
|
|
||||||
"""Handle delete button click."""
|
|
||||||
self.emit('delete-requested')
|
|
||||||
|
|
||||||
def refresh(self, variable_names):
|
|
||||||
"""Refresh widget with updated variable names."""
|
|
||||||
self.variable_names = variable_names
|
|
||||||
self._populate()
|
|
||||||
|
|
||||||
def get_values(self):
|
|
||||||
"""Get all variable values."""
|
|
||||||
return {var_name: entry.get_text() for var_name, entry in self.value_entries.items()}
|
|
||||||
@ -7,7 +7,6 @@
|
|||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
@ -21,7 +20,7 @@
|
|||||||
from gi.repository import Gtk, GObject
|
from gi.repository import Gtk, GObject
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/header-row.ui')
|
@Gtk.Template(resource_path='/cz/vesp/roster/widgets/header-row.ui')
|
||||||
class HeaderRow(Gtk.Box):
|
class HeaderRow(Gtk.Box):
|
||||||
"""Widget for editing a single HTTP header key-value pair."""
|
"""Widget for editing a single HTTP header key-value pair."""
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<!-- Summary (always visible) -->
|
<!-- Summary (always visible) -->
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox" id="summary_box">
|
<object class="GtkBox">
|
||||||
<property name="orientation">horizontal</property>
|
<property name="orientation">horizontal</property>
|
||||||
<property name="spacing">12</property>
|
<property name="spacing">12</property>
|
||||||
<property name="margin-start">12</property>
|
<property name="margin-start">12</property>
|
||||||
@ -61,40 +61,6 @@
|
|||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
<!-- Redaction Indicator (shown when sensitive variables were redacted) -->
|
|
||||||
<child>
|
|
||||||
<object class="GtkImage" id="redaction_indicator">
|
|
||||||
<property name="icon-name">dialog-information-symbolic</property>
|
|
||||||
<property name="tooltip-text">Sensitive variables were redacted from this history entry</property>
|
|
||||||
<property name="valign">center</property>
|
|
||||||
<property name="visible">False</property>
|
|
||||||
<style>
|
|
||||||
<class name="warning"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<!-- Delete Button -->
|
|
||||||
<child>
|
|
||||||
<object class="GtkButton" id="delete_button">
|
|
||||||
<property name="icon-name">user-trash-symbolic</property>
|
|
||||||
<property name="tooltip-text">Delete this history item</property>
|
|
||||||
<property name="valign">center</property>
|
|
||||||
<signal name="clicked" handler="on_delete_clicked" swapped="no"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
<class name="circular"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<!-- Click gesture for toggling expansion (only on summary) -->
|
|
||||||
<child>
|
|
||||||
<object class="GtkGestureClick" id="click_gesture">
|
|
||||||
<signal name="released" handler="on_clicked" swapped="no"/>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
@ -113,35 +79,16 @@
|
|||||||
<property name="margin-bottom">12</property>
|
<property name="margin-bottom">12</property>
|
||||||
|
|
||||||
<!-- Request Section -->
|
<!-- Request Section -->
|
||||||
<child>
|
|
||||||
<object class="GtkBox">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">6</property>
|
|
||||||
<property name="margin-top">6</property>
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkLabel">
|
<object class="GtkLabel">
|
||||||
<property name="label">Request:</property>
|
<property name="label">Request:</property>
|
||||||
<property name="xalign">0</property>
|
<property name="xalign">0</property>
|
||||||
<property name="hexpand">True</property>
|
<property name="margin-top">6</property>
|
||||||
<style>
|
<style>
|
||||||
<class name="heading"/>
|
<class name="heading"/>
|
||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
|
||||||
<object class="GtkButton" id="copy_request_button">
|
|
||||||
<property name="icon-name">edit-copy-symbolic</property>
|
|
||||||
<property name="tooltip-text">Copy request to clipboard</property>
|
|
||||||
<property name="valign">center</property>
|
|
||||||
<signal name="clicked" handler="on_copy_request_clicked" swapped="no"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
<class name="circular"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkLabel" id="request_headers_label">
|
<object class="GtkLabel" id="request_headers_label">
|
||||||
@ -157,95 +104,30 @@
|
|||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
<!-- Request Body (scrollable, initially collapsed) -->
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkScrolledWindow" id="request_body_scroll">
|
<object class="GtkLabel" id="request_body_label">
|
||||||
<property name="margin-top">6</property>
|
<property name="label">Body...</property>
|
||||||
<property name="min-content-height">60</property>
|
<property name="xalign">0</property>
|
||||||
<property name="max-content-height">60</property>
|
<property name="selectable">True</property>
|
||||||
<property name="propagate-natural-height">True</property>
|
<property name="wrap">True</property>
|
||||||
<child>
|
|
||||||
<object class="GtkTextView" id="request_body_text">
|
|
||||||
<property name="editable">False</property>
|
|
||||||
<property name="monospace">True</property>
|
|
||||||
<property name="left-margin">6</property>
|
|
||||||
<property name="right-margin">6</property>
|
|
||||||
<property name="top-margin">6</property>
|
|
||||||
<property name="bottom-margin">6</property>
|
|
||||||
<property name="wrap-mode">word-char</property>
|
<property name="wrap-mode">word-char</property>
|
||||||
<style>
|
<style>
|
||||||
<class name="view"/>
|
<class name="monospace"/>
|
||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<!-- Request Expander Separator -->
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox" id="request_expander">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">6</property>
|
|
||||||
<property name="halign">fill</property>
|
|
||||||
<property name="margin-top">3</property>
|
|
||||||
<property name="margin-bottom">3</property>
|
|
||||||
<child>
|
|
||||||
<object class="GtkSeparator">
|
|
||||||
<property name="hexpand">True</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkLabel" id="request_expander_label">
|
|
||||||
<property name="label">Show full request</property>
|
|
||||||
<style>
|
|
||||||
<class name="dim-label"/>
|
|
||||||
<class name="caption"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkSeparator">
|
|
||||||
<property name="hexpand">True</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkGestureClick">
|
|
||||||
<signal name="released" handler="on_request_expander_clicked" swapped="no"/>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<!-- Response Section -->
|
<!-- Response Section -->
|
||||||
<child>
|
|
||||||
<object class="GtkBox">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">6</property>
|
|
||||||
<property name="margin-top">6</property>
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkLabel">
|
<object class="GtkLabel">
|
||||||
<property name="label">Response:</property>
|
<property name="label">Response:</property>
|
||||||
<property name="xalign">0</property>
|
<property name="xalign">0</property>
|
||||||
<property name="hexpand">True</property>
|
<property name="margin-top">6</property>
|
||||||
<style>
|
<style>
|
||||||
<class name="heading"/>
|
<class name="heading"/>
|
||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
|
||||||
<object class="GtkButton" id="copy_response_button">
|
|
||||||
<property name="icon-name">edit-copy-symbolic</property>
|
|
||||||
<property name="tooltip-text">Copy response to clipboard</property>
|
|
||||||
<property name="valign">center</property>
|
|
||||||
<signal name="clicked" handler="on_copy_response_clicked" swapped="no"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
<class name="circular"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkLabel" id="response_headers_label">
|
<object class="GtkLabel" id="response_headers_label">
|
||||||
@ -261,64 +143,18 @@
|
|||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
<!-- Response Body (scrollable, initially collapsed) -->
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkScrolledWindow" id="response_body_scroll">
|
<object class="GtkLabel" id="response_body_label">
|
||||||
<property name="margin-top">6</property>
|
<property name="label">Body...</property>
|
||||||
<property name="min-content-height">60</property>
|
<property name="xalign">0</property>
|
||||||
<property name="max-content-height">60</property>
|
<property name="selectable">True</property>
|
||||||
<property name="propagate-natural-height">True</property>
|
<property name="wrap">True</property>
|
||||||
<child>
|
|
||||||
<object class="GtkTextView" id="response_body_text">
|
|
||||||
<property name="editable">False</property>
|
|
||||||
<property name="monospace">True</property>
|
|
||||||
<property name="left-margin">6</property>
|
|
||||||
<property name="right-margin">6</property>
|
|
||||||
<property name="top-margin">6</property>
|
|
||||||
<property name="bottom-margin">6</property>
|
|
||||||
<property name="wrap-mode">word-char</property>
|
<property name="wrap-mode">word-char</property>
|
||||||
<style>
|
<style>
|
||||||
<class name="view"/>
|
<class name="monospace"/>
|
||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<!-- Response Expander Separator -->
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox" id="response_expander">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">6</property>
|
|
||||||
<property name="halign">fill</property>
|
|
||||||
<property name="margin-top">3</property>
|
|
||||||
<property name="margin-bottom">3</property>
|
|
||||||
<child>
|
|
||||||
<object class="GtkSeparator">
|
|
||||||
<property name="hexpand">True</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkLabel" id="response_expander_label">
|
|
||||||
<property name="label">Show full response</property>
|
|
||||||
<style>
|
|
||||||
<class name="dim-label"/>
|
|
||||||
<class name="caption"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkSeparator">
|
|
||||||
<property name="hexpand">True</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkGestureClick">
|
|
||||||
<signal name="released" handler="on_response_expander_clicked" swapped="no"/>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<!-- Load Button -->
|
<!-- Load Button -->
|
||||||
<child>
|
<child>
|
||||||
@ -336,5 +172,12 @@
|
|||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
|
<!-- Click gesture for toggling expansion -->
|
||||||
|
<child>
|
||||||
|
<object class="GtkGestureClick" id="click_gesture">
|
||||||
|
<signal name="released" handler="on_clicked" swapped="no"/>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
</template>
|
</template>
|
||||||
</interface>
|
</interface>
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
@ -18,11 +17,11 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from gi.repository import Gtk, GObject, Gdk
|
from gi.repository import Gtk, GObject
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/history-item.ui')
|
@Gtk.Template(resource_path='/cz/vesp/roster/widgets/history-item.ui')
|
||||||
class HistoryItem(Gtk.Box):
|
class HistoryItem(Gtk.Box):
|
||||||
"""Widget for displaying a history entry."""
|
"""Widget for displaying a history entry."""
|
||||||
|
|
||||||
@ -34,33 +33,20 @@ class HistoryItem(Gtk.Box):
|
|||||||
url_label = Gtk.Template.Child()
|
url_label = Gtk.Template.Child()
|
||||||
timestamp_label = Gtk.Template.Child()
|
timestamp_label = Gtk.Template.Child()
|
||||||
status_label = Gtk.Template.Child()
|
status_label = Gtk.Template.Child()
|
||||||
redaction_indicator = Gtk.Template.Child()
|
|
||||||
delete_button = Gtk.Template.Child()
|
|
||||||
request_headers_label = Gtk.Template.Child()
|
request_headers_label = Gtk.Template.Child()
|
||||||
request_body_scroll = Gtk.Template.Child()
|
request_body_label = Gtk.Template.Child()
|
||||||
request_body_text = Gtk.Template.Child()
|
|
||||||
request_expander = Gtk.Template.Child()
|
|
||||||
request_expander_label = Gtk.Template.Child()
|
|
||||||
copy_request_button = Gtk.Template.Child()
|
|
||||||
response_headers_label = Gtk.Template.Child()
|
response_headers_label = Gtk.Template.Child()
|
||||||
response_body_scroll = Gtk.Template.Child()
|
response_body_label = Gtk.Template.Child()
|
||||||
response_body_text = Gtk.Template.Child()
|
|
||||||
response_expander = Gtk.Template.Child()
|
|
||||||
response_expander_label = Gtk.Template.Child()
|
|
||||||
copy_response_button = Gtk.Template.Child()
|
|
||||||
load_button = Gtk.Template.Child()
|
load_button = Gtk.Template.Child()
|
||||||
|
|
||||||
__gsignals__ = {
|
__gsignals__ = {
|
||||||
'load-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
'load-requested': (GObject.SIGNAL_RUN_FIRST, None, ())
|
||||||
'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, entry):
|
def __init__(self, entry):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
self.expanded = False
|
self.expanded = False
|
||||||
self.request_expanded = False
|
|
||||||
self.response_expanded = False
|
|
||||||
self._populate_ui()
|
self._populate_ui()
|
||||||
|
|
||||||
def _populate_ui(self):
|
def _populate_ui(self):
|
||||||
@ -78,10 +64,6 @@ class HistoryItem(Gtk.Box):
|
|||||||
|
|
||||||
self.timestamp_label.set_text(timestamp_str)
|
self.timestamp_label.set_text(timestamp_str)
|
||||||
|
|
||||||
# Show redaction indicator if sensitive variables were redacted
|
|
||||||
if self.entry.has_redacted_variables:
|
|
||||||
self.redaction_indicator.set_visible(True)
|
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
if self.entry.response:
|
if self.entry.response:
|
||||||
status_text = f"{self.entry.response.status_code} {self.entry.response.status_text}"
|
status_text = f"{self.entry.response.status_code} {self.entry.response.status_text}"
|
||||||
@ -97,46 +79,23 @@ class HistoryItem(Gtk.Box):
|
|||||||
|
|
||||||
# Details - Request body
|
# Details - Request body
|
||||||
body_str = self.entry.request.body if self.entry.request.body else "(empty)"
|
body_str = self.entry.request.body if self.entry.request.body else "(empty)"
|
||||||
self.request_body_text.get_buffer().set_text(body_str)
|
# Truncate long bodies
|
||||||
|
if len(body_str) > 200:
|
||||||
# Count lines to determine if we need the expander
|
body_str = body_str[:200] + "..."
|
||||||
num_lines = body_str.count('\n') + 1
|
self.request_body_label.set_text(body_str)
|
||||||
# Show expander if content is longer than ~3 lines (60px)
|
|
||||||
needs_expander = num_lines > 3 or len(body_str) > 150
|
|
||||||
self.request_expander.set_visible(needs_expander)
|
|
||||||
|
|
||||||
# Set cursor to pointer for clickability hint
|
|
||||||
if needs_expander:
|
|
||||||
self.request_expander.set_cursor_from_name("pointer")
|
|
||||||
|
|
||||||
# Details - Response
|
# Details - Response
|
||||||
if self.entry.response:
|
if self.entry.response:
|
||||||
self.response_headers_label.set_text(self.entry.response.headers)
|
self.response_headers_label.set_text(self.entry.response.headers)
|
||||||
|
|
||||||
response_body = self.entry.response.body if self.entry.response.body else "(empty)"
|
response_body = self.entry.response.body if self.entry.response.body else "(empty)"
|
||||||
self.response_body_text.get_buffer().set_text(response_body)
|
# Truncate long bodies
|
||||||
|
if len(response_body) > 200:
|
||||||
# Count lines to determine if we need the expander
|
response_body = response_body[:200] + "..."
|
||||||
num_lines = response_body.count('\n') + 1
|
self.response_body_label.set_text(response_body)
|
||||||
needs_expander = num_lines > 3 or len(response_body) > 150
|
|
||||||
self.response_expander.set_visible(needs_expander)
|
|
||||||
|
|
||||||
# Set cursor to pointer for clickability hint
|
|
||||||
if needs_expander:
|
|
||||||
self.response_expander.set_cursor_from_name("pointer")
|
|
||||||
elif self.entry.error:
|
elif self.entry.error:
|
||||||
self.response_headers_label.set_text("(error)")
|
self.response_headers_label.set_text("(error)")
|
||||||
error_text = self.entry.error
|
self.response_body_label.set_text(self.entry.error)
|
||||||
self.response_body_text.get_buffer().set_text(error_text)
|
|
||||||
|
|
||||||
# Show expander for long errors
|
|
||||||
num_lines = error_text.count('\n') + 1
|
|
||||||
needs_expander = num_lines > 3 or len(error_text) > 150
|
|
||||||
self.response_expander.set_visible(needs_expander)
|
|
||||||
|
|
||||||
# Set cursor to pointer for clickability hint
|
|
||||||
if needs_expander:
|
|
||||||
self.response_expander.set_cursor_from_name("pointer")
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
@Gtk.Template.Callback()
|
||||||
def on_clicked(self, gesture, n_press, x, y):
|
def on_clicked(self, gesture, n_press, x, y):
|
||||||
@ -148,93 +107,6 @@ class HistoryItem(Gtk.Box):
|
|||||||
"""Emit load signal when load button clicked."""
|
"""Emit load signal when load button clicked."""
|
||||||
self.emit('load-requested')
|
self.emit('load-requested')
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_delete_clicked(self, button):
|
|
||||||
"""Emit delete signal when delete button clicked."""
|
|
||||||
self.emit('delete-requested')
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_request_expander_clicked(self, gesture, n_press, x, y):
|
|
||||||
"""Toggle request body expansion."""
|
|
||||||
self.request_expanded = not self.request_expanded
|
|
||||||
|
|
||||||
if self.request_expanded:
|
|
||||||
# Expand to show full scrollable content
|
|
||||||
# Set max first, then min to avoid assertion errors
|
|
||||||
self.request_body_scroll.set_max_content_height(300)
|
|
||||||
self.request_body_scroll.set_min_content_height(300)
|
|
||||||
self.request_expander_label.set_text("Collapse request")
|
|
||||||
else:
|
|
||||||
# Collapse to show only few lines
|
|
||||||
# Set min first, then max to avoid assertion errors
|
|
||||||
self.request_body_scroll.set_min_content_height(60)
|
|
||||||
self.request_body_scroll.set_max_content_height(60)
|
|
||||||
self.request_expander_label.set_text("Show full request")
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_response_expander_clicked(self, gesture, n_press, x, y):
|
|
||||||
"""Toggle response body expansion."""
|
|
||||||
self.response_expanded = not self.response_expanded
|
|
||||||
|
|
||||||
if self.response_expanded:
|
|
||||||
# Expand to show full scrollable content
|
|
||||||
# Set max first, then min to avoid assertion errors
|
|
||||||
self.response_body_scroll.set_max_content_height(300)
|
|
||||||
self.response_body_scroll.set_min_content_height(300)
|
|
||||||
self.response_expander_label.set_text("Collapse response")
|
|
||||||
else:
|
|
||||||
# Collapse to show only few lines
|
|
||||||
# Set min first, then max to avoid assertion errors
|
|
||||||
self.response_body_scroll.set_min_content_height(60)
|
|
||||||
self.response_body_scroll.set_max_content_height(60)
|
|
||||||
self.response_expander_label.set_text("Show full response")
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_copy_request_clicked(self, button):
|
|
||||||
"""Copy full request to clipboard."""
|
|
||||||
# Build full request text
|
|
||||||
request_text = f"{self.entry.request.method} {self.entry.request.url}\n\n"
|
|
||||||
|
|
||||||
# Add headers
|
|
||||||
if self.entry.request.headers:
|
|
||||||
request_text += "Headers:\n"
|
|
||||||
for key, value in self.entry.request.headers.items():
|
|
||||||
request_text += f"{key}: {value}\n"
|
|
||||||
request_text += "\n"
|
|
||||||
|
|
||||||
# Add body
|
|
||||||
if self.entry.request.body:
|
|
||||||
request_text += "Body:\n"
|
|
||||||
request_text += self.entry.request.body
|
|
||||||
|
|
||||||
# Copy to clipboard
|
|
||||||
clipboard = Gdk.Display.get_default().get_clipboard()
|
|
||||||
clipboard.set(request_text)
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_copy_response_clicked(self, button):
|
|
||||||
"""Copy full response to clipboard."""
|
|
||||||
if self.entry.response:
|
|
||||||
# Build full response text
|
|
||||||
response_text = f"{self.entry.response.status_code} {self.entry.response.status_text}\n\n"
|
|
||||||
|
|
||||||
# Add headers
|
|
||||||
if self.entry.response.headers:
|
|
||||||
response_text += self.entry.response.headers
|
|
||||||
response_text += "\n\n"
|
|
||||||
|
|
||||||
# Add body
|
|
||||||
if self.entry.response.body:
|
|
||||||
response_text += self.entry.response.body
|
|
||||||
elif self.entry.error:
|
|
||||||
response_text = f"Error:\n{self.entry.error}"
|
|
||||||
else:
|
|
||||||
response_text = "(no response)"
|
|
||||||
|
|
||||||
# Copy to clipboard
|
|
||||||
clipboard = Gdk.Display.get_default().get_clipboard()
|
|
||||||
clipboard.set(response_text)
|
|
||||||
|
|
||||||
def toggle_expanded(self):
|
def toggle_expanded(self):
|
||||||
"""Toggle between collapsed and expanded view."""
|
"""Toggle between collapsed and expanded view."""
|
||||||
self.expanded = not self.expanded
|
self.expanded = not self.expanded
|
||||||
|
|||||||
@ -5,20 +5,6 @@
|
|||||||
|
|
||||||
<menu id="project_menu">
|
<menu id="project_menu">
|
||||||
<section>
|
<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>
|
|
||||||
<attribute name="action">project.manage-environments</attribute>
|
|
||||||
</item>
|
|
||||||
<item>
|
<item>
|
||||||
<attribute name="label">Add Request</attribute>
|
<attribute name="label">Add Request</attribute>
|
||||||
<attribute name="action">project.add-request</attribute>
|
<attribute name="action">project.add-request</attribute>
|
||||||
@ -39,7 +25,7 @@
|
|||||||
<property name="spacing">0</property>
|
<property name="spacing">0</property>
|
||||||
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox" id="header_box">
|
<object class="GtkBox">
|
||||||
<property name="orientation">horizontal</property>
|
<property name="orientation">horizontal</property>
|
||||||
<property name="spacing">6</property>
|
<property name="spacing">6</property>
|
||||||
<property name="margin-start">6</property>
|
<property name="margin-start">6</property>
|
||||||
@ -82,8 +68,7 @@
|
|||||||
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkListBox" id="requests_listbox">
|
<object class="GtkListBox" id="requests_listbox">
|
||||||
<property name="margin-start">6</property>
|
<property name="margin-start">24</property>
|
||||||
<property name="margin-end">6</property>
|
|
||||||
<style>
|
<style>
|
||||||
<class name="navigation-sidebar"/>
|
<class name="navigation-sidebar"/>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
@ -22,13 +21,12 @@ from gi.repository import Gtk, GObject, Gio
|
|||||||
from .request_item import RequestItem
|
from .request_item import RequestItem
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/project-item.ui')
|
@Gtk.Template(resource_path='/cz/vesp/roster/widgets/project-item.ui')
|
||||||
class ProjectItem(Gtk.Box):
|
class ProjectItem(Gtk.Box):
|
||||||
"""Widget for displaying a project with its requests."""
|
"""Widget for displaying a project with its requests."""
|
||||||
|
|
||||||
__gtype_name__ = 'ProjectItem'
|
__gtype_name__ = 'ProjectItem'
|
||||||
|
|
||||||
header_box = Gtk.Template.Child()
|
|
||||||
project_icon = Gtk.Template.Child()
|
project_icon = Gtk.Template.Child()
|
||||||
name_label = Gtk.Template.Child()
|
name_label = Gtk.Template.Child()
|
||||||
requests_revealer = Gtk.Template.Child()
|
requests_revealer = Gtk.Template.Child()
|
||||||
@ -40,18 +38,11 @@ class ProjectItem(Gtk.Box):
|
|||||||
'add-request-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
'add-request-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||||
'request-load-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
'request-load-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||||
'request-delete-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__()
|
super().__init__()
|
||||||
self.project = project
|
self.project = project
|
||||||
self.other_projects = other_projects or []
|
|
||||||
self.expanded = False
|
self.expanded = False
|
||||||
self._setup_actions()
|
self._setup_actions()
|
||||||
self._populate()
|
self._populate()
|
||||||
@ -59,24 +50,12 @@ class ProjectItem(Gtk.Box):
|
|||||||
# Click gesture for header
|
# Click gesture for header
|
||||||
gesture = Gtk.GestureClick.new()
|
gesture = Gtk.GestureClick.new()
|
||||||
gesture.connect('released', self._on_header_clicked)
|
gesture.connect('released', self._on_header_clicked)
|
||||||
self.header_box.add_controller(gesture)
|
self.add_controller(gesture)
|
||||||
|
|
||||||
def _setup_actions(self):
|
def _setup_actions(self):
|
||||||
"""Setup action group for menu."""
|
"""Setup action group for menu."""
|
||||||
actions = Gio.SimpleActionGroup.new()
|
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)
|
|
||||||
|
|
||||||
action = Gio.SimpleAction.new("add-request", None)
|
action = Gio.SimpleAction.new("add-request", None)
|
||||||
action.connect("activate", lambda *_: self.emit('add-request-requested'))
|
action.connect("activate", lambda *_: self.emit('add-request-requested'))
|
||||||
actions.add_action(action)
|
actions.add_action(action)
|
||||||
@ -96,25 +75,16 @@ class ProjectItem(Gtk.Box):
|
|||||||
self.name_label.set_text(self.project.name)
|
self.name_label.set_text(self.project.name)
|
||||||
self.project_icon.set_from_icon_name(self.project.icon)
|
self.project_icon.set_from_icon_name(self.project.icon)
|
||||||
|
|
||||||
|
# Clear and add requests
|
||||||
while child := self.requests_listbox.get_first_child():
|
while child := self.requests_listbox.get_first_child():
|
||||||
self.requests_listbox.remove(child)
|
self.requests_listbox.remove(child)
|
||||||
|
|
||||||
total = len(self.project.requests)
|
for saved_request in self.project.requests:
|
||||||
for i, saved_request in enumerate(self.project.requests):
|
|
||||||
item = RequestItem(saved_request)
|
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',
|
item.connect('load-requested',
|
||||||
lambda w, sr=saved_request: self.emit('request-load-requested', sr))
|
lambda w, sr=saved_request: self.emit('request-load-requested', sr))
|
||||||
item.connect('delete-requested',
|
item.connect('delete-requested',
|
||||||
lambda w, sr=saved_request: self.emit('request-delete-requested', sr))
|
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)
|
self.requests_listbox.append(item)
|
||||||
|
|
||||||
def _on_header_clicked(self, gesture, n_press, x, y):
|
def _on_header_clicked(self, gesture, n_press, x, y):
|
||||||
@ -122,14 +92,6 @@ class ProjectItem(Gtk.Box):
|
|||||||
self.expanded = not self.expanded
|
self.expanded = not self.expanded
|
||||||
self.requests_revealer.set_reveal_child(self.expanded)
|
self.requests_revealer.set_reveal_child(self.expanded)
|
||||||
|
|
||||||
def set_can_move_up(self, can: bool):
|
def refresh(self):
|
||||||
self._move_up_action.set_enabled(can)
|
"""Refresh from project data."""
|
||||||
|
|
||||||
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
|
|
||||||
self._populate()
|
self._populate()
|
||||||
|
|||||||
@ -11,9 +11,10 @@
|
|||||||
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkLabel" id="method_label">
|
<object class="GtkLabel" id="method_label">
|
||||||
<property name="xalign">0.5</property>
|
<property name="width-chars">6</property>
|
||||||
<style>
|
<style>
|
||||||
<class name="method-chip"/>
|
<class name="caption"/>
|
||||||
|
<class name="dim-label"/>
|
||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
@ -27,12 +28,12 @@
|
|||||||
</child>
|
</child>
|
||||||
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkMenuButton" id="menu_button">
|
<object class="GtkButton" id="delete_button">
|
||||||
<property name="icon-name">view-more-symbolic</property>
|
<property name="icon-name">edit-delete-symbolic</property>
|
||||||
<property name="tooltip-text">Options</property>
|
<property name="tooltip-text">Delete request</property>
|
||||||
|
<signal name="clicked" handler="on_delete_clicked"/>
|
||||||
<style>
|
<style>
|
||||||
<class name="flat"/>
|
<class name="flat"/>
|
||||||
<class name="request-menu-button"/>
|
|
||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
@ -18,18 +17,10 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from gi.repository import Gtk, GObject, Gio, GLib
|
from gi.repository import Gtk, GObject
|
||||||
|
|
||||||
_METHOD_CSS = {
|
|
||||||
'GET': 'accent',
|
|
||||||
'POST': 'success',
|
|
||||||
'PUT': 'warning',
|
|
||||||
'PATCH': 'warning',
|
|
||||||
'DELETE': 'error',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/request-item.ui')
|
@Gtk.Template(resource_path='/cz/vesp/roster/widgets/request-item.ui')
|
||||||
class RequestItem(Gtk.Box):
|
class RequestItem(Gtk.Box):
|
||||||
"""Widget for displaying a saved request."""
|
"""Widget for displaying a saved request."""
|
||||||
|
|
||||||
@ -37,115 +28,28 @@ class RequestItem(Gtk.Box):
|
|||||||
|
|
||||||
method_label = Gtk.Template.Child()
|
method_label = Gtk.Template.Child()
|
||||||
name_label = Gtk.Template.Child()
|
name_label = Gtk.Template.Child()
|
||||||
menu_button = Gtk.Template.Child()
|
|
||||||
|
|
||||||
__gsignals__ = {
|
__gsignals__ = {
|
||||||
'load-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
'load-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||||
'delete-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):
|
def __init__(self, saved_request):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.saved_request = saved_request
|
self.saved_request = saved_request
|
||||||
method = saved_request.request.method
|
self.method_label.set_text(saved_request.request.method)
|
||||||
self.method_label.set_text(method)
|
self.name_label.set_text(saved_request.name)
|
||||||
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._setup_menu()
|
# Add click gesture for loading
|
||||||
|
|
||||||
# Click gesture for loading
|
|
||||||
gesture = Gtk.GestureClick.new()
|
gesture = Gtk.GestureClick.new()
|
||||||
gesture.connect('released', self._on_clicked)
|
gesture.connect('released', self._on_clicked)
|
||||||
self.add_controller(gesture)
|
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):
|
def _on_clicked(self, gesture, n_press, x, y):
|
||||||
"""Load request on row click (but not if the menu button was clicked)."""
|
"""Handle click to load request."""
|
||||||
alloc = self.menu_button.get_allocation()
|
|
||||||
if alloc.x <= x <= alloc.x + alloc.width and alloc.y <= y <= alloc.y + alloc.height:
|
|
||||||
return
|
|
||||||
self.emit('load-requested')
|
self.emit('load-requested')
|
||||||
|
|
||||||
def _on_hover_enter(self, controller, x, y):
|
@Gtk.Template.Callback()
|
||||||
self.menu_button.set_opacity(1.0)
|
def on_delete_clicked(self, button):
|
||||||
|
"""Handle delete button click."""
|
||||||
def _on_hover_leave(self, controller):
|
self.emit('delete-requested')
|
||||||
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}")
|
|
||||||
|
|||||||
@ -1,91 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<interface>
|
|
||||||
<requires lib="gtk" version="4.0"/>
|
|
||||||
<template class="VariableDataRow" parent="GtkBox">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">12</property>
|
|
||||||
<property name="margin-start">12</property>
|
|
||||||
<property name="margin-end">12</property>
|
|
||||||
<property name="margin-top">6</property>
|
|
||||||
<property name="margin-bottom">6</property>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox" id="variable_cell">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">6</property>
|
|
||||||
<property name="width-request">150</property>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkEntry" id="name_entry">
|
|
||||||
<property name="placeholder-text">Variable name</property>
|
|
||||||
<property name="hexpand">True</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<!-- Inline edit buttons (shown only when editing) -->
|
|
||||||
<child>
|
|
||||||
<object class="GtkRevealer" id="edit_buttons_revealer">
|
|
||||||
<property name="transition-type">slide-left</property>
|
|
||||||
<property name="reveal-child">False</property>
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox">
|
|
||||||
<property name="spacing">3</property>
|
|
||||||
<child>
|
|
||||||
<object class="GtkButton" id="cancel_button">
|
|
||||||
<property name="icon-name">process-stop-symbolic</property>
|
|
||||||
<property name="tooltip-text">Cancel editing</property>
|
|
||||||
<signal name="clicked" handler="on_cancel_clicked"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkButton" id="save_button">
|
|
||||||
<property name="icon-name">object-select-symbolic</property>
|
|
||||||
<property name="tooltip-text">Save changes</property>
|
|
||||||
<signal name="clicked" handler="on_save_clicked"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
<class name="suggested-action"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkToggleButton" id="sensitive_toggle">
|
|
||||||
<property name="icon-name">changes-allow-symbolic</property>
|
|
||||||
<property name="tooltip-text">Mark as sensitive (store in keyring)</property>
|
|
||||||
<signal name="toggled" handler="on_sensitive_toggled"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkButton" id="delete_button">
|
|
||||||
<property name="icon-name">edit-delete-symbolic</property>
|
|
||||||
<property name="tooltip-text">Delete variable</property>
|
|
||||||
<signal name="clicked" handler="on_delete_clicked"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox" id="values_box">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">6</property>
|
|
||||||
<property name="hexpand">True</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</template>
|
|
||||||
</interface>
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<interface>
|
|
||||||
<requires lib="gtk" version="4.0"/>
|
|
||||||
<template class="VariableRow" parent="GtkBox">
|
|
||||||
<property name="orientation">horizontal</property>
|
|
||||||
<property name="spacing">6</property>
|
|
||||||
<property name="margin-start">6</property>
|
|
||||||
<property name="margin-end">6</property>
|
|
||||||
<property name="margin-top">3</property>
|
|
||||||
<property name="margin-bottom">3</property>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkEntry" id="name_entry">
|
|
||||||
<property name="placeholder-text">Variable name</property>
|
|
||||||
<property name="hexpand">True</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<child>
|
|
||||||
<object class="GtkButton" id="remove_button">
|
|
||||||
<property name="icon-name">edit-delete-symbolic</property>
|
|
||||||
<property name="tooltip-text">Remove variable</property>
|
|
||||||
<signal name="clicked" handler="on_remove_clicked" swapped="no"/>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</template>
|
|
||||||
</interface>
|
|
||||||
@ -1,290 +0,0 @@
|
|||||||
# variable_data_row.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 Gtk, GObject, GLib
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/variable-data-row.ui')
|
|
||||||
class VariableDataRow(Gtk.Box):
|
|
||||||
"""Widget for a data row with variable name and values for each environment."""
|
|
||||||
|
|
||||||
__gtype_name__ = 'VariableDataRow'
|
|
||||||
|
|
||||||
variable_cell = Gtk.Template.Child()
|
|
||||||
name_entry = Gtk.Template.Child()
|
|
||||||
edit_buttons_revealer = Gtk.Template.Child()
|
|
||||||
cancel_button = Gtk.Template.Child()
|
|
||||||
save_button = Gtk.Template.Child()
|
|
||||||
sensitive_toggle = Gtk.Template.Child()
|
|
||||||
delete_button = Gtk.Template.Child()
|
|
||||||
values_box = Gtk.Template.Child()
|
|
||||||
|
|
||||||
__gsignals__ = {
|
|
||||||
'variable-changed': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
|
||||||
'variable-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
|
||||||
'value-changed': (GObject.SIGNAL_RUN_FIRST, None, (str, str)), # env_id, value
|
|
||||||
'sensitivity-changed': (GObject.SIGNAL_RUN_FIRST, None, (bool,)), # is_sensitive
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, variable_name, environments, size_group, project, project_manager, update_timestamp=None):
|
|
||||||
super().__init__()
|
|
||||||
self.variable_name = variable_name
|
|
||||||
self.original_name = variable_name # Store original name for cancel
|
|
||||||
self.environments = environments
|
|
||||||
self.size_group = size_group
|
|
||||||
self.project = project
|
|
||||||
self.project_manager = project_manager
|
|
||||||
self.value_entries = {}
|
|
||||||
self.update_timeout_id = None
|
|
||||||
self._updating_toggle = False # Flag to prevent recursion
|
|
||||||
self._is_editing = False # Track editing state
|
|
||||||
|
|
||||||
# Set variable name
|
|
||||||
self.name_entry.set_text(variable_name)
|
|
||||||
|
|
||||||
# Connect focus events for inline editing
|
|
||||||
focus_controller = Gtk.EventControllerFocus()
|
|
||||||
focus_controller.connect('enter', self._on_name_entry_focus_in)
|
|
||||||
focus_controller.connect('leave', self._on_name_entry_focus_out)
|
|
||||||
self.name_entry.add_controller(focus_controller)
|
|
||||||
|
|
||||||
# Connect activate signal (Enter key)
|
|
||||||
self.name_entry.connect('activate', self._on_name_entry_activate)
|
|
||||||
|
|
||||||
# Set initial sensitivity state
|
|
||||||
is_sensitive = variable_name in self.project.sensitive_variables
|
|
||||||
self._updating_toggle = True
|
|
||||||
self.sensitive_toggle.set_active(is_sensitive)
|
|
||||||
self._update_sensitive_icon(is_sensitive)
|
|
||||||
self._updating_toggle = False
|
|
||||||
|
|
||||||
# Add variable cell to size group for alignment
|
|
||||||
if size_group:
|
|
||||||
size_group.add_widget(self.variable_cell)
|
|
||||||
|
|
||||||
self._populate_values()
|
|
||||||
|
|
||||||
# Apply visual marking if recently updated
|
|
||||||
if update_timestamp:
|
|
||||||
self._apply_update_marking(update_timestamp)
|
|
||||||
|
|
||||||
def _populate_values(self):
|
|
||||||
"""Populate value entries for each environment."""
|
|
||||||
# Clear existing
|
|
||||||
while child := self.values_box.get_first_child():
|
|
||||||
self.values_box.remove(child)
|
|
||||||
|
|
||||||
self.value_entries = {}
|
|
||||||
|
|
||||||
# Check if this variable is sensitive
|
|
||||||
is_sensitive = self.variable_name in self.project.sensitive_variables
|
|
||||||
|
|
||||||
# Create entry for each environment
|
|
||||||
for env in self.environments:
|
|
||||||
# Create a box to hold the entry (for size group alignment)
|
|
||||||
entry_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
||||||
entry_box.set_size_request(200, -1)
|
|
||||||
|
|
||||||
entry = Gtk.Entry()
|
|
||||||
entry.set_placeholder_text(env.name)
|
|
||||||
entry.set_hexpand(True)
|
|
||||||
|
|
||||||
# Configure entry for sensitive variables
|
|
||||||
if is_sensitive:
|
|
||||||
entry.set_visibility(False) # Show bullets instead of text
|
|
||||||
entry.set_input_purpose(Gtk.InputPurpose.PASSWORD)
|
|
||||||
entry.add_css_class("sensitive-variable")
|
|
||||||
|
|
||||||
entry_box.append(entry)
|
|
||||||
|
|
||||||
# Add entry box to size group for alignment
|
|
||||||
if self.size_group:
|
|
||||||
self.size_group.add_widget(entry_box)
|
|
||||||
|
|
||||||
self.values_box.append(entry_box)
|
|
||||||
self.value_entries[env.id] = entry
|
|
||||||
|
|
||||||
# Get value from appropriate storage (keyring or JSON) asynchronously
|
|
||||||
def on_value_retrieved(value, entry=entry, env_id=env.id):
|
|
||||||
entry.set_text(value)
|
|
||||||
# Connect changed handler after setting initial value to avoid spurious signals
|
|
||||||
entry.connect('changed', self._on_value_changed, env_id)
|
|
||||||
|
|
||||||
self.project_manager.get_variable_value(
|
|
||||||
self.project.id,
|
|
||||||
env.id,
|
|
||||||
self.variable_name,
|
|
||||||
on_value_retrieved
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_value_changed(self, entry, env_id):
|
|
||||||
"""Handle value entry changes."""
|
|
||||||
value = entry.get_text()
|
|
||||||
|
|
||||||
# Emit signal first (value is being changed)
|
|
||||||
self.emit('value-changed', env_id, value)
|
|
||||||
|
|
||||||
# Use project_manager to store value in appropriate location (async)
|
|
||||||
self.project_manager.set_variable_value(
|
|
||||||
self.project.id,
|
|
||||||
env_id,
|
|
||||||
self.variable_name,
|
|
||||||
value,
|
|
||||||
callback=None # Fire and forget - errors are logged
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_name_entry_focus_in(self, controller):
|
|
||||||
"""Show edit buttons when entry gains focus."""
|
|
||||||
self._is_editing = True
|
|
||||||
self.original_name = self.name_entry.get_text().strip()
|
|
||||||
self.edit_buttons_revealer.set_reveal_child(True)
|
|
||||||
|
|
||||||
def _on_name_entry_focus_out(self, controller):
|
|
||||||
"""Handle focus leaving the entry without explicit save/cancel."""
|
|
||||||
# Only auto-hide if user didn't click cancel/save
|
|
||||||
# The buttons will handle hiding themselves
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _on_name_entry_activate(self, entry):
|
|
||||||
"""Handle Enter key press - same as clicking Save."""
|
|
||||||
self._save_changes()
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_cancel_clicked(self, button):
|
|
||||||
"""Cancel editing and restore original name."""
|
|
||||||
self.name_entry.set_text(self.original_name)
|
|
||||||
self._is_editing = False
|
|
||||||
self.edit_buttons_revealer.set_reveal_child(False)
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_save_clicked(self, button):
|
|
||||||
"""Save variable name changes."""
|
|
||||||
self._save_changes()
|
|
||||||
|
|
||||||
def _save_changes(self):
|
|
||||||
"""Save the variable name change."""
|
|
||||||
new_name = self.name_entry.get_text().strip()
|
|
||||||
|
|
||||||
# Only emit if name actually changed
|
|
||||||
if new_name != self.original_name:
|
|
||||||
self.emit('variable-changed')
|
|
||||||
|
|
||||||
self._is_editing = False
|
|
||||||
self.edit_buttons_revealer.set_reveal_child(False)
|
|
||||||
self.original_name = new_name
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_delete_clicked(self, button):
|
|
||||||
"""Handle delete button click."""
|
|
||||||
self.emit('variable-delete-requested')
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_sensitive_toggled(self, toggle_button):
|
|
||||||
"""Handle sensitivity toggle."""
|
|
||||||
if self._updating_toggle:
|
|
||||||
return
|
|
||||||
|
|
||||||
is_sensitive = toggle_button.get_active()
|
|
||||||
self._update_sensitive_icon(is_sensitive)
|
|
||||||
|
|
||||||
# Emit signal so parent can update the project
|
|
||||||
self.emit('sensitivity-changed', is_sensitive)
|
|
||||||
|
|
||||||
def _update_sensitive_icon(self, is_sensitive):
|
|
||||||
"""Update the lock icon based on sensitivity state."""
|
|
||||||
if is_sensitive:
|
|
||||||
self.sensitive_toggle.set_icon_name("channel-secure-symbolic")
|
|
||||||
self.sensitive_toggle.set_tooltip_text("Sensitive (stored in keyring)\nClick to make non-sensitive")
|
|
||||||
self.name_entry.add_css_class("sensitive-variable-name")
|
|
||||||
else:
|
|
||||||
self.sensitive_toggle.set_icon_name("changes-allow-symbolic")
|
|
||||||
self.sensitive_toggle.set_tooltip_text("Not sensitive (stored in JSON)\nClick to mark as sensitive")
|
|
||||||
self.name_entry.remove_css_class("sensitive-variable-name")
|
|
||||||
|
|
||||||
def get_variable_name(self):
|
|
||||||
"""Return current variable name."""
|
|
||||||
return self.name_entry.get_text().strip()
|
|
||||||
|
|
||||||
def refresh(self, environments, project):
|
|
||||||
"""Refresh with updated environments and project."""
|
|
||||||
self.environments = environments
|
|
||||||
self.project = project
|
|
||||||
|
|
||||||
# Update original_name in case variable was renamed
|
|
||||||
self.original_name = self.variable_name
|
|
||||||
|
|
||||||
# Update sensitivity toggle state
|
|
||||||
is_sensitive = self.variable_name in self.project.sensitive_variables
|
|
||||||
self._updating_toggle = True
|
|
||||||
self.sensitive_toggle.set_active(is_sensitive)
|
|
||||||
self._update_sensitive_icon(is_sensitive)
|
|
||||||
self._updating_toggle = False
|
|
||||||
|
|
||||||
self._populate_values()
|
|
||||||
|
|
||||||
def _apply_update_marking(self, timestamp_str):
|
|
||||||
"""
|
|
||||||
Apply visual marking to show variable was recently updated.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timestamp_str: ISO format timestamp of when variable was updated
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Parse timestamp
|
|
||||||
update_time = datetime.fromisoformat(timestamp_str)
|
|
||||||
time_diff = datetime.now() - update_time
|
|
||||||
|
|
||||||
# Only apply marking if updated within last 3 minutes
|
|
||||||
if time_diff < timedelta(minutes=3):
|
|
||||||
# Add CSS class for visual styling
|
|
||||||
self.add_css_class('recently-updated')
|
|
||||||
|
|
||||||
# Set tooltip with update time
|
|
||||||
seconds_ago = int(time_diff.total_seconds())
|
|
||||||
if seconds_ago < 10:
|
|
||||||
tooltip_text = "✨ Updated just now by script"
|
|
||||||
elif seconds_ago < 60:
|
|
||||||
tooltip_text = f"✨ Updated {seconds_ago} seconds ago by script"
|
|
||||||
else:
|
|
||||||
minutes_ago = seconds_ago // 60
|
|
||||||
if minutes_ago == 1:
|
|
||||||
tooltip_text = "✨ Updated 1 minute ago by script"
|
|
||||||
else:
|
|
||||||
tooltip_text = f"✨ Updated {minutes_ago} minutes ago by script"
|
|
||||||
|
|
||||||
self.set_tooltip_text(tooltip_text)
|
|
||||||
|
|
||||||
# Keep highlighting visible for 60 seconds (plenty of time to see it)
|
|
||||||
if self.update_timeout_id:
|
|
||||||
GLib.source_remove(self.update_timeout_id)
|
|
||||||
|
|
||||||
self.update_timeout_id = GLib.timeout_add_seconds(60, self._remove_update_marking)
|
|
||||||
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
# Invalid timestamp format, skip marking
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _remove_update_marking(self):
|
|
||||||
"""Remove visual marking after timeout."""
|
|
||||||
self.remove_css_class('recently-updated')
|
|
||||||
self.set_tooltip_text("")
|
|
||||||
self.update_timeout_id = None
|
|
||||||
return False # Don't repeat timeout
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
# variable_row.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 Gtk, GObject
|
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/variable-row.ui')
|
|
||||||
class VariableRow(Gtk.Box):
|
|
||||||
"""Widget for editing a variable name."""
|
|
||||||
|
|
||||||
__gtype_name__ = 'VariableRow'
|
|
||||||
|
|
||||||
name_entry = Gtk.Template.Child()
|
|
||||||
remove_button = Gtk.Template.Child()
|
|
||||||
|
|
||||||
__gsignals__ = {
|
|
||||||
'remove-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
|
||||||
'changed': (GObject.SIGNAL_RUN_FIRST, None, ())
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, variable_name=""):
|
|
||||||
super().__init__()
|
|
||||||
self.name_entry.set_text(variable_name)
|
|
||||||
self.name_entry.connect('changed', self._on_entry_changed)
|
|
||||||
|
|
||||||
def _on_entry_changed(self, entry):
|
|
||||||
"""Handle changes to name entry."""
|
|
||||||
self.emit('changed')
|
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
|
||||||
def on_remove_clicked(self, button):
|
|
||||||
"""Handle remove button click."""
|
|
||||||
self.emit('remove-requested')
|
|
||||||
|
|
||||||
def get_variable_name(self):
|
|
||||||
"""Return variable name."""
|
|
||||||
return self.name_entry.get_text().strip()
|
|
||||||
|
|
||||||
def set_variable_name(self, name: str):
|
|
||||||
"""Set variable name."""
|
|
||||||
self.name_entry.set_text(name)
|
|
||||||
869
src/window.py
@ -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>'
|
|
||||||
)
|
|
||||||