Compare commits

..

No commits in common. "master" and "feature/scripts-postprocessing" have entirely different histories.

81 changed files with 950 additions and 7334 deletions

1
.gitignore vendored
View File

@ -25,4 +25,3 @@ __pycache__/
# Flatpak # Flatpak
.flatpak/ .flatpak/
repo/ repo/
*.local.json

View File

@ -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()`

View File

@ -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

View File

@ -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
View File

@ -6,132 +6,27 @@ 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
![Main Interface](screenshots/main.png) - libadwaita 1
- Python 3
- libsoup3 (provided by GNOME Platform)
### Request History ## Building
![Request History](screenshots/history.png)
### Environment Variables
![Environment Variables](screenshots/variables.png)
## 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 libsoup3 (from GNOME Platform) for making HTTP requests - no external dependencies required.
### Usage Run Roster from your application menu or with the `roster` command.
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
# Put your Roster data directory under version control
cd ~/.local/share/cz.bugsy.roster/projects
git init
git remote add origin git@example.com:your-team/api-collections.git
git add .
git commit -m "Add API collections"
git push
```
Team members can then clone the repository into their own data directory and get the full set of projects and requests instantly. Changes to requests show up as clean, reviewable diffs — each request is a separate file, so there are no merge conflicts from unrelated edits.
### JavaScript Automation
Use preprocessing and postprocessing scripts to:
- 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.

View File

@ -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"
}
]
}
]
}

View File

@ -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" : [

View File

@ -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

View File

@ -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>

View 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

View File

@ -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>

View File

@ -0,0 +1,82 @@
<?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="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>
<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>

View File

@ -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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
)

View File

@ -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'
) )

View File

@ -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)

View File

@ -1,5 +1,5 @@
project('roster', project('roster',
version: '0.11.0', version: '0.3.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', ],
) )

View File

@ -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

View File

@ -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 ""

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

View File

@ -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

View File

@ -5,7 +5,7 @@
<template class="EnvironmentsDialog" parent="AdwDialog"> <template class="EnvironmentsDialog" parent="AdwDialog">
<property name="title">Manage Environments</property> <property name="title">Manage Environments</property>
<property name="content-width">1200</property> <property name="content-width">900</property>
<property name="content-height">600</property> <property name="content-height">600</property>
<property name="follows-content-size">false</property> <property name="follows-content-size">false</property>
@ -18,7 +18,7 @@
</child> </child>
<property name="content"> <property name="content">
<object class="GtkScrolledWindow" id="scrolled_window"> <object class="GtkScrolledWindow">
<property name="vexpand">true</property> <property name="vexpand">true</property>
<child> <child>
<object class="AdwClamp"> <object class="AdwClamp">

View File

@ -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,7 +17,6 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import re
from gi.repository import Adw, Gtk, GObject from gi.repository import Adw, Gtk, GObject
from .widgets.variable_row import VariableRow from .widgets.variable_row import VariableRow
from .widgets.environment_row import EnvironmentRow from .widgets.environment_row import EnvironmentRow
@ -26,7 +24,7 @@ from .widgets.environment_header_row import EnvironmentHeaderRow
from .widgets.variable_data_row import VariableDataRow from .widgets.variable_data_row import VariableDataRow
@Gtk.Template(resource_path='/cz/bugsy/roster/environments-dialog.ui') @Gtk.Template(resource_path='/cz/vesp/roster/environments-dialog.ui')
class EnvironmentsDialog(Adw.Dialog): class EnvironmentsDialog(Adw.Dialog):
"""Dialog for managing project environments and variables.""" """Dialog for managing project environments and variables."""
@ -34,7 +32,6 @@ class EnvironmentsDialog(Adw.Dialog):
table_container = Gtk.Template.Child() table_container = Gtk.Template.Child()
add_environment_button = Gtk.Template.Child() add_environment_button = Gtk.Template.Child()
scrolled_window = Gtk.Template.Child()
__gsignals__ = { __gsignals__ = {
'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()), 'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()),
@ -49,10 +46,6 @@ class EnvironmentsDialog(Adw.Dialog):
self.header_row = None self.header_row = None
self.data_rows = [] self.data_rows = []
self._populate_table() 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): def _populate_table(self):
"""Populate the transposed environment-variable table.""" """Populate the transposed environment-variable table."""
@ -71,8 +64,6 @@ class EnvironmentsDialog(Adw.Dialog):
self._on_environment_edit) self._on_environment_edit)
self.header_row.connect('environment-delete-requested', self.header_row.connect('environment-delete-requested',
self._on_environment_delete) self._on_environment_delete)
self.header_row.connect('environment-copy-requested',
self._on_environment_copy)
self.table_container.append(self.header_row) self.table_container.append(self.header_row)
# Add separator # Add separator
@ -88,8 +79,6 @@ class EnvironmentsDialog(Adw.Dialog):
var_name, var_name,
self.project.environments, self.project.environments,
self.size_group, self.size_group,
self.project,
self.project_manager,
update_timestamp=update_timestamp update_timestamp=update_timestamp
) )
row.connect('variable-changed', row.connect('variable-changed',
@ -98,8 +87,6 @@ class EnvironmentsDialog(Adw.Dialog):
self._on_variable_remove, var_name) self._on_variable_remove, var_name)
row.connect('value-changed', row.connect('value-changed',
self._on_value_changed, var_name) self._on_value_changed, var_name)
row.connect('sensitivity-changed',
self._on_sensitivity_changed, var_name)
self.table_container.append(row) self.table_container.append(row)
self.data_rows.append(row) self.data_rows.append(row)
@ -177,13 +164,11 @@ class EnvironmentsDialog(Adw.Dialog):
def on_response(dlg, response): def on_response(dlg, response):
if response == "delete": if response == "delete":
def on_delete_complete(): self.project_manager.delete_variable(self.project.id, var_name)
self._reload_project() self._reload_project()
self._populate_table() self._populate_table()
self.emit('environments-updated') self.emit('environments-updated')
self.project_manager.delete_variable(self.project.id, var_name, on_delete_complete)
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) dialog.present(self)
@ -191,13 +176,11 @@ class EnvironmentsDialog(Adw.Dialog):
"""Handle variable name change.""" """Handle variable name change."""
new_name = row.get_variable_name() new_name = row.get_variable_name()
if new_name and new_name != old_name and new_name not in self.project.variable_names: if new_name and new_name != old_name and new_name not in self.project.variable_names:
def on_rename_complete(): self.project_manager.rename_variable(self.project.id, old_name, new_name)
self._reload_project() self._reload_project()
self._populate_table() self._populate_table()
self.emit('environments-updated') self.emit('environments-updated')
self.project_manager.rename_variable(self.project.id, old_name, new_name, on_rename_complete)
@Gtk.Template.Callback() @Gtk.Template.Callback()
def on_add_environment_clicked(self, button): def on_add_environment_clicked(self, button):
"""Add new environment.""" """Add new environment."""
@ -257,30 +240,6 @@ class EnvironmentsDialog(Adw.Dialog):
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) 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): def _on_environment_delete(self, widget, environment):
"""Delete environment with confirmation.""" """Delete environment with confirmation."""
# Prevent deletion of last environment # Prevent deletion of last environment
@ -303,38 +262,19 @@ class EnvironmentsDialog(Adw.Dialog):
def on_response(dlg, response): def on_response(dlg, response):
if response == "delete": if response == "delete":
def on_delete_complete(): self.project_manager.delete_environment(self.project.id, environment.id)
self._reload_project() self._reload_project()
self._populate_table() self._populate_table()
self.emit('environments-updated') self.emit('environments-updated')
self.project_manager.delete_environment(self.project.id, environment.id, on_delete_complete)
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) dialog.present(self)
def _on_value_changed(self, widget, env_id, value, var_name): def _on_value_changed(self, widget, env_id, value, var_name):
"""Handle value change in table cell.""" """Handle value change in table cell."""
# Note: value is already stored by VariableDataRow using set_variable_value self.project_manager.update_environment_variable(self.project.id, env_id, var_name, value)
# This handler is just for emitting the update signal
self.emit('environments-updated') 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): def _reload_project(self):
"""Reload project data from manager.""" """Reload project data from manager."""
projects = self.project_manager.load_projects() projects = self.project_manager.load_projects()

View File

@ -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>

View File

@ -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")

View File

@ -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']

View File

@ -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

View File

@ -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)

View File

@ -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()]

View File

@ -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,23 +67,27 @@ 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): def delete_entry(self, entry: HistoryEntry):
"""Delete a specific entry from history by ID.""" """Delete a specific entry from history."""
entries = self.load_history() entries = self.load_history()
# Filter out the entry by comparing unique IDs # Filter out the entry by comparing timestamps and URLs
entries = [e for e in entries if e.id != entry.id] entries = [e for e in entries if not (
e.timestamp == entry.timestamp and
e.request.url == entry.request.url and
e.request.method == entry.request.method
)]
self.save_history(entries) self.save_history(entries)
def clear_history(self): def clear_history(self):

View File

@ -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
@ -153,24 +153,15 @@ class HttpClient:
# Format headers in HTTPie-style output (HTTP/1.1 200 OK\nHeader: value\n...) # Format headers in HTTPie-style 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:
@ -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:

View File

@ -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")

View File

@ -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] + ''

View File

@ -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."""

View File

@ -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>
@ -111,7 +72,7 @@
</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,20 +82,6 @@
<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 --> <!-- Left side buttons -->
<child type="start"> <child type="start">
<object class="GtkButton" id="save_request_button"> <object class="GtkButton" id="save_request_button">
@ -146,26 +93,14 @@
</style> </style>
</object> </object>
</child> </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 +109,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 +146,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 +161,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 +188,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 +207,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>

View File

@ -26,16 +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, version='0.0.0'):
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.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)
@ -56,25 +55,14 @@ 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=self.version,
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)
@ -82,14 +70,14 @@ class RosterApplication(Adw.Application):
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 window = self.props.active_window
preferences = PreferencesDialog(history_manager=window.history_manager, project_manager=window.project_manager) preferences = PreferencesDialog(history_manager=window.history_manager)
preferences.set_transient_for(window) preferences.set_transient_for(window)
preferences.connect('history-cleared', lambda d: window._load_history()) 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 +100,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
constants.VERSION = version
app = RosterApplication(version=version) app = RosterApplication(version=version)
return app.run(sys.argv) return app.run(sys.argv)

View File

@ -34,37 +34,18 @@ roster_sources = [
'variable_substitution.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', 'environments_dialog.py',
'export_dialog.py',
'preferences_dialog.py', 'preferences_dialog.py',
'request_tab_widget.py', 'request_tab_widget.py',
'script_executor.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',

View File

@ -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

View File

@ -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)
@ -63,7 +51,6 @@ class HttpResponse:
headers: str # Raw header text from libsoup3 headers: str # Raw header text from libsoup3
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)
) )
@ -187,7 +162,6 @@ class Project:
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 variable_names: List[str] = None # List of variable names defined for this project
environments: List[Environment] = None # List of environments 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): def __post_init__(self):
"""Initialize optional fields.""" """Initialize optional fields."""
@ -195,8 +169,6 @@ class Project:
self.variable_names = [] self.variable_names = []
if self.environments is None: if self.environments is None:
self.environments = [] 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."""
@ -207,8 +179,7 @@ class Project:
'created_at': self.created_at, 'created_at': self.created_at,
'icon': self.icon, 'icon': self.icon,
'variable_names': self.variable_names, 'variable_names': self.variable_names,
'environments': [env.to_dict() for env in self.environments], 'environments': [env.to_dict() for env in self.environments]
'sensitive_variables': self.sensitive_variables
} }
@classmethod @classmethod
@ -221,8 +192,7 @@ class Project:
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', []), variable_names=data.get('variable_names', []),
environments=[Environment.from_dict(e) for e in data.get('environments', [])], environments=[Environment.from_dict(e) for e in data.get('environments', [])]
sensitive_variables=data.get('sensitive_variables', []) # Default for old data
) )

View File

@ -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")

View File

@ -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_, {})

View File

@ -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">
@ -66,65 +67,6 @@
</child> </child>
</object> </object>
</child> </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>

View File

@ -17,83 +17,51 @@
# #
# 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, GObject from gi.repository import Adw, Gtk, Gio, GObject
logger = logging.getLogger(__name__)
@Gtk.Template(resource_path='/cz/vesp/roster/preferences-dialog.ui')
@Gtk.Template(resource_path='/cz/bugsy/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() 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__ = { __gsignals__ = {
'history-cleared': (GObject.SIGNAL_RUN_FIRST, None, ()) 'history-cleared': (GObject.SIGNAL_RUN_FIRST, None, ())
} }
def __init__(self, history_manager=None, project_manager=None, **kwargs): def __init__(self, history_manager=None, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.history_manager = history_manager self.history_manager = history_manager
self.project_manager = project_manager
self.settings = Gio.Settings.new('cz.bugsy.roster') # Get settings
self.settings = Gio.Settings.new('cz.vesp.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() @Gtk.Template.Callback()
def on_clear_history_clicked(self, button): def on_clear_history_clicked(self, button):
"""Clear all history after confirmation."""
if not self.history_manager: if not self.history_manager:
return return
# Create confirmation dialog
dialog = Adw.AlertDialog.new( dialog = Adw.AlertDialog.new(
"Clear All History?", "Clear All History?",
"This will permanently delete all saved request and response history. This action cannot be undone." "This will permanently delete all saved request and response history. This action cannot be undone."
@ -106,59 +74,10 @@ class PreferencesDialog(Adw.PreferencesWindow):
def on_response(dialog, response): def on_response(dialog, response):
if response == "clear": if response == "clear":
# Clear the history
self.history_manager.clear_history() self.history_manager.clear_history()
# Notify the main window to refresh
self.emit('history-cleared') self.emit('history-cleared')
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) 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)

View File

@ -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,311 +18,69 @@
# 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, Environment
from .secret_manager import get_secret_manager
from .migration import _sanitize_name, needs_migration, run_migration
logger = logging.getLogger(__name__)
def _unique_filename(base: str, exclude: set) -> str:
"""Return a unique .json filename whose basename is not in `exclude`."""
if base not in exclude:
return base + '.json'
i = 2
while f"{base}-{i}" in exclude:
i += 1
return f"{base}-{i}.json"
class ProjectManager: 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 with default environment."""
projects = self.load_projects() projects = self.load_projects()
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
# Create default environment
default_env = Environment( default_env = Environment(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
name="Default", name="Default",
variables={}, variables={},
created_at=now created_at=now
) )
project = Project( project = Project(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
name=name, name=name,
@ -333,14 +90,11 @@ class ProjectManager:
environments=[default_env] 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 +102,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:
if p.id == project_id:
if not self._legacy_mode:
dir_name = self._project_dirs.get(project_id)
if dir_name:
project_dir = self.projects_dir / dir_name
if project_dir.exists():
shutil.rmtree(project_dir)
self._project_dirs.pop(project_id, None)
break
self._projects_cache = [p for p in projects if p.id != project_id]
if self._legacy_mode:
self._save_all_legacy()
else:
order = self._load_project_order()
self._save_project_order([pid for pid in order if pid != project_id])
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: def add_request(self, project_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest:
"""Add request to a project."""
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(
@ -395,11 +126,12 @@ class ProjectManager:
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:
@ -410,6 +142,7 @@ class ProjectManager:
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, scripts=None) -> 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:
@ -422,73 +155,29 @@ class ProjectManager:
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: def add_environment(self, project_id: str, name: str) -> Environment:
"""Add environment to a project."""
projects = self.load_projects() projects = self.load_projects()
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
environment = None environment = None
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
# Create environment with empty values for all variables
variables = {var_name: "" for var_name in p.variable_names} variables = {var_name: "" for var_name in p.variable_names}
environment = Environment( environment = Environment(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@ -497,78 +186,13 @@ class ProjectManager:
created_at=now created_at=now
) )
p.environments.append(environment) p.environments.append(environment)
self._save_project(p)
break break
self.save_projects(projects)
return environment 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): def update_environment(self, project_id: str, env_id: str, name: str = None, variables: dict = None):
"""Update an environment's name and/or variables."""
projects = self.load_projects() projects = self.load_projects()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
@ -579,101 +203,63 @@ class ProjectManager:
if variables is not None: if variables is not None:
env.variables = variables env.variables = variables
break break
self._save_project(p)
break break
self.save_projects(projects)
def delete_environment(self, project_id: str, env_id: str, def delete_environment(self, project_id: str, env_id: str):
callback: Optional[Callable[[], None]] = None): """Delete an environment."""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
p.environments = [e for e in p.environments if e.id != env_id] p.environments = [e for e in p.environments if e.id != env_id]
self._save_project(p)
break break
self.save_projects(projects)
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): def add_variable(self, project_id: str, variable_name: str):
"""Add a variable to the project and all environments."""
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:
if variable_name not in p.variable_names: if variable_name not in p.variable_names:
p.variable_names.append(variable_name) p.variable_names.append(variable_name)
# Add empty value to all environments
for env in p.environments: for env in p.environments:
env.variables[variable_name] = "" env.variables[variable_name] = ""
self._save_project(p)
break break
self.save_projects(projects)
def rename_variable(self, project_id: str, old_name: str, new_name: str, def rename_variable(self, project_id: str, old_name: str, new_name: str):
callback: Optional[Callable[[], None]] = None): """Rename a variable in the project and all environments."""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager()
is_sensitive = False
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
# Update variable_names list
if old_name in p.variable_names: if old_name in p.variable_names:
idx = p.variable_names.index(old_name) idx = p.variable_names.index(old_name)
p.variable_names[idx] = new_name p.variable_names[idx] = new_name
# Update all environments
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: for env in p.environments:
if old_name in env.variables: if old_name in env.variables:
env.variables[new_name] = env.variables.pop(old_name) env.variables[new_name] = env.variables.pop(old_name)
self._save_project(p)
break break
self.save_projects(projects)
if is_sensitive: def delete_variable(self, project_id: str, variable_name: str):
secret_manager.rename_variable_secrets(project_id, old_name, new_name, lambda success: callback() if callback else None) """Delete a variable from the project and all environments."""
elif callback:
callback()
def delete_variable(self, project_id: str, variable_name: str,
callback: Optional[Callable[[], None]] = None):
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager()
is_sensitive = False
environments_to_cleanup = []
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
# Remove from variable_names
if variable_name in p.variable_names: if variable_name in p.variable_names:
p.variable_names.remove(variable_name) p.variable_names.remove(variable_name)
# Remove from all environments
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: for env in p.environments:
env.variables.pop(variable_name, None) env.variables.pop(variable_name, None)
self._save_project(p)
break break
self.save_projects(projects)
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): def update_environment_variable(self, project_id: str, env_id: str, variable_name: str, value: str):
"""Update a single variable value in an environment."""
projects = self.load_projects() projects = self.load_projects()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
@ -681,230 +267,26 @@ class ProjectManager:
if env.id == env_id: if env.id == env_id:
env.variables[variable_name] = value env.variables[variable_name] = value
break break
self._save_project(p)
break break
self.save_projects(projects)
def batch_update_environment_variables(self, project_id: str, env_id: str, variables: dict): def batch_update_environment_variables(self, project_id: str, env_id: str, variables: dict):
"""
Update multiple variables in an environment in one operation.
Args:
project_id: Project ID
env_id: Environment ID
variables: Dict of variable_name -> value pairs to update
"""
projects = self.load_projects() projects = self.load_projects()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
for env in p.environments: for env in p.environments:
if env.id == env_id: if env.id == env_id:
# Update all variables
for var_name, var_value in variables.items(): for var_name, var_value in variables.items():
env.variables[var_name] = var_value env.variables[var_name] = var_value
break break
self._save_project(p)
break break
self.save_projects(projects)
# ===== 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)

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,11 @@
<?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">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>

View File

@ -24,11 +24,9 @@ import re
from pathlib import Path from pathlib import Path
from typing import Tuple, Optional, Dict, List, TYPE_CHECKING from typing import Tuple, Optional, Dict, List, TYPE_CHECKING
from dataclasses import dataclass, field from dataclasses import dataclass, field
from .constants import SCRIPT_EXECUTION_TIMEOUT_SECONDS
if TYPE_CHECKING: if TYPE_CHECKING:
from .project_manager import ProjectManager from .project_manager import ProjectManager
from .models import HttpRequest
@dataclass @dataclass
@ -39,7 +37,6 @@ class ScriptResult:
error: Optional[str] = None error: Optional[str] = None
variable_updates: Dict[str, str] = field(default_factory=dict) # Variables set by script 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) warnings: List[str] = field(default_factory=list) # Warnings (e.g., no env selected)
modified_request: Optional['HttpRequest'] = None # Modified request from preprocessing
@dataclass @dataclass
@ -53,7 +50,7 @@ class ScriptContext:
class ScriptExecutor: class ScriptExecutor:
"""Executes JavaScript code using gjs (GNOME JavaScript).""" """Executes JavaScript code using gjs (GNOME JavaScript)."""
TIMEOUT_SECONDS = SCRIPT_EXECUTION_TIMEOUT_SECONDS TIMEOUT_SECONDS = 5
@staticmethod @staticmethod
def execute_postprocessing_script( def execute_postprocessing_script(
@ -102,9 +99,6 @@ let consoleOutput = [];
const console = {{ const console = {{
log: function(...args) {{ log: function(...args) {{
consoleOutput.push(args.map(arg => String(arg)).join(' ')); consoleOutput.push(args.map(arg => String(arg)).join(' '));
}},
error: function(...args) {{
consoleOutput.push(args.map(arg => String(arg)).join(' '));
}} }}
}}; }};
@ -163,174 +157,6 @@ print(JSON.stringify(roster.__variables));
error_msg = error.strip() if error else "Script execution failed" error_msg = error.strip() if error else "Script execution failed"
return ScriptResult(success=False, output="", error=error_msg) 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 @staticmethod
def _create_response_object(response) -> dict: def _create_response_object(response) -> dict:
"""Convert HttpResponse to JavaScript-compatible dict.""" """Convert HttpResponse to JavaScript-compatible dict."""
@ -351,47 +177,6 @@ print(JSON.stringify(roster.__variables));
'responseTime': response.response_time_ms '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 @staticmethod
def _is_valid_variable_name(name: str) -> bool: def _is_valid_variable_name(name: str) -> bool:
""" """

View File

@ -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

View File

@ -26,18 +26,6 @@
<property name="accelerator">&lt;Control&gt;s</property> <property name="accelerator">&lt;Control&gt;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">&lt;Control&gt;l</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes" context="shortcut window">Send Request</property>
<property name="accelerator">&lt;Control&gt;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>

View File

@ -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."""
@ -49,10 +45,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,
@ -154,6 +147,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

View File

@ -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
@ -65,11 +64,8 @@ class VariableSubstitution:
var_name = match.group(1) var_name = match.group(1)
if var_name in variables: if var_name in variables:
value = variables[var_name] value = variables[var_name]
# Check if value is empty/None - treat as undefined for warning purposes # Replace with value or empty string if value is None/empty
if not value: return value if value else ""
undefined_vars.append(var_name)
return ""
return value
else: else:
# Variable not defined - track it and replace with empty string # Variable not defined - track it and replace with empty string
undefined_vars.append(var_name) undefined_vars.append(var_name)
@ -127,118 +123,3 @@ class VariableSubstitution:
) )
return new_request, all_undefined 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

View File

@ -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

View File

@ -23,18 +23,6 @@
<property name="halign">center</property> <property name="halign">center</property>
<property name="spacing">4</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> <child>
<object class="GtkButton" id="edit_button"> <object class="GtkButton" id="edit_button">
<property name="icon-name">document-properties-symbolic</property> <property name="icon-name">document-properties-symbolic</property>

View File

@ -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,21 +20,19 @@
from gi.repository import Gtk, GObject from gi.repository import Gtk, GObject
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/environment-column-header.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/environment-column-header.ui')
class EnvironmentColumnHeader(Gtk.Box): class EnvironmentColumnHeader(Gtk.Box):
"""Widget for displaying an environment column header with edit/delete buttons.""" """Widget for displaying an environment column header with edit/delete buttons."""
__gtype_name__ = 'EnvironmentColumnHeader' __gtype_name__ = 'EnvironmentColumnHeader'
name_label = Gtk.Template.Child() name_label = Gtk.Template.Child()
copy_button = Gtk.Template.Child()
edit_button = Gtk.Template.Child() edit_button = Gtk.Template.Child()
delete_button = Gtk.Template.Child() delete_button = Gtk.Template.Child()
__gsignals__ = { __gsignals__ = {
'edit-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), 'edit-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), 'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'copy-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
} }
def __init__(self, environment): def __init__(self, environment):
@ -43,11 +40,6 @@ class EnvironmentColumnHeader(Gtk.Box):
self.environment = environment self.environment = environment
self.name_label.set_text(environment.name) 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() @Gtk.Template.Callback()
def on_edit_clicked(self, button): def on_edit_clicked(self, button):
"""Handle edit button click.""" """Handle edit button click."""

View File

@ -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 Gtk, GObject
from .environment_column_header import EnvironmentColumnHeader from .environment_column_header import EnvironmentColumnHeader
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/environment-header-row.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/environment-header-row.ui')
class EnvironmentHeaderRow(Gtk.Box): class EnvironmentHeaderRow(Gtk.Box):
"""Widget for the header row containing all environment column headers.""" """Widget for the header row containing all environment column headers."""
@ -35,7 +34,6 @@ class EnvironmentHeaderRow(Gtk.Box):
__gsignals__ = { __gsignals__ = {
'environment-edit-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)), 'environment-edit-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
'environment-delete-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): def __init__(self, environments, size_group):
@ -63,7 +61,6 @@ class EnvironmentHeaderRow(Gtk.Box):
header = EnvironmentColumnHeader(env) header = EnvironmentColumnHeader(env)
header.connect('edit-requested', self._on_edit_requested, env) header.connect('edit-requested', self._on_edit_requested, env)
header.connect('delete-requested', self._on_delete_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 # Add to size group for alignment with data rows
if self.size_group: if self.size_group:
@ -80,10 +77,6 @@ class EnvironmentHeaderRow(Gtk.Box):
"""Handle delete request from column header.""" """Handle delete request from column header."""
self.emit('environment-delete-requested', environment) 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): def refresh(self, environments):
"""Refresh with updated environments list.""" """Refresh with updated environments list."""
self.environments = environments self.environments = environments

View File

@ -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/environment-row.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/environment-row.ui')
class EnvironmentRow(Gtk.Box): class EnvironmentRow(Gtk.Box):
"""Widget for displaying and editing an environment with its variables.""" """Widget for displaying and editing an environment with its variables."""

View File

@ -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."""

View File

@ -62,19 +62,6 @@
</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 --> <!-- Delete Button -->
<child> <child>
<object class="GtkButton" id="delete_button"> <object class="GtkButton" id="delete_button">

View File

@ -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 Gtk, GObject, Gdk
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,7 +33,6 @@ 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() 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_scroll = Gtk.Template.Child()
@ -78,10 +76,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}"

View File

@ -4,16 +4,6 @@
<requires lib="libadwaita" version="1.0"/> <requires lib="libadwaita" version="1.0"/>
<menu id="project_menu"> <menu id="project_menu">
<section>
<item>
<attribute name="label">Move Up</attribute>
<attribute name="action">project.move-up</attribute>
</item>
<item>
<attribute name="label">Move Down</attribute>
<attribute name="action">project.move-down</attribute>
</item>
</section>
<section> <section>
<item> <item>
<attribute name="label">Manage Environments</attribute> <attribute name="label">Manage Environments</attribute>
@ -39,7 +29,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 +72,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>

View File

@ -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()
@ -41,17 +39,11 @@ class ProjectItem(Gtk.Box):
'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, ()), '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,20 +51,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 = Gio.SimpleAction.new("manage-environments", None)
action.connect("activate", lambda *_: self.emit('manage-environments-requested')) action.connect("activate", lambda *_: self.emit('manage-environments-requested'))
actions.add_action(action) actions.add_action(action)
@ -96,25 +80,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 +97,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()

View File

@ -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>

View File

@ -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}")

View File

@ -19,53 +19,7 @@
<object class="GtkEntry" id="name_entry"> <object class="GtkEntry" id="name_entry">
<property name="placeholder-text">Variable name</property> <property name="placeholder-text">Variable name</property>
<property name="hexpand">True</property> <property name="hexpand">True</property>
</object> <signal name="changed" handler="on_name_changed"/>
</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> </object>
</child> </child>
@ -79,6 +33,8 @@
</style> </style>
</object> </object>
</child> </child>
</object>
</child>
<child> <child>
<object class="GtkBox" id="values_box"> <object class="GtkBox" id="values_box">

View File

@ -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 Gtk, GObject, GLib
from datetime import datetime, timedelta from datetime import datetime, timedelta
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/variable-data-row.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/variable-data-row.ui')
class VariableDataRow(Gtk.Box): class VariableDataRow(Gtk.Box):
"""Widget for a data row with variable name and values for each environment.""" """Widget for a data row with variable name and values for each environment."""
@ -30,10 +29,6 @@ class VariableDataRow(Gtk.Box):
variable_cell = Gtk.Template.Child() variable_cell = Gtk.Template.Child()
name_entry = 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() delete_button = Gtk.Template.Child()
values_box = Gtk.Template.Child() values_box = Gtk.Template.Child()
@ -41,41 +36,19 @@ class VariableDataRow(Gtk.Box):
'variable-changed': (GObject.SIGNAL_RUN_FIRST, None, ()), 'variable-changed': (GObject.SIGNAL_RUN_FIRST, None, ()),
'variable-delete-requested': (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 '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): def __init__(self, variable_name, environments, size_group, update_timestamp=None):
super().__init__() super().__init__()
self.variable_name = variable_name self.variable_name = variable_name
self.original_name = variable_name # Store original name for cancel
self.environments = environments self.environments = environments
self.size_group = size_group self.size_group = size_group
self.project = project
self.project_manager = project_manager
self.value_entries = {} self.value_entries = {}
self.update_timeout_id = None self.update_timeout_id = None
self._updating_toggle = False # Flag to prevent recursion
self._is_editing = False # Track editing state
# Set variable name # Set variable name
self.name_entry.set_text(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 # Add variable cell to size group for alignment
if size_group: if size_group:
size_group.add_widget(self.variable_cell) size_group.add_widget(self.variable_cell)
@ -94,9 +67,6 @@ class VariableDataRow(Gtk.Box):
self.value_entries = {} self.value_entries = {}
# Check if this variable is sensitive
is_sensitive = self.variable_name in self.project.sensitive_variables
# Create entry for each environment # Create entry for each environment
for env in self.environments: for env in self.environments:
# Create a box to hold the entry (for size group alignment) # Create a box to hold the entry (for size group alignment)
@ -105,13 +75,9 @@ class VariableDataRow(Gtk.Box):
entry = Gtk.Entry() entry = Gtk.Entry()
entry.set_placeholder_text(env.name) entry.set_placeholder_text(env.name)
entry.set_text(env.variables.get(self.variable_name, ""))
entry.set_hexpand(True) entry.set_hexpand(True)
entry.connect('changed', self._on_value_changed, env.id)
# 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) entry_box.append(entry)
@ -122,122 +88,27 @@ class VariableDataRow(Gtk.Box):
self.values_box.append(entry_box) self.values_box.append(entry_box)
self.value_entries[env.id] = entry 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): def _on_value_changed(self, entry, env_id):
"""Handle value entry changes.""" """Handle value entry changes."""
value = entry.get_text() self.emit('value-changed', env_id, 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() @Gtk.Template.Callback()
def on_cancel_clicked(self, button): def on_name_changed(self, entry):
"""Cancel editing and restore original name.""" """Handle variable name changes."""
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.emit('variable-changed')
self._is_editing = False
self.edit_buttons_revealer.set_reveal_child(False)
self.original_name = new_name
@Gtk.Template.Callback() @Gtk.Template.Callback()
def on_delete_clicked(self, button): def on_delete_clicked(self, button):
"""Handle delete button click.""" """Handle delete button click."""
self.emit('variable-delete-requested') 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): def get_variable_name(self):
"""Return current variable name.""" """Return current variable name."""
return self.name_entry.get_text().strip() return self.name_entry.get_text().strip()
def refresh(self, environments, project): def refresh(self, environments):
"""Refresh with updated environments and project.""" """Refresh with updated environments list."""
self.environments = environments 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() self._populate_values()
def _apply_update_marking(self, timestamp_str): def _apply_update_marking(self, timestamp_str):

View File

@ -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/variable-row.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/variable-row.ui')
class VariableRow(Gtk.Box): class VariableRow(Gtk.Box):
"""Widget for editing a variable name.""" """Widget for editing a variable name."""

View File

@ -19,26 +19,14 @@
import gi import gi
gi.require_version('GtkSource', '5') gi.require_version('GtkSource', '5')
from gi.repository import Adw, Gtk, GLib, Gio, GtkSource, GObject from gi.repository import Adw, Gtk, GLib, Gio, GtkSource
from typing import Dict, Optional
import logging
from .models import HttpRequest, HttpResponse, HistoryEntry, RequestTab from .models import HttpRequest, HttpResponse, HistoryEntry, RequestTab
from .constants import (
UI_TOAST_TIMEOUT_SECONDS,
DELAY_TAB_CREATION_MS,
DISPLAY_URL_NAME_MAX_LENGTH,
PROJECT_NAME_MAX_LENGTH,
REQUEST_NAME_MAX_LENGTH,
)
from .http_client import HttpClient from .http_client import HttpClient
from .history_manager import HistoryManager from .history_manager import HistoryManager
from .project_manager import ProjectManager from .project_manager import ProjectManager
from .tab_manager import TabManager from .tab_manager import TabManager
from .icon_picker_dialog import IconPickerDialog from .icon_picker_dialog import IconPickerDialog
from .environments_dialog import EnvironmentsDialog from .environments_dialog import EnvironmentsDialog
from .wsdl_import_dialog import WsdlImportDialog
from .openapi_import_dialog import OpenApiImportDialog
from .http_file_import_dialog import HttpFileImportDialog
from .request_tab_widget import RequestTabWidget from .request_tab_widget import RequestTabWidget
from .widgets.history_item import HistoryItem from .widgets.history_item import HistoryItem
from .widgets.project_item import ProjectItem from .widgets.project_item import ProjectItem
@ -46,29 +34,23 @@ from datetime import datetime
import json import json
import uuid import uuid
logger = logging.getLogger(__name__)
@Gtk.Template(resource_path='/cz/vesp/roster/main-window.ui')
@Gtk.Template(resource_path='/cz/bugsy/roster/main-window.ui')
class RosterWindow(Adw.ApplicationWindow): class RosterWindow(Adw.ApplicationWindow):
__gtype_name__ = 'RosterWindow' __gtype_name__ = 'RosterWindow'
# Toast overlay
toast_overlay = Gtk.Template.Child()
# Top bar widgets # Top bar widgets
save_request_button = Gtk.Template.Child() save_request_button = Gtk.Template.Child()
export_request_button = Gtk.Template.Child()
new_request_button = Gtk.Template.Child() new_request_button = Gtk.Template.Child()
tab_view = Gtk.Template.Child() tab_view = Gtk.Template.Child()
tab_bar = Gtk.Template.Child() tab_bar = Gtk.Template.Child()
# Split view # Panes
split_view = Gtk.Template.Child() main_pane = Gtk.Template.Child()
sidebar_toggle_button = Gtk.Template.Child()
# Sidebar widgets # Sidebar widgets
projects_listbox = Gtk.Template.Child() projects_listbox = Gtk.Template.Child()
add_project_button = Gtk.Template.Child()
# History (hidden but kept for compatibility) # History (hidden but kept for compatibility)
history_listbox = Gtk.Template.Child() history_listbox = Gtk.Template.Child()
@ -82,13 +64,13 @@ class RosterWindow(Adw.ApplicationWindow):
self.tab_manager = TabManager() self.tab_manager = TabManager()
# Map AdwTabPage to tab data # Map AdwTabPage to tab data
self.page_to_widget: Dict[Adw.TabPage, RequestTabWidget] = {} self.page_to_widget = {} # AdwTabPage -> RequestTabWidget
self.page_to_tab: Dict[Adw.TabPage, RequestTab] = {} self.page_to_tab = {} # AdwTabPage -> RequestTab
self.current_tab_id: Optional[str] = None self.current_tab_id = None
# Track recently updated variables for visual marking # Track recently updated variables for visual marking
# Format: {project_id: {var_name: timestamp}} # Format: {project_id: {var_name: timestamp}}
self.recently_updated_variables: Dict[str, Dict[str, str]] = {} self.recently_updated_variables = {}
# Connect to tab view signals # Connect to tab view signals
self.tab_view.connect('close-page', self._on_tab_close_page) self.tab_view.connect('close-page', self._on_tab_close_page)
@ -100,17 +82,6 @@ class RosterWindow(Adw.ApplicationWindow):
# Setup custom CSS # Setup custom CSS
self._setup_custom_css() self._setup_custom_css()
# Bind sidebar toggle button to split view (bidirectional)
self.split_view.bind_property(
'show-sidebar',
self.sidebar_toggle_button,
'active',
GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE
)
# Switch tab widgets to narrow layout when sidebar collapses
self.split_view.connect("notify::collapsed", self._on_split_view_collapsed_changed)
# Setup UI # Setup UI
self._setup_tab_system() self._setup_tab_system()
self._load_projects() self._load_projects()
@ -122,13 +93,7 @@ class RosterWindow(Adw.ApplicationWindow):
# Create first tab # Create first tab
self._create_new_tab() self._create_new_tab()
def _on_split_view_collapsed_changed(self, split_view, pspec) -> None: def _on_close_request(self, window):
"""Switch all tab widgets to narrow or normal layout based on sidebar state."""
is_narrow = split_view.get_collapsed()
for widget in self.page_to_widget.values():
widget.set_narrow_mode(is_narrow)
def _on_close_request(self, window) -> bool:
"""Handle window close request - warn if there are unsaved changes.""" """Handle window close request - warn if there are unsaved changes."""
# Check if any tabs have unsaved changes # Check if any tabs have unsaved changes
modified_tabs = [] modified_tabs = []
@ -163,29 +128,31 @@ class RosterWindow(Adw.ApplicationWindow):
# No unsaved changes, allow closing # No unsaved changes, allow closing
return False return False
def _on_close_app_dialog_response(self, dialog, response) -> None: def _on_close_app_dialog_response(self, dialog, response):
"""Handle close application dialog response.""" """Handle close application dialog response."""
if response == "close": if response == "close":
# User confirmed - close the window # User confirmed - close the window
self.destroy() self.destroy()
def _setup_custom_css(self) -> None: def _setup_custom_css(self):
"""Setup custom CSS for UI styling.""" """Setup custom CSS for UI styling."""
css_provider = Gtk.CssProvider() css_provider = Gtk.CssProvider()
css_provider.load_from_data(b""" css_provider.load_from_data(b"""
/* AdwToolbarView handles header bar heights automatically */ /* AdwToolbarView handles header bar heights automatically */
/* Just add minimal custom styling for other elements */ /* Just add minimal custom styling for other elements */
/* Request tab switcher (Headers/Body/Scripts) */ /* Stack switchers styling (Headers/Body tabs) */
.request-tab-switcher button { stackswitcher button {
padding: 6px 16px; padding: 6px 16px;
min-height: 32px; min-height: 32px;
border-radius: 6px; border-radius: 6px;
margin: 0 2px; margin: 0 2px;
} }
.request-tab-switcher button:checked { stackswitcher button:checked {
font-weight: 900; background: @accent_bg_color;
color: @accent_fg_color;
font-weight: 600;
} }
/* Warning styling for undefined variables */ /* Warning styling for undefined variables */
@ -208,33 +175,6 @@ class RosterWindow(Adw.ApplicationWindow):
mix(@success_bg_color, @view_bg_color, 0.35), mix(@success_bg_color, @view_bg_color, 0.35),
mix(@success_bg_color, @view_bg_color, 0.15)); mix(@success_bg_color, @view_bg_color, 0.15));
} }
/* Sensitive variable styling */
entry.sensitive-variable {
font-family: monospace;
}
entry.sensitive-variable-name {
color: @accent_color;
font-weight: 600;
}
/* URL entry can shrink to near-zero so the Send button stays visible */
entry.url-entry {
min-width: 0;
}
/* Method chips in sidebar request list */
.method-chip {
border-radius: 4px;
padding: 1px 5px;
font-size: 0.72em;
font-weight: 700;
}
.method-chip.accent { background-color: alpha(@accent_bg_color, 0.18); }
.method-chip.success { background-color: alpha(@success_bg_color, 0.18); }
.method-chip.warning { background-color: alpha(@warning_bg_color, 0.18); }
.method-chip.error { background-color: alpha(@error_bg_color, 0.18); }
""") """)
Gtk.StyleContext.add_provider_for_display( Gtk.StyleContext.add_provider_for_display(
@ -243,12 +183,12 @@ class RosterWindow(Adw.ApplicationWindow):
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
) )
def _setup_tab_system(self) -> None: def _setup_tab_system(self):
"""Set up the tab system.""" """Set up the tab system."""
# Connect new request button # Connect new request button
self.new_request_button.connect("clicked", self._on_new_request_clicked) self.new_request_button.connect("clicked", self._on_new_request_clicked)
def _on_tab_selected(self, tab_view, param) -> None: def _on_tab_selected(self, tab_view, param):
"""Handle tab selection change.""" """Handle tab selection change."""
page = tab_view.get_selected_page() page = tab_view.get_selected_page()
if not page: if not page:
@ -259,7 +199,7 @@ class RosterWindow(Adw.ApplicationWindow):
if tab: if tab:
self.current_tab_id = tab.id self.current_tab_id = tab.id
def _on_tab_close_page(self, tab_view, page) -> bool: def _on_tab_close_page(self, tab_view, page):
"""Handle tab close request.""" """Handle tab close request."""
# Get the RequestTab and widget for this page # Get the RequestTab and widget for this page
tab = self.page_to_tab.get(page) tab = self.page_to_tab.get(page)
@ -292,7 +232,7 @@ class RosterWindow(Adw.ApplicationWindow):
remaining_tabs = len(self.page_to_tab) remaining_tabs = len(self.page_to_tab)
if remaining_tabs == 0: if remaining_tabs == 0:
# Schedule creating exactly one new tab # Schedule creating exactly one new tab
GLib.timeout_add(DELAY_TAB_CREATION_MS, self._create_new_tab_once) GLib.timeout_add(50, self._create_new_tab_once)
# Close the page # Close the page
tab_view.close_page_finish(page, True) tab_view.close_page_finish(page, True)
@ -318,7 +258,7 @@ class RosterWindow(Adw.ApplicationWindow):
return False # Allow close return False # Allow close
def _create_new_tab_once(self) -> bool: def _create_new_tab_once(self):
"""Create a new tab (one-time callback).""" """Create a new tab (one-time callback)."""
# Only create if there really are no tabs # Only create if there really are no tabs
if self.tab_view.get_n_pages() == 0: if self.tab_view.get_n_pages() == 0:
@ -326,7 +266,7 @@ class RosterWindow(Adw.ApplicationWindow):
return False # Don't repeat return False # Don't repeat
def _is_empty_new_request_tab(self) -> bool: def _is_empty_new_request_tab(self):
"""Check if current tab is an empty 'New Request' tab.""" """Check if current tab is an empty 'New Request' tab."""
if not self.current_tab_id: if not self.current_tab_id:
return False return False
@ -348,25 +288,22 @@ class RosterWindow(Adw.ApplicationWindow):
# Check if it's empty # Check if it's empty
request = widget.get_request() request = widget.get_request()
# Consider headers empty if they're either empty or only contain default headers
from .models import HttpRequest
has_only_default_headers = request.headers == HttpRequest.default_headers()
is_empty = ( is_empty = (
not request.url.strip() and not request.url.strip() and
not request.body.strip() and not request.body.strip() and
(not request.headers or has_only_default_headers) not request.headers
) )
return is_empty return is_empty
def _find_tab_by_saved_request_id(self, saved_request_id) -> Optional[RequestTab]: def _find_tab_by_saved_request_id(self, saved_request_id):
"""Find a tab by its saved_request_id. Returns None if not found.""" """Find a tab by its saved_request_id. Returns None if not found."""
for tab in self.tab_manager.tabs: for tab in self.tab_manager.tabs:
if tab.saved_request_id == saved_request_id: if tab.saved_request_id == saved_request_id:
return tab return tab
return None return None
def _generate_copy_name(self, base_name: str) -> str: def _generate_copy_name(self, base_name):
"""Generate a unique copy name like 'ReqA (copy)', 'ReqA (copy 2)', etc.""" """Generate a unique copy name like 'ReqA (copy)', 'ReqA (copy 2)', etc."""
# Check if "Name (copy)" exists # Check if "Name (copy)" exists
existing_names = {tab.name for tab in self.tab_manager.tabs} existing_names = {tab.name for tab in self.tab_manager.tabs}
@ -383,11 +320,11 @@ class RosterWindow(Adw.ApplicationWindow):
return f"{base_name} (copy {counter})" return f"{base_name} (copy {counter})"
def _create_new_tab(self, name="New Request", request=None, saved_request_id=None, response=None, def _create_new_tab(self, name="New Request", request=None, saved_request_id=None, response=None,
project_id=None, selected_environment_id=None, scripts=None) -> str: project_id=None, selected_environment_id=None, scripts=None):
"""Create a new tab with RequestTabWidget.""" """Create a new tab with RequestTabWidget."""
# Create tab in tab manager # Create tab in tab manager
if not request: if not request:
request = HttpRequest(method="GET", url="", headers=HttpRequest.default_headers(), body="", syntax="RAW") request = HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW")
tab = self.tab_manager.create_tab(name, request, saved_request_id, response, tab = self.tab_manager.create_tab(name, request, saved_request_id, response,
project_id, selected_environment_id, scripts) project_id, selected_environment_id, scripts)
@ -409,14 +346,6 @@ class RosterWindow(Adw.ApplicationWindow):
# Populate environment dropdown if project_id is set # Populate environment dropdown if project_id is set
if project_id and hasattr(widget, '_populate_environment_dropdown'): if project_id and hasattr(widget, '_populate_environment_dropdown'):
widget._populate_environment_dropdown() widget._populate_environment_dropdown()
# Re-run indicator update now that project_manager is injected;
# the earlier call in __init__ ran with project_manager=None and
# incorrectly marked all variables as undefined.
widget._update_variable_indicators()
# Apply narrow mode if sidebar is currently collapsed
if self.split_view.get_collapsed():
widget.set_narrow_mode(True)
# Connect to send button # Connect to send button
widget.send_button.connect("clicked", lambda btn: self._on_send_clicked(widget)) widget.send_button.connect("clicked", lambda btn: self._on_send_clicked(widget))
@ -465,7 +394,7 @@ class RosterWindow(Adw.ApplicationWindow):
# Remove star from title # Remove star from title
page.set_title(tab.name) page.set_title(tab.name)
def _switch_to_tab(self, tab_id: str) -> None: def _switch_to_tab(self, tab_id):
"""Switch to a tab by its ID.""" """Switch to a tab by its ID."""
# Find the page for this tab # Find the page for this tab
for page, tab in self.page_to_tab.items(): for page, tab in self.page_to_tab.items():
@ -473,34 +402,17 @@ class RosterWindow(Adw.ApplicationWindow):
self.tab_view.set_selected_page(page) self.tab_view.set_selected_page(page)
return return
def _close_current_tab(self) -> None: def _close_current_tab(self):
"""Close the currently active tab.""" """Close the currently active tab."""
page = self.tab_view.get_selected_page() page = self.tab_view.get_selected_page()
if page: if page:
self.tab_view.close_page(page) self.tab_view.close_page(page)
def _focus_url_field(self) -> None:
"""Focus the URL field in the current tab."""
page = self.tab_view.get_selected_page()
if page:
widget = self.page_to_widget.get(page)
if widget and hasattr(widget, 'url_entry'):
widget.url_entry.grab_focus()
def _send_current_request(self) -> None:
"""Send the request from the current tab."""
page = self.tab_view.get_selected_page()
if page:
widget = self.page_to_widget.get(page)
if widget and hasattr(widget, 'send_button'):
# Trigger the send button click
self._on_send_clicked(widget)
def _on_new_request_clicked(self, button): def _on_new_request_clicked(self, button):
"""Handle New Request button click.""" """Handle New Request button click."""
self._create_new_tab() self._create_new_tab()
def _create_actions(self) -> None: def _create_actions(self):
"""Create window-level actions.""" """Create window-level actions."""
# New tab shortcut (Ctrl+T) # New tab shortcut (Ctrl+T)
action = Gio.SimpleAction.new("new-tab", None) action = Gio.SimpleAction.new("new-tab", None)
@ -520,96 +432,28 @@ class RosterWindow(Adw.ApplicationWindow):
self.add_action(action) self.add_action(action)
self.get_application().set_accels_for_action("win.save-request", ["<Control>s"]) self.get_application().set_accels_for_action("win.save-request", ["<Control>s"])
# Focus URL field shortcut (Ctrl+L)
action = Gio.SimpleAction.new("focus-url", None)
action.connect("activate", lambda a, p: self._focus_url_field())
self.add_action(action)
self.get_application().set_accels_for_action("win.focus-url", ["<Control>l"])
# Send request shortcut (Ctrl+Return)
action = Gio.SimpleAction.new("send-request", None)
action.connect("activate", lambda a, p: self._send_current_request())
self.add_action(action)
self.get_application().set_accels_for_action("win.send-request", ["<Control>Return"])
action = Gio.SimpleAction.new("add-project", None)
action.connect("activate", lambda a, p: self.on_add_project_clicked(None))
self.add_action(action)
action = Gio.SimpleAction.new("import-openapi", None)
action.connect("activate", lambda a, p: self.on_import_openapi_clicked(None))
self.add_action(action)
action = Gio.SimpleAction.new("import-wsdl", None)
action.connect("activate", lambda a, p: self.on_import_wsdl_clicked(None))
self.add_action(action)
action = Gio.SimpleAction.new("import-http-file", None)
action.connect("activate", lambda a, p: self.on_import_http_file_clicked(None))
self.add_action(action)
def _on_send_clicked(self, widget): def _on_send_clicked(self, widget):
"""Handle Send button click from a tab widget.""" """Handle Send button click from a tab widget."""
# Clear previous preprocessing results
if hasattr(widget, '_clear_preprocessing_results'):
widget._clear_preprocessing_results()
# Validate URL # Validate URL
request = widget.get_request() request = widget.get_request()
if not request.url.strip(): if not request.url.strip():
self._show_toast("Please enter a URL") self._show_toast("Please enter a URL")
return return
# Execute preprocessing script (if exists) # Apply variable substitution if environment is selected
scripts = widget.get_scripts() substituted_request = request
modified_request = request if widget.selected_environment_id:
env = widget.get_selected_environment()
if scripts and scripts.preprocessing.strip(): if env:
from .script_executor import ScriptContext from .variable_substitution import VariableSubstitution
substituted_request, undefined = VariableSubstitution.substitute_request(request, env)
preprocessing_result = self._execute_preprocessing( # Log undefined variables for debugging
widget, scripts.preprocessing, request if undefined:
) print(f"Warning: Undefined variables in request: {', '.join(undefined)}")
# Display preprocessing results
if hasattr(widget, 'display_preprocessing_results'):
widget.display_preprocessing_results(preprocessing_result)
# Handle errors - DON'T send request if preprocessing failed
if not preprocessing_result.success:
self._show_toast(f"Preprocessing error: {preprocessing_result.error}")
return # Early return, don't send request
# Create context for variable updates
context = None
if widget.project_id and widget.selected_environment_id:
context = ScriptContext(
project_id=widget.project_id,
environment_id=widget.selected_environment_id,
project_manager=self.project_manager
)
# Process variable updates from preprocessing
self._process_variable_updates(preprocessing_result, widget, context)
# Use modified request (if returned)
if preprocessing_result.modified_request:
modified_request = preprocessing_result.modified_request
# Disable send button during request # Disable send button during request
widget.send_button.set_sensitive(False) widget.send_button.set_sensitive(False)
def proceed_with_request(env):
"""Continue with request after environment is loaded."""
# Apply variable substitution to (possibly modified) request
substituted_request = modified_request
if env:
from .variable_substitution import VariableSubstitution
substituted_request, undefined = VariableSubstitution.substitute_request(modified_request, env)
# Log undefined variables for debugging
if undefined:
logger.warning(f"Undefined variables in request: {', '.join(undefined)}")
# Execute async # Execute async
def callback(response, error, user_data): def callback(response, error, user_data):
"""Callback runs on main thread.""" """Callback runs on main thread."""
@ -659,39 +503,12 @@ class RosterWindow(Adw.ApplicationWindow):
if tab: if tab:
tab.response = response tab.response = response
# Create history entry with redacted sensitive variables # Create history entry (save the substituted request with actual values)
# Determine which request to save to history
request_for_history = modified_request
has_redacted = False
if env and widget.project_id:
# Get the project's sensitive variables list
projects = self.project_manager.load_projects()
sensitive_vars = []
for p in projects:
if p.id == widget.project_id:
sensitive_vars = p.sensitive_variables
break
# Create redacted request (only non-sensitive variables substituted)
if sensitive_vars:
from .variable_substitution import VariableSubstitution
request_for_history, _, has_redacted = VariableSubstitution.substitute_request_excluding_sensitive(
modified_request, env, sensitive_vars
)
else:
# No sensitive variables defined, use fully substituted request
request_for_history = substituted_request
elif env:
# Environment loaded but no project - use fully substituted request
request_for_history = substituted_request
entry = HistoryEntry( entry = HistoryEntry(
timestamp=datetime.now().isoformat(), timestamp=datetime.now().isoformat(),
request=request_for_history, request=substituted_request,
response=response, response=response,
error=error, error=error
has_redacted_variables=has_redacted
) )
self.history_manager.add_entry(entry) self.history_manager.add_entry(entry)
@ -700,14 +517,8 @@ class RosterWindow(Adw.ApplicationWindow):
self.http_client.execute_request_async(substituted_request, callback, None) self.http_client.execute_request_async(substituted_request, callback, None)
# Fetch environment asynchronously then proceed
if widget.selected_environment_id:
widget.get_selected_environment(proceed_with_request)
else:
proceed_with_request(None)
# History and Project Management # History and Project Management
def _load_history(self) -> None: def _load_history(self):
"""Load history from file and populate list.""" """Load history from file and populate list."""
# Clear existing history items # Clear existing history items
while True: while True:
@ -743,7 +554,7 @@ class RosterWindow(Adw.ApplicationWindow):
# Generate name from method and URL # Generate name from method and URL
url_parts = request.url.split('/') url_parts = request.url.split('/')
url_name = url_parts[-1] if url_parts else request.url url_name = url_parts[-1] if url_parts else request.url
name = f"{request.method} {url_name[:DISPLAY_URL_NAME_MAX_LENGTH]}" if url_name else f"{request.method} Request" name = f"{request.method} {url_name[:30]}" if url_name else f"{request.method} Request"
# Check if current tab is an empty "New Request" # Check if current tab is an empty "New Request"
if self._is_empty_new_request_tab(): if self._is_empty_new_request_tab():
@ -785,36 +596,6 @@ class RosterWindow(Adw.ApplicationWindow):
self._show_toast("Request loaded from history") self._show_toast("Request loaded from history")
def _execute_preprocessing(self, widget, script_code, request):
"""
Execute preprocessing script and return result.
Args:
widget: RequestTabWidget instance
script_code: JavaScript preprocessing script
request: HttpRequest to be preprocessed
Returns:
ScriptResult with modified request and variable updates
"""
from .script_executor import ScriptExecutor, ScriptContext
# Create context for variable access
context = None
if widget.project_id and widget.selected_environment_id:
context = ScriptContext(
project_id=widget.project_id,
environment_id=widget.selected_environment_id,
project_manager=self.project_manager
)
# Execute preprocessing
return ScriptExecutor.execute_preprocessing_script(
script_code,
request,
context
)
def _process_variable_updates(self, script_result, widget, context): def _process_variable_updates(self, script_result, widget, context):
""" """
Process variable updates from postprocessing script. Process variable updates from postprocessing script.
@ -894,114 +675,37 @@ class RosterWindow(Adw.ApplicationWindow):
vars_list = ', '.join(updated_vars) vars_list = ', '.join(updated_vars)
self._show_toast(f"Updated {len(updated_vars)} variable(s) in {env_name}: {vars_list}") self._show_toast(f"Updated {len(updated_vars)} variable(s) in {env_name}: {vars_list}")
def _show_toast(self, message: str) -> None: def _show_toast(self, message):
"""Show a toast notification.""" """Show a toast notification."""
toast = Adw.Toast() toast = Adw.Toast()
toast.set_title(message) toast.set_title(message)
toast.set_timeout(UI_TOAST_TIMEOUT_SECONDS) toast.set_timeout(3)
self.toast_overlay.add_toast(toast)
# Get the toast overlay (we need to add one)
# For now, just print to console
print(f"Toast: {message}")
# Project Management Methods # Project Management Methods
def _validate_project_name(self, name: str) -> tuple[bool, str]: def _load_projects(self):
"""
Validate project name.
Args:
name: The project name to validate
Returns:
Tuple of (is_valid, error_message)
"""
# Check if empty
if not name or not name.strip():
return False, "Project name cannot be empty"
# Check length
if len(name) > PROJECT_NAME_MAX_LENGTH:
return False, f"Project name is too long (max {PROJECT_NAME_MAX_LENGTH} characters)"
# Check for invalid characters (file system unsafe characters)
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0']
for char in invalid_chars:
if char in name:
return False, f"Project name cannot contain '{char}'"
# Check if it's only whitespace
if name.strip() == '':
return False, "Project name cannot be only whitespace"
return True, ""
def _validate_request_name(self, name: str) -> tuple[bool, str]:
"""
Validate request name.
Args:
name: The request name to validate
Returns:
Tuple of (is_valid, error_message)
"""
# Check if empty
if not name or not name.strip():
return False, "Request name cannot be empty"
# Check length
if len(name) > REQUEST_NAME_MAX_LENGTH:
return False, f"Request name is too long (max {REQUEST_NAME_MAX_LENGTH} characters)"
# Check for invalid characters (file system unsafe characters)
invalid_chars = ['\\', '*', '?', '"', '<', '>', '|', '\0']
for char in invalid_chars:
if char in name:
return False, f"Request name cannot contain '{char}'"
return True, ""
def _load_projects(self) -> None:
"""Load and display projects.""" """Load and display projects."""
# Remember which projects are currently expanded.
# GtkListBox wraps each child in a GtkListBoxRow, so we call get_child()
# on the row to reach the actual ProjectItem widget.
expanded_ids = set()
row = self.projects_listbox.get_first_child()
while row:
project_item = row.get_child()
if isinstance(project_item, ProjectItem) and project_item.expanded:
expanded_ids.add(project_item.project.id)
row = row.get_next_sibling()
# Clear existing # Clear existing
while child := self.projects_listbox.get_first_child(): while child := self.projects_listbox.get_first_child():
self.projects_listbox.remove(child) self.projects_listbox.remove(child)
# Load and populate # Load and populate
projects = self.project_manager.load_projects() projects = self.project_manager.load_projects()
total = len(projects) for project in projects:
for i, project in enumerate(projects): item = ProjectItem(project)
other_projects = [p for p in projects if p.id != project.id]
item = ProjectItem(project, other_projects)
item.set_can_move_up(i > 0)
item.set_can_move_down(i < total - 1)
item.connect('edit-requested', self._on_project_edit, project) item.connect('edit-requested', self._on_project_edit, project)
item.connect('delete-requested', self._on_project_delete, project) item.connect('delete-requested', self._on_project_delete, project)
item.connect('add-request-requested', self._on_add_to_project, project) item.connect('add-request-requested', self._on_add_to_project, project)
item.connect('request-load-requested', self._on_load_request) item.connect('request-load-requested', self._on_load_request)
item.connect('request-delete-requested', self._on_delete_request, project) item.connect('request-delete-requested', self._on_delete_request, project)
item.connect('manage-environments-requested', self._on_manage_environments, project) item.connect('manage-environments-requested', self._on_manage_environments, project)
item.connect('move-up-requested', self._on_project_move_up, project)
item.connect('move-down-requested', self._on_project_move_down, project)
item.connect('request-move-up-requested', self._on_request_move_up, project)
item.connect('request-move-down-requested', self._on_request_move_down, project)
item.connect('request-move-to-project-requested', self._on_request_move_to_project, project)
if project.id in expanded_ids:
item.expanded = True
item.requests_revealer.set_transition_duration(0)
item.requests_revealer.set_reveal_child(True)
GLib.idle_add(lambda r=item.requests_revealer: (r.set_transition_duration(250), False)[1])
self.projects_listbox.append(item) self.projects_listbox.append(item)
@Gtk.Template.Callback()
def on_add_project_clicked(self, button): def on_add_project_clicked(self, button):
"""Show dialog to create project.""" """Show dialog to create project."""
dialog = Adw.AlertDialog() dialog = Adw.AlertDialog()
@ -1021,13 +725,9 @@ class RosterWindow(Adw.ApplicationWindow):
def on_response(dlg, response): def on_response(dlg, response):
if response == "create": if response == "create":
name = entry.get_text().strip() name = entry.get_text().strip()
is_valid, error_msg = self._validate_project_name(name) if name:
if is_valid:
self.project_manager.add_project(name) self.project_manager.add_project(name)
self._load_projects() self._load_projects()
self._show_toast(f"Project '{name}' created")
else:
self._show_toast(error_msg)
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) dialog.present(self)
@ -1077,13 +777,9 @@ class RosterWindow(Adw.ApplicationWindow):
if response == "save": if response == "save":
new_name = entry.get_text().strip() new_name = entry.get_text().strip()
new_icon = icon_button.selected_icon new_icon = icon_button.selected_icon
is_valid, error_msg = self._validate_project_name(new_name) if new_name:
if is_valid:
self.project_manager.update_project(project.id, new_name, new_icon) self.project_manager.update_project(project.id, new_name, new_icon)
self._load_projects() self._load_projects()
self._show_toast(f"Project updated")
else:
self._show_toast(error_msg)
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) dialog.present(self)
@ -1109,8 +805,7 @@ class RosterWindow(Adw.ApplicationWindow):
def _on_manage_environments(self, widget, project): def _on_manage_environments(self, widget, project):
"""Show environments management dialog.""" """Show environments management dialog."""
# Get latest project data (includes variables created by scripts) # Reload project from disk to get latest data (including variables created by scripts)
# Note: load_projects() uses cached data that's kept up-to-date by save operations
projects = self.project_manager.load_projects() projects = self.project_manager.load_projects()
fresh_project = None fresh_project = None
for p in projects: for p in projects:
@ -1129,41 +824,10 @@ class RosterWindow(Adw.ApplicationWindow):
def on_environments_updated(dlg): def on_environments_updated(dlg):
# Reload projects to reflect changes # Reload projects to reflect changes
self._load_projects() self._load_projects()
# Refresh environment dropdowns in all open tabs for this project
for page, tab_widget in self.page_to_widget.items():
if tab_widget.project_id == project.id:
tab_widget._populate_environment_dropdown()
dialog.connect('environments-updated', on_environments_updated) dialog.connect('environments-updated', on_environments_updated)
dialog.present(self) dialog.present(self)
def _on_project_move_up(self, widget, project):
self.project_manager.reorder_project(project.id, -1)
self._load_projects()
def _on_project_move_down(self, widget, project):
self.project_manager.reorder_project(project.id, +1)
self._load_projects()
def _on_request_move_up(self, widget, saved_request, project):
self.project_manager.reorder_request(project.id, saved_request.id, -1)
self._load_projects()
def _on_request_move_down(self, widget, saved_request, project):
self.project_manager.reorder_request(project.id, saved_request.id, +1)
self._load_projects()
def _on_request_move_to_project(self, widget, saved_request, target_project_id, source_project):
self.project_manager.move_request_to_project(
saved_request.id, source_project.id, target_project_id
)
self._load_projects()
# Find target project name for toast
projects = self.project_manager.load_projects()
target = next((p for p in projects if p.id == target_project_id), None)
if target:
self._show_toast(f"Moved to '{target.name}'")
@Gtk.Template.Callback() @Gtk.Template.Callback()
def on_save_request_clicked(self, button): def on_save_request_clicked(self, button):
"""Save current request to a project.""" """Save current request to a project."""
@ -1202,7 +866,7 @@ class RosterWindow(Adw.ApplicationWindow):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
# Check if current tab has a saved request or project (for pre-filling) # Check if current tab has a saved request (for pre-filling)
current_tab = None current_tab = None
preselect_project_index = 0 preselect_project_index = 0
prefill_name = "" prefill_name = ""
@ -1217,13 +881,7 @@ class RosterWindow(Adw.ApplicationWindow):
preselect_project_index = i preselect_project_index = i
prefill_name = current_tab.name prefill_name = current_tab.name
break break
elif current_tab.project_id: elif current_tab.name and current_tab.name != "New Request":
# Tab associated with a project (e.g., from "Add Request")
for i, p in enumerate(projects):
if p.id == current_tab.project_id:
preselect_project_index = i
break
if current_tab.name and current_tab.name != "New Request":
# Copy tab or named unsaved tab - prefill with tab name # Copy tab or named unsaved tab - prefill with tab name
prefill_name = current_tab.name prefill_name = current_tab.name
@ -1253,8 +911,7 @@ class RosterWindow(Adw.ApplicationWindow):
def on_response(dlg, response): def on_response(dlg, response):
if response == "save": if response == "save":
name = entry.get_text().strip() name = entry.get_text().strip()
is_valid, error_msg = self._validate_request_name(name) if name:
if is_valid:
selected = dropdown.get_selected() selected = dropdown.get_selected()
project = projects[selected] project = projects[selected]
# Check for duplicate name # Check for duplicate name
@ -1270,57 +927,10 @@ class RosterWindow(Adw.ApplicationWindow):
# Clear modified flag on current tab # Clear modified flag on current tab
self._mark_tab_as_saved(saved_request.id, name, request, scripts) self._mark_tab_as_saved(saved_request.id, name, request, scripts)
else:
self._show_toast(error_msg)
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) dialog.present(self)
@Gtk.Template.Callback()
def on_export_request_clicked(self, button):
"""Handle Export button click - export request as cURL command."""
# Get current tab
page = self.tab_view.get_selected_page()
if not page:
self._show_toast("No active request")
return
widget = self.page_to_widget.get(page)
if not widget:
self._show_toast("No active request")
return
# Get request from widget (raw with {{variables}})
request = widget.get_request()
# Validate URL
if not request.url.strip():
self._show_toast("Cannot export: URL is empty")
return
# Apply variable substitution if environment is selected
def show_export(env):
substituted_request = request
if env:
from .variable_substitution import VariableSubstitution
substituted_request, undefined = VariableSubstitution.substitute_request(request, env)
# Warn about undefined variables
if undefined:
logger.warning(f"Undefined variables in export: {', '.join(undefined)}")
# Show warning toast but continue with export
self._show_toast(f"Warning: {len(undefined)} undefined variable(s)")
# Show export dialog with substituted request
from .export_dialog import ExportDialog
dialog = ExportDialog(substituted_request)
dialog.present(self)
if widget.selected_environment_id:
widget.get_selected_environment(show_export)
else:
show_export(None)
def _mark_tab_as_saved(self, saved_request_id, name, request, scripts=None): def _mark_tab_as_saved(self, saved_request_id, name, request, scripts=None):
"""Mark the current tab as saved (clear modified flag).""" """Mark the current tab as saved (clear modified flag)."""
page = self.tab_view.get_selected_page() page = self.tab_view.get_selected_page()
@ -1391,18 +1001,45 @@ class RosterWindow(Adw.ApplicationWindow):
dialog.present(self) dialog.present(self)
def _on_add_to_project(self, widget, project): def _on_add_to_project(self, widget, project):
"""Create a new empty request tab associated with the project.""" """Save current request to specific project."""
# Get default environment for the project request = self._build_request_from_ui()
default_env_id = project.environments[0].id if project.environments else None
# Create a new tab with project_id set so Save will pre-select this project if not request.url.strip():
self._create_new_tab( self._show_toast("Cannot save: URL is empty")
name="New Request", return
project_id=project.id,
selected_environment_id=default_env_id
)
self._show_toast(f"New request for '{project.name}'") dialog = Adw.AlertDialog()
dialog.set_heading(f"Save to {project.name}")
dialog.set_body("Enter a name for this request:")
entry = Gtk.Entry()
entry.set_placeholder_text("Request name")
dialog.set_extra_child(entry)
dialog.add_response("cancel", "Cancel")
dialog.add_response("save", "Save")
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
def on_response(dlg, response):
if response == "save":
name = entry.get_text().strip()
if name:
# Check for duplicate name
existing = self.project_manager.find_request_by_name(project.id, name)
if existing:
# Show overwrite confirmation
self._show_overwrite_dialog(project, name, existing.id, request)
else:
# No duplicate, save normally
saved_request = self.project_manager.add_request(project.id, name, request)
self._load_projects()
self._show_toast(f"Saved as '{name}'")
# Clear modified flag on current tab
self._mark_tab_as_saved(saved_request.id, name, request)
dialog.connect("response", on_response)
dialog.present(self)
def _on_load_request(self, widget, saved_request): def _on_load_request(self, widget, saved_request):
"""Load saved request - smart loading based on current tab state.""" """Load saved request - smart loading based on current tab state."""
@ -1518,57 +1155,6 @@ class RosterWindow(Adw.ApplicationWindow):
widget.original_request = None widget.original_request = None
widget.modified = True widget.modified = True
def on_import_http_file_clicked(self, button):
"""Open the .http file import dialog."""
projects = self.project_manager.load_projects()
dialog = HttpFileImportDialog(self.project_manager, projects)
dialog.connect('import-completed', self._on_http_file_import_completed)
dialog.present(self)
def _on_http_file_import_completed(self, dialog, project_id: str):
"""Refresh sidebar after a successful .http file import."""
self._load_projects()
projects = self.project_manager.load_projects()
for p in projects:
if p.id == project_id:
self._show_toast(f"Imported {len(p.requests)} request(s) into '{p.name}'")
break
def on_import_wsdl_clicked(self, button):
"""Open the WSDL import dialog."""
projects = self.project_manager.load_projects()
dialog = WsdlImportDialog(self.project_manager, projects)
dialog.connect('import-completed', self._on_wsdl_import_completed)
dialog.present(self)
def on_import_openapi_clicked(self, button):
"""Open the OpenAPI import dialog."""
projects = self.project_manager.load_projects()
dialog = OpenApiImportDialog(self.project_manager, projects)
dialog.connect('import-completed', self._on_openapi_import_completed)
dialog.present(self)
def _on_openapi_import_completed(self, dialog, project_id: str):
"""Refresh sidebar after a successful OpenAPI import."""
self._load_projects()
projects = self.project_manager.load_projects()
for p in projects:
if p.id == project_id:
self._show_toast(f"Imported {len(p.requests)} operation(s) into '{p.name}'")
break
def _on_wsdl_import_completed(self, dialog, project_id: str):
"""Refresh sidebar after a successful WSDL import."""
self._load_projects()
projects = self.project_manager.load_projects()
count = 0
for p in projects:
if p.id == project_id:
count = len(p.requests)
name = p.name
break
self._show_toast(f"Imported {count} operation(s) into '{name}'")
def _on_delete_request(self, widget, saved_request, project): def _on_delete_request(self, widget, saved_request, project):
"""Delete saved request with confirmation.""" """Delete saved request with confirmation."""
dialog = Adw.AlertDialog() dialog = Adw.AlertDialog()

View File

@ -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")

View File

@ -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>'
)

Binary file not shown.