Compare commits
No commits in common. "master" and "v0.2.0" have entirely different histories.
18
CLAUDE.md
@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
Roster is a GNOME application written in Python using GTK 4 and libadwaita. The project follows GNOME application conventions and uses the Meson build system.
|
||||
|
||||
- **Application ID**: `cz.bugsy.roster`
|
||||
- **Application ID**: `cz.vesp.roster`
|
||||
- **Build System**: Meson
|
||||
- **Runtime**: GNOME Platform (org.gnome.Platform)
|
||||
- **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
|
||||
|
||||
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
|
||||
# Build with GNOME Builder (recommended for GNOME apps)
|
||||
# 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
|
||||
@ -83,10 +83,10 @@ src/
|
||||
roster.gresource.xml # GResource bundle definition
|
||||
|
||||
data/
|
||||
cz.bugsy.roster.desktop.in # Desktop entry (i18n template)
|
||||
cz.bugsy.roster.metainfo.xml.in # AppStream metadata (i18n template)
|
||||
cz.bugsy.roster.gschema.xml # GSettings schema
|
||||
cz.bugsy.roster.service.in # D-Bus service file (configured by Meson)
|
||||
cz.vesp.roster.desktop.in # Desktop entry (i18n template)
|
||||
cz.vesp.roster.metainfo.xml.in # AppStream metadata (i18n template)
|
||||
cz.vesp.roster.gschema.xml # GSettings schema
|
||||
cz.vesp.roster.service.in # D-Bus service file (configured by Meson)
|
||||
icons/ # Application icons
|
||||
|
||||
po/
|
||||
@ -116,10 +116,10 @@ When adding new `.py` files to `src/`:
|
||||
When adding new `.ui` files:
|
||||
1. Place in `src/` directory
|
||||
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
|
||||
|
||||
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`
|
||||
- Compiled automatically during installation via `gnome.post_install()`
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
Application Icons License
|
||||
=========================
|
||||
|
||||
The application icons in this repository are licensed under the Creative Commons
|
||||
Attribution-ShareAlike 3.0 International License (CC BY-SA 3.0).
|
||||
|
||||
Copyright (c) 2025 Pavel Baksy
|
||||
|
||||
You are free to:
|
||||
- Share — copy and redistribute the material in any medium or format
|
||||
- Adapt — remix, transform, and build upon the material for any purpose,
|
||||
even commercially
|
||||
|
||||
Under the following terms:
|
||||
- Attribution — You must give appropriate credit, provide a link to the
|
||||
license, and indicate if changes were made. You may do so in any reasonable
|
||||
manner, but not in any way that suggests the licensor endorses you or your use.
|
||||
|
||||
- ShareAlike — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the same license as the original.
|
||||
|
||||
- No additional restrictions — You may not apply legal terms or technological
|
||||
measures that legally restrict others from doing anything the license permits.
|
||||
|
||||
Icons covered by this license:
|
||||
- data/icons/hicolor/scalable/apps/cz.bugsy.roster.svg
|
||||
- data/icons/hicolor/symbolic/apps/cz.bugsy.roster-symbolic.svg
|
||||
|
||||
To view the full license, visit:
|
||||
https://creativecommons.org/licenses/by-sa/3.0/legalcode
|
||||
|
||||
SPDX-License-Identifier: CC-BY-SA-3.0
|
||||
@ -1,20 +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.
|
||||
|
||||
All licenses are compatible with GPL-3.0-or-later.
|
||||
110
README.md
@ -6,115 +6,27 @@ A modern HTTP client for GNOME, built with GTK 4 and libadwaita.
|
||||
|
||||
- Send HTTP requests (GET, POST, PUT, DELETE)
|
||||
- Configure custom headers and request bodies
|
||||
- View response headers and bodies with syntax highlighting
|
||||
- View response headers and bodies
|
||||
- Track request history with persistence
|
||||
- Organize requests into projects
|
||||
- Environment variables with secure credential storage
|
||||
- JavaScript preprocessing and postprocessing scripts
|
||||
- Export requests (cURL and more)
|
||||
- GNOME-native UI
|
||||
- Beautiful GNOME-native UI
|
||||
|
||||
## Screenshots
|
||||
## Dependencies
|
||||
|
||||
### Main Interface
|
||||

|
||||
- GTK 4
|
||||
- libadwaita 1
|
||||
- Python 3
|
||||
- libsoup3 (provided by GNOME Platform)
|
||||
|
||||
### Request History
|
||||

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

|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
**Build from source:**
|
||||
```bash
|
||||
git clone https://git.bugsy.cz/beval/roster.git
|
||||
cd roster
|
||||
meson setup builddir
|
||||
meson compile -C builddir
|
||||
sudo meson install -C builddir
|
||||
```
|
||||
|
||||
**Flatpak:**
|
||||
```bash
|
||||
flatpak-builder --user --install --force-clean build-dir cz.bugsy.roster.json
|
||||
flatpak run cz.bugsy.roster
|
||||
```
|
||||
## Usage
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
### 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.
|
||||
Run Roster from your application menu or with the `roster` command.
|
||||
|
||||
@ -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.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"id" : "cz.bugsy.roster",
|
||||
"id" : "cz.vesp.roster",
|
||||
"runtime" : "org.gnome.Platform",
|
||||
"runtime-version" : "49",
|
||||
"sdk" : "org.gnome.Sdk",
|
||||
@ -1,11 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Name=Roster
|
||||
Comment=HTTP client for API testing
|
||||
Exec=roster
|
||||
Icon=cz.bugsy.roster
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Development;Network;
|
||||
Keywords=HTTP;REST;API;Client;Request;JSON;
|
||||
StartupNotify=true
|
||||
DBusActivatable=true
|
||||
@ -1,115 +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>
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release version="0.8.1" date="2026-01-15">
|
||||
<description translate="no">
|
||||
<p>Version 0.8.1 release</p>
|
||||
<ul>
|
||||
<li>Remove license note from app description</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.8.0" date="2026-01-15">
|
||||
<description translate="no">
|
||||
<p>Version 0.8.0 release</p>
|
||||
<ul>
|
||||
<li>Shorten User-Agent version to major.minor format</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.7.0" date="2026-01-14">
|
||||
<description translate="no">
|
||||
<p>Version 0.7.0 release</p>
|
||||
<ul>
|
||||
<li>Prepare for Flathub submission</li>
|
||||
<li>Add real screenshots and release notes</li>
|
||||
<li>Add Request creates new tab for project</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.6.0" date="2026-01-10">
|
||||
<description translate="no">
|
||||
<p>Version 0.6.0 release</p>
|
||||
<ul>
|
||||
<li>Added keyboard shortcuts: Ctrl+L (focus URL), Ctrl+Return (send request)</li>
|
||||
<li>Translation improvements</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.5.0" date="2026-01-08">
|
||||
<description translate="no">
|
||||
<p>Version 0.5.0 release</p>
|
||||
<ul>
|
||||
<li>Authentication improvements</li>
|
||||
<li>Bug fixes and stability improvements</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.4.0" date="2026-01-05">
|
||||
<description translate="no">
|
||||
<p>Version 0.4.0 release</p>
|
||||
<ul>
|
||||
<li>Improved sidebar layout with reduced indentation</li>
|
||||
<li>Enhanced panel resizing capabilities</li>
|
||||
<li>Better project folder state management</li>
|
||||
<li>Status icons fixes in panels</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.2.0" date="2025-12-30">
|
||||
<description translate="no">
|
||||
<p>Version 0.2.0 release</p>
|
||||
<ul>
|
||||
<li>Environment variable support improvements</li>
|
||||
<li>UI refinements and bug fixes</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
|
||||
</component>
|
||||
10
data/cz.vesp.roster.desktop.in
Normal file
@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Name=roster
|
||||
Exec=roster
|
||||
Icon=cz.vesp.roster
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Utility;
|
||||
Keywords=GTK;
|
||||
StartupNotify=true
|
||||
DBusActivatable=true
|
||||
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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">
|
||||
<default>true</default>
|
||||
<summary>Force TLS verification</summary>
|
||||
82
data/cz.vesp.roster.metainfo.xml.in
Normal 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>
|
||||
@ -1,3 +1,3 @@
|
||||
[D-BUS Service]
|
||||
Name=cz.bugsy.roster
|
||||
Name=cz.vesp.roster
|
||||
Exec=@bindir@/roster --gapplication-service
|
||||
@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<linearGradient id="a" gradientUnits="userSpaceOnUse" spreadMethod="reflect" x1="63.93500143502" x2="116.66087219391" y1="114.25781884399" y2="113.87787042471">
|
||||
<stop offset="0" stop-color="#567cb9"/>
|
||||
<stop offset="0.691254" stop-color="#567cb9" stop-opacity="0.74902"/>
|
||||
<stop offset="0.738096" stop-color="#567cb9" stop-opacity="0.623529"/>
|
||||
<stop offset="0.782391" stop-color="#567cb9" stop-opacity="0.498039"/>
|
||||
<stop offset="0.821796" stop-color="#567cb9" stop-opacity="0.74902"/>
|
||||
<stop offset="0.85385" stop-color="#567cb9" stop-opacity="0.87451"/>
|
||||
<stop offset="0.955449" stop-color="#567cb9" stop-opacity="0.937255"/>
|
||||
<stop offset="1" stop-color="#567cb9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="b" gradientTransform="matrix(0.185607 0 0 0.185596 -41.369942 -39.816223)" gradientUnits="userSpaceOnUse" spreadMethod="reflect" x1="781.887085" x2="537.229431" y1="760.514282" y2="763.320618">
|
||||
<stop offset="0" stop-color="#173c7a"/>
|
||||
<stop offset="0.149359" stop-color="#5383d9"/>
|
||||
<stop offset="0.246092" stop-color="#1f4a92"/>
|
||||
<stop offset="0.337206" stop-color="#1b4386"/>
|
||||
<stop offset="1" stop-color="#173c7a"/>
|
||||
</linearGradient>
|
||||
<path d="m 94.542969 18.238281 c 1.398437 0.253907 1.480469 0.511719 1.480469 4.527344 c 0 2.933594 0.132812 4.207031 0.589843 5.683594 c 1 3.242187 3.492188 6.097656 6.773438 7.769531 c 0.667969 0.339844 2.316406 0.605469 5.035156 0.808594 c 5.144531 0.386718 5.445313 0.519531 5.742187 2.585937 c 0.128907 0.875 0.195313 16.273438 0.152344 34.210938 l -0.078125 32.613281 l -0.964843 1.960938 c -1.171876 2.382812 -3.964844 5.121093 -6.488282 6.359374 l -1.726562 0.847657 h -40.589844 l -40.589844 -0.136719 c -0.402344 0.003906 -2.464844 -0.859375 -2.464844 -0.859375 c -2.792968 -1.207031 -4.558593 -3.820313 -6.070312 -6.554687 l -1.074219 -1.949219 l 0.160157 -39.21875 v -39.242188 l 0.992187 -2.027343 c 1.871094 -3.824219 5.535156 -6.671876 9.390625 -7.304688 c 1.742188 -0.285156 68.199219 -0.355469 69.730469 -0.074219 z m 0 0" fill="url(#a)"/>
|
||||
<path d="m 94.382812 13.8125 c 1.394532 0.257812 1.480469 0.515625 1.480469 4.527344 c 0 2.9375 0.128907 4.210937 0.585938 5.683594 c 1 3.242187 3.492187 6.101562 6.773437 7.773437 c 0.667969 0.339844 2.316406 0.605469 5.035156 0.808594 c 5.148438 0.382812 5.449219 0.519531 5.746094 2.582031 c 0.125 0.878906 0.195313 16.273438 0.152344 34.210938 l -0.078125 32.617187 l -0.96875 1.960937 c -1.171875 2.382813 -3.964844 5.121094 -6.484375 6.359376 l -1.730469 0.847656 h -40.589843 l -40.71875 0.078125 c -0.941407 -0.027344 -2.34375 -0.996094 -2.34375 -0.996094 c -2.597657 -1.457031 -4.71875 -3.8125 -6.058594 -6.632813 l -0.914063 -1.925781 v -78.484375 l 0.992188 -2.027344 c 1.867187 -3.824218 5.53125 -6.675781 9.386719 -7.304687 c 1.742187 -0.285156 68.199218 -0.359375 69.734374 -0.078125 z m 0 0" fill="#96bcf9"/>
|
||||
<path d="m 12.089844 26.011719 h 79.894531 c 0.824219 0 1.492187 0.273437 1.492187 0.613281 v 51.039062 c 0 0.339844 -0.667968 0.617188 -1.492187 0.617188 h -79.894531 c -0.820313 0 -1.488282 -0.277344 -1.488282 -0.617188 v -51.039062 c 0 -0.339844 0.667969 -0.613281 1.488282 -0.613281 z m 0 0" fill="#96bcf9"/>
|
||||
<path d="m 111.046875 56.371094 c 0.945313 0.621094 10.683594 7.730468 14.746094 11.3125 c 0 0 -0.011719 1.800781 -0.011719 2.089844 c 0 0.316406 -2.621094 2.820312 -6.546875 6.25 c -9.179687 8.019531 -8.914063 7.832031 -9.84375 6.804687 c -0.40625 -0.445313 -0.488281 -1.238281 -0.488281 -4.589844 v -4.046875 h -2.804688 c -2.46875 0 -2.839844 -0.070312 -3.109375 -0.574218 c -0.167969 -0.316407 -0.304687 -2.238282 -0.304687 -4.273438 c 0 -4.363281 0.164062 -4.695312 2.335937 -4.625 c 0.75 0.027344 1.929688 0.035156 2.621094 0.019531 l 1.261719 -0.023437 v -3.722656 c 0 -4.699219 0.472656 -5.71875 2.144531 -4.621094 z m 0 0" fill="#173c7a"/>
|
||||
<path d="m 23.355469 103.21875 c -1.398438 -0.257812 -1.480469 -0.511719 -1.480469 -4.527344 c 0 -2.933594 -0.132812 -4.207031 -0.585938 -5.683594 c -1.003906 -3.242187 -3.492187 -6.101562 -6.777343 -7.773437 c -0.667969 -0.339844 -2.316407 -0.605469 -5.035157 -0.808594 c -5.144531 -0.382812 -5.445312 -0.519531 -5.742187 -2.582031 c -0.128906 -0.878906 -0.195313 -16.273438 -0.152344 -34.210938 l 0.078125 -32.617187 l 0.964844 -1.960937 c 1.171875 -2.382813 3.964844 -5.117188 6.488281 -6.355469 l 1.726563 -0.851563 h 81.1875 l 2.25 1.113282 c 2.730468 1.347656 5.996094 3.667968 6.835937 6.675781 l 0.703125 2.527343 l -0.382812 38.402344 s 1.335937 36.335938 0.039062 39.242188 l -0.992187 2.03125 c -1.867188 3.820312 -5.53125 6.671875 -9.386719 7.304687 c -1.746094 0.285157 -68.207031 0.355469 -69.738281 0.074219 z m 28.640625 -22.28125 c 1.199218 -0.730469 1.867187 -2.007812 1.867187 -3.566406 c 0 -1.574219 -0.628906 -2.75 -1.878906 -3.523438 c -0.984375 -0.609375 -1.296875 -0.621094 -15.808594 -0.621094 h -14.804687 l -1.003906 0.675782 c -1.257813 0.847656 -1.742188 1.789062 -1.746094 3.410156 c -0.003906 1.523438 0.570312 2.738281 1.691406 3.574219 c 0.804688 0.601562 1.117188 0.613281 15.777344 0.621093 c 14.324218 0.007813 15 -0.015624 15.90625 -0.570312 z m 24.613281 -20.390625 c 1.144531 -0.652344 1.835937 -2.109375 1.835937 -3.871094 c 0 -1.488281 -0.085937 -1.679687 -1.292968 -2.886719 l -1.292969 -1.292968 h -54.753906 l -1.242188 1.195312 c -0.824219 0.789063 -1.257812 1.46875 -1.277343 2 c -0.101563 2.636719 0.472656 3.964844 2.105468 4.855469 c 1 0.546875 1.929688 0.5625 28.136719 0.476563 c 21.710937 -0.070313 27.234375 -0.164063 27.78125 -0.476563 z m 3.613281 -21.28125 l 6.070313 -0.195313 l 0.9375 -0.929687 c 0.515625 -0.515625 1.089843 -1.5 1.277343 -2.195313 c 0.292969 -1.097656 0.261719 -1.421874 -0.269531 -2.515624 c -0.335937 -0.691407 -1.007812 -1.550782 -1.492187 -1.910157 l -0.882813 -0.652343 l -64.867187 0.15625 l -1.246094 1.246093 c -1.121094 1.117188 -1.246094 1.390625 -1.246094 2.671875 c 0 1.542969 0.664063 2.96875 1.703125 3.65625 c 0.515625 0.34375 3.328125 0.449219 15.292969 0.578125 c 24.835938 0.265625 38.390625 0.292969 44.722656 0.089844 z m 0 0" fill="url(#b)"/>
|
||||
<path d="m 23.847656 99.390625 c -1.398437 -0.253906 -1.480468 -0.511719 -1.480468 -4.523437 c 0 -2.9375 -0.132813 -4.210938 -0.585938 -5.683594 c -1.003906 -3.242188 -3.492188 -6.101563 -6.777344 -7.773438 c -0.667968 -0.339844 -2.316406 -0.605468 -5.03125 -0.808594 c -5.148437 -0.382812 -5.449218 -0.519531 -5.746094 -2.585937 c -0.125 -0.875 -0.195312 -16.269531 -0.152343 -34.210937 l 0.078125 -32.613282 l 0.964844 -1.960937 c 1.175781 -2.382813 3.96875 -5.121094 6.488281 -6.359375 l 1.726562 -0.847656 h 81.179688 l 2.253906 1.113281 c 2.730469 1.347656 4.941406 3.613281 6.28125 6.4375 l 0.914063 1.925781 v 39.242188 s 0.109374 38.625 0 39.242187 l -0.992188 2.027344 c -1.871094 3.824219 -5.53125 6.671875 -9.390625 7.304687 c -1.742187 0.285156 -68.199219 0.359375 -69.730469 0.074219 z m 28.640625 -22.277344 c 1.195313 -0.730469 1.863281 -2.007812 1.863281 -3.566406 c 0 -1.574219 -0.625 -2.753906 -1.878906 -3.527344 c -0.984375 -0.605469 -1.292968 -0.621093 -15.808594 -0.621093 h -14.800781 l -1.003906 0.675781 c -1.257813 0.851562 -1.742187 1.789062 -1.746094 3.414062 c -0.003906 1.519531 0.570313 2.738281 1.691407 3.570313 c 0.804687 0.601562 1.117187 0.617187 15.777343 0.625 c 14.320313 0.003906 14.996094 -0.019532 15.90625 -0.570313 z m 24.609375 -20.394531 c 1.144532 -0.648438 1.835938 -2.105469 1.835938 -3.867188 c 0 -1.488281 -0.085938 -1.679687 -1.292969 -2.890624 l -1.292969 -1.292969 h -54.75 l -1.246094 1.195312 c -0.820312 0.792969 -1.253906 1.46875 -1.273437 2 c -0.101563 2.636719 0.472656 3.964844 2.105469 4.855469 c 1 0.546875 1.929687 0.5625 28.132812 0.476562 c 21.710938 -0.070312 27.234375 -0.164062 27.78125 -0.476562 z m 3.613282 -21.277344 l 6.070312 -0.195312 l 0.9375 -0.933594 c 0.515625 -0.511719 1.089844 -1.5 1.277344 -2.191406 c 0.292968 -1.101563 0.261718 -1.421875 -0.269532 -2.519532 c -0.335937 -0.691406 -1.007812 -1.550781 -1.492187 -1.90625 l -0.882813 -0.65625 l -32.433593 0.078126 l -32.429688 0.078124 l -1.246093 1.246094 c -1.121094 1.121094 -1.246094 1.394532 -1.246094 2.671875 c 0 1.542969 0.664062 2.96875 1.703125 3.660157 c 0.515625 0.34375 3.328125 0.449218 15.292969 0.578124 c 24.832031 0.265626 38.386718 0.292969 44.71875 0.089844 z m 0 0" fill="#2e7af4"/>
|
||||
<path d="m 111.0625 54.285156 c 0.945312 0.617188 10.394531 8.785156 12.878906 11.125 c 1.019532 0.964844 1.855469 1.988282 1.855469 2.277344 c 0 0.3125 -2.625 2.816406 -6.546875 6.246094 c -9.179688 8.019531 -8.914062 7.835937 -9.84375 6.808594 c -0.40625 -0.449219 -0.492188 -1.238282 -0.492188 -4.589844 v -4.050782 h -2.800781 c -2.46875 0 -2.839843 -0.066406 -3.109375 -0.570312 c -0.167968 -0.316406 -0.308594 -2.238281 -0.308594 -4.273438 c 0 -4.367187 0.167969 -4.699218 2.335938 -4.625 c 0.753906 0.023438 1.933594 0.03125 2.625 0.019532 l 1.257812 -0.027344 v -3.722656 c 0 -4.699219 0.476563 -5.71875 2.148438 -4.617188 z m 0 0" fill="#2e7af4"/>
|
||||
<path d="m 98.59375 70.117188 c 0.375 0.375 0.464844 1.210937 0.464844 4.296874 v 3.828126 h 2.753906 c 1.75 0 1.835938 0.121093 1.855469 0.4375 c 0.046875 0.773437 0.410156 7.398437 0.332031 8.183593 c -0.015625 0.160157 -1.058594 0.332031 -2.1875 0.484375 l -2.753906 0.371094 v 3.964844 c 0 3.441406 -0.074219 4.058594 -0.554688 4.65625 c -0.746094 0.917968 -1.207031 0.683594 -4.816406 -2.445313 c -1.566406 -1.359375 -4.605469 -3.992187 -6.75 -5.847656 c -4.023438 -3.484375 -4.914062 -4.570313 -4.4375 -5.417969 c 0.25 -0.445312 10.828125 -9.90625 13.441406 -12.023437 c 1.3125 -1.058594 1.960938 -1.179688 2.652344 -0.488281 z m 0 0" fill="#173c7a"/>
|
||||
<path d="m 98.734375 68.582031 c 0.375 0.375 0.464844 1.207031 0.464844 4.292969 v 3.832031 h 2.757812 c 1.746094 0 3.996094 0.128907 4.222657 0.351563 c 0.492187 0.496094 0.492187 8.273437 0 8.769531 c -0.226563 0.222656 -2.476563 0.355469 -4.222657 0.355469 h -2.757812 v 3.964844 c 0 3.441406 -0.070313 4.054687 -0.554688 4.652343 c -0.746093 0.917969 -1.203125 0.6875 -4.8125 -2.441406 c -1.570312 -1.363281 -4.609375 -3.992187 -6.753906 -5.851563 c -4.023437 -3.480468 -4.914063 -4.566406 -4.4375 -5.414062 c 0.25 -0.449219 10.828125 -9.910156 13.445313 -12.023438 c 1.308593 -1.0625 1.957031 -1.179687 2.648437 -0.488281 z m 0 0" fill="#96bcf9"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 10 KiB |
130
data/icons/hicolor/scalable/apps/cz.vesp.roster.svg
Normal file
@ -0,0 +1,130 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="33.750061mm"
|
||||
height="33.750061mm"
|
||||
viewBox="0 0 33.750061 33.750061"
|
||||
version="1.1"
|
||||
id="svg974">
|
||||
<defs
|
||||
id="defs968">
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath18689">
|
||||
<rect
|
||||
clip-path="none"
|
||||
transform="rotate(45)"
|
||||
ry="32.000008"
|
||||
rx="32.000008"
|
||||
y="123.9986"
|
||||
x="486.03726"
|
||||
height="362.94299"
|
||||
width="362.94299"
|
||||
id="rect18691"
|
||||
style="display:inline;opacity:1;vector-effect:none;fill:#4a86cf;fill-opacity:1;stroke:none;stroke-width:26.0669;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath18689-3">
|
||||
<rect
|
||||
clip-path="none"
|
||||
transform="rotate(45)"
|
||||
ry="32.000008"
|
||||
rx="32.000008"
|
||||
y="123.9986"
|
||||
x="486.03726"
|
||||
height="362.94299"
|
||||
width="362.94299"
|
||||
id="rect18691-6"
|
||||
style="display:inline;opacity:1;vector-effect:none;fill:#4a86cf;fill-opacity:1;stroke:none;stroke-width:26.0669;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<metadata
|
||||
id="metadata971">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-61.819823,-103.94395)">
|
||||
<g
|
||||
transform="matrix(0.26367235,0,0,0.26367235,61.819823,-529.39703)"
|
||||
style="display:inline;stroke-width:0.25;enable-background:new"
|
||||
id="g1836">
|
||||
<title
|
||||
id="title1838">application-x-executable</title>
|
||||
<g
|
||||
transform="matrix(0.25,0,0,0.25,0,2295)"
|
||||
id="g18818"
|
||||
style="stroke-width:0.25">
|
||||
<g
|
||||
style="stroke-width:0.269963"
|
||||
transform="matrix(0.92605186,0,0,0.92605186,18.930729,50.876335)"
|
||||
id="g18590">
|
||||
<g
|
||||
style="stroke-width:0.269963"
|
||||
id="g18681"
|
||||
clip-path="url(#clipPath18689-3)">
|
||||
<rect
|
||||
style="opacity:1;vector-effect:none;fill:#3584e4;fill-opacity:1;stroke:none;stroke-width:8.22095;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||
id="rect18571"
|
||||
width="424"
|
||||
height="424"
|
||||
x="458.33722"
|
||||
y="90.641701"
|
||||
rx="10.092117"
|
||||
ry="10.092117"
|
||||
transform="matrix(0.60528171,0.60528171,-0.60528171,0.60528171,33.440632,99.073632)"
|
||||
clip-path="none" />
|
||||
<circle
|
||||
style="opacity:1;vector-effect:none;fill:#f66151;fill-opacity:1;stroke:none;stroke-width:7.03712;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||
id="path18706"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="0"
|
||||
transform="translate(0,-212)" />
|
||||
<circle
|
||||
style="opacity:1;vector-effect:none;fill:#f66151;fill-opacity:1;stroke:none;stroke-width:7.03712;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||
id="path18708"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="0"
|
||||
transform="translate(0,-212)" />
|
||||
<path
|
||||
style="display:inline;opacity:1;vector-effect:none;fill:#98c1f1;fill-opacity:1;stroke:none;stroke-width:7.03712;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"
|
||||
d="m 408.91993,561.9183 -9.8861,29.82892 a 172.97099,172.97099 0 0 0 -1.42693,-0.0713 172.97099,172.97099 0 0 0 -23.92891,1.85189 l -13.80082,-28.50125 a 203.29325,203.29325 0 0 0 -29.40085,7.97217 l 2.28619,31.40474 a 172.97099,172.97099 0 0 0 -22.73152,11.31923 l -23.71796,-21.103 a 203.29325,203.29325 0 0 0 -24.05918,18.6741 l 14.09863,28.07319 a 172.97099,172.97099 0 0 0 -16.63608,19.21074 l -30.05845,-10.44758 a 203.29325,203.29325 0 0 0 -15.01683,26.48807 l 23.73035,20.50738 a 172.97099,172.97099 0 0 0 -7.98456,24.14293 l -31.73044,1.84879 a 203.29325,203.29325 0 0 0 -3.77825,30.21664 l 29.82892,9.8861 a 172.97099,172.97099 0 0 0 -0.0713,1.42693 172.97099,172.97099 0 0 0 1.85188,23.92889 l -28.50125,13.80084 a 203.29325,203.29325 0 0 0 7.97215,29.40084 l 31.40475,-2.28619 a 172.97099,172.97099 0 0 0 11.31922,22.73152 l -21.10296,23.71797 a 203.29325,203.29325 0 0 0 18.67409,24.05918 l 28.07319,-14.09863 a 172.97099,172.97099 0 0 0 19.21074,16.63606 l -10.44758,30.05847 a 203.29325,203.29325 0 0 0 26.48806,15.01683 l 20.50739,-23.73036 a 172.97099,172.97099 0 0 0 24.14293,7.98457 l 1.8488,31.73043 a 203.29325,203.29325 0 0 0 30.21666,3.77826 l 9.8861,-29.82892 a 172.97099,172.97099 0 0 0 1.42693,0.0713 172.97099,172.97099 0 0 0 23.9289,-1.85188 l 13.80084,28.50125 a 203.29325,203.29325 0 0 0 29.40084,-7.97217 l -2.2862,-31.40474 a 172.97099,172.97099 0 0 0 22.73153,-11.31922 l 23.71796,21.10297 A 203.29325,203.29325 0 0 0 532.96,916.00016 l -14.09864,-28.07319 a 172.97099,172.97099 0 0 0 16.63607,-19.21073 l 30.05846,10.44757 a 203.29325,203.29325 0 0 0 15.01683,-26.48807 l -23.73036,-20.50738 a 172.97099,172.97099 0 0 0 7.98457,-24.14293 l 31.73044,-1.84879 a 203.29325,203.29325 0 0 0 3.77825,-30.21667 l -29.82892,-9.8861 a 172.97099,172.97099 0 0 0 0.0713,-1.42692 172.97099,172.97099 0 0 0 -1.85189,-23.9289 l 28.50124,-13.80084 a 203.29325,203.29325 0 0 0 -7.97215,-29.40084 l -31.40474,2.2862 a 172.97099,172.97099 0 0 0 -11.31923,-22.73153 l 21.10297,-23.71797 a 203.29325,203.29325 0 0 0 -18.67409,-24.05918 l -28.07319,14.09863 a 172.97099,172.97099 0 0 0 -19.21074,-16.63606 l 10.44757,-30.05847 A 203.29325,203.29325 0 0 0 485.6357,581.68117 l -20.50738,23.73035 a 172.97099,172.97099 0 0 0 -24.14293,-7.98455 l -1.84879,-31.73044 a 203.29325,203.29325 0 0 0 -30.21667,-3.77826 z M 397.6069,637.72208 A 126.92605,126.92605 0 0 1 524.5318,764.64699 126.92605,126.92605 0 0 1 397.6069,891.57189 126.92605,126.92605 0 0 1 270.682,764.64699 126.92605,126.92605 0 0 1 397.6069,637.72208 Z"
|
||||
id="path18717-4" />
|
||||
<path
|
||||
id="path18758"
|
||||
d="m 51.748325,401.28402 -9.8861,29.82892 c -0.475543,-0.0257 -0.951191,-0.0495 -1.42693,-0.0713 -8.00956,0.0625 -16.005106,0.6813 -23.92891,1.85189 L 2.7055639,404.39228 c -9.9858697,1.91835 -19.8137359,4.58322 -29.4008489,7.97217 l 2.28619,31.40474 c -7.844275,3.21103 -15.441918,6.9943 -22.73152,11.31923 l -23.71796,-21.103 c -8.475372,5.61437 -16.517661,11.85658 -24.05918,18.6741 l 14.09863,28.07319 c -6.008901,5.98701 -11.569263,12.40791 -16.636077,19.21074 l -30.058438,-10.44758 c -5.66072,8.44155 -10.68041,17.29574 -15.01683,26.48807 l 23.73035,20.50738 c -3.25027,7.84084 -5.919,15.91028 -7.98456,24.14293 l -31.73044,1.84879 c -2.01308,9.96359 -3.27604,20.06413 -3.77825,30.21664 l 29.82892,9.8861 c -0.0257,0.47554 -0.0495,0.95119 -0.0713,1.42693 0.0625,8.00955 0.68129,16.00509 1.85188,23.92889 l -28.50125,13.80084 c 1.91835,9.98587 4.58321,19.81373 7.97215,29.40084 l 31.40475,-2.28619 c 3.21102,7.84427 6.99429,15.44192 11.31922,22.73152 l -21.10296,23.71797 c 5.61437,8.47537 11.85658,16.51766 18.67409,24.05918 l 28.073175,-14.09863 c 5.987006,6.00889 12.407911,11.56925 19.21074,16.63606 l -10.44758,30.05847 c 8.441549,5.66072 17.295731,10.68041 26.48806,15.01683 l 20.50739,-23.73036 c 7.840839,3.25027 15.910282,5.91901 24.142929,7.98457 l 1.8488,31.73043 c 9.9635962,2.01309 20.064147,3.27605 30.216661,3.77826 l 9.8861,-29.82892 c 0.475543,0.0257 0.951191,0.0495 1.42693,0.0713 8.009557,-0.0625 16.005099,-0.68129 23.9289,-1.85188 l 13.80084,28.50125 c 9.985867,-1.91835 19.813731,-4.58322 29.400855,-7.97217 l -2.2862,-31.40474 c 7.84428,-3.21102 15.44192,-6.99429 22.73153,-11.31922 l 23.71796,21.10297 c 8.47538,-5.61437 16.51767,-11.85658 24.05919,-18.6741 l -14.09864,-28.07319 c 6.0089,-5.987 11.56926,-12.4079 16.63607,-19.21073 l 30.05846,10.44757 c 5.66072,-8.44155 10.68041,-17.29574 15.01683,-26.48807 l -23.73036,-20.50738 c 3.25027,-7.84084 5.91901,-15.91028 7.98457,-24.14293 l 31.73044,-1.84879 c 2.01308,-9.9636 3.27604,-20.06415 3.77825,-30.21667 l -29.82892,-9.8861 c 0.0257,-0.47554 0.0495,-0.95118 0.0713,-1.42692 -0.0625,-8.00956 -0.6813,-16.0051 -1.85189,-23.9289 l 28.50124,-13.80084 c -1.91835,-9.98587 -4.58321,-19.81373 -7.97215,-29.40084 l -31.40474,2.2862 c -3.21103,-7.84428 -6.9943,-15.44192 -11.31923,-22.73153 l 21.10297,-23.71797 c -5.61437,-8.47537 -11.85658,-16.51766 -18.67409,-24.05918 l -28.07319,14.09863 c -5.98701,-6.00889 -12.40791,-11.56925 -19.21074,-16.63606 l 10.44757,-30.05847 c -8.44155,-5.66072 -17.29572,-10.6804 -26.48805,-15.01682 l -20.50738,23.73035 c -7.84085,-3.25027 -15.910298,-5.91899 -24.142945,-7.98455 l -1.84879,-31.73044 c -9.9636,-2.01309 -20.064152,-3.27605 -30.21667,-3.77826 z"
|
||||
style="display:inline;opacity:1;vector-effect:none;fill:#1a5fb4;fill-opacity:1;stroke:none;stroke-width:7.03712;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" />
|
||||
</g>
|
||||
</g>
|
||||
<path
|
||||
style="display:inline;opacity:0.534;vector-effect:none;fill:#1a5fb4;fill-opacity:1;stroke:none;stroke-width:1.62918;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"
|
||||
clip-path="none"
|
||||
d="m 8.4765625,2676 c -1.1711695,2.8866 -0.5827763,6.3078 1.7656255,8.6562 l 48.101562,48.1016 c 3.133898,3.1339 8.178602,3.1339 11.3125,0 l 48.10156,-48.1016 c 2.3484,-2.3484 2.9368,-5.7696 1.76563,-8.6562 -0.39174,0.9655 -0.98013,1.8708 -1.76563,2.6562 l -48.10156,48.1016 c -3.133898,3.1339 -8.178602,3.1339 -11.3125,0 L 10.242188,2678.6562 C 9.4566904,2677.8708 8.8682972,2676.9655 8.4765625,2676 Z"
|
||||
transform="matrix(4,0,0,4,0,-10028)"
|
||||
id="rect18571-6" />
|
||||
</g>
|
||||
<rect
|
||||
y="2402"
|
||||
x="-1.5000001e-06"
|
||||
height="128"
|
||||
width="128"
|
||||
id="rect9125-7-2"
|
||||
style="display:inline;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:none;stroke-width:1.25;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m 1.53125 0.0429688 l -0.277344 0.1328122 c -0.402344 0.195313 -0.847656 0.632813 -1.035156 1.011719 l -0.1523438 0.3125 l -0.0117187 5.195312 c -0.0078125 2.859376 0.0039063 5.3125 0.0234375 5.449219 c 0.046875 0.332031 0.097656 0.351563 0.914063 0.410157 c 0.433593 0.035156 0.695312 0.078124 0.800781 0.132812 c 0.523437 0.265625 0.921875 0.71875 1.082031 1.238281 c 0.074219 0.234375 0.09375 0.4375 0.09375 0.90625 c 0 0.636719 0.011719 0.679688 0.234375 0.71875 c 0.242187 0.042969 10.832031 0.035157 11.109375 -0.011719 c 0.613281 -0.101562 1.199219 -0.554687 1.496094 -1.164062 l 0.15625 -0.320312 c 0.015625 -0.101563 0 -6.253907 0 -6.253907 v -6.25 l -0.144532 -0.304687 c -0.214843 -0.453125 -0.566406 -0.816406 -1 -1.027344 l -0.359374 -0.1757812 z m 5.242188 2.9140622 c 0.09375 -0.003906 0.207031 0.042969 0.34375 0.136719 c 0.3125 0.203125 3.441406 2.90625 4.265624 3.679688 c 0.335938 0.320312 0.609376 0.65625 0.609376 0.753906 c 0 0.105468 -0.867188 0.933594 -2.164063 2.066406 c -3.035156 2.652344 -2.949219 2.59375 -3.253906 2.25 c -0.136719 -0.144531 -0.164063 -0.40625 -0.164063 -1.515625 v -1.339844 h -0.929687 c -0.816407 0 -0.9375 -0.023437 -1.027344 -0.1875 c -0.054687 -0.105469 -0.101563 -0.742187 -0.101563 -1.414062 c 0 -1.445313 0.054688 -1.558594 0.773438 -1.53125 c 0.25 0.007812 0.636719 0.011719 0.867188 0.007812 l 0.417968 -0.011719 v -1.230468 c 0 -1.167969 0.085938 -1.648438 0.363282 -1.664063 z m 0 0"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><g color="#000" fill="#2e3436"><path d="M7.188 2.281c-.094.056-.192.125-.29.19L5.566 3.803a1.684 1.684 0 11-2.17 2.17L2.332 7.037c.506-.069 1.017-.136 1.2.026.242.214.139 1.031.155 1.656.213.088.427.171.657.219.04.008.085-.007.125 0 .337-.525.683-1.288 1-1.344.322-.057.905.562 1.406.937a3.67 3.67 0 00.656-.468c-.195-.595-.594-1.369-.437-1.657.158-.29 1.019-.37 1.625-.531.028-.183.062-.371.062-.562 0-.075-.027-.146-.031-.22-.587-.217-1.435-.385-1.562-.687-.128-.302.34-1.021.593-1.593a3.722 3.722 0 00-.593-.532zm3.875 3.25c-.165.475-.305 1.086-.47 1.563-.43.047-.84.14-1.218.312-.38-.322-.787-.773-1.156-1.093a5.562 5.562 0 00-.688.468c.177.46.453 1.001.625 1.469-.298.309-.531.67-.719 1.063-.494 0-1.102-.084-1.593-.094a5.68 5.68 0 00-.219.812c.435.24 1.006.468 1.438.72-.006.093-.032.185-.032.28 0 .333.049.66.125.97-.382.304-.898.63-1.28.937.015.044.04.083.058.127l.613.613c.417-.1.868-.223 1.266-.303.248.343.532.626.875.875-.027.135-.068.283-.104.428.174-.063.34-.155.482-.297l1.432-1.432a1.994 1.994 0 01.533-3.918c.919 0 1.684.623 1.918 1.467l1.338-1.338c.06-.06.11-.124.156-.191-.035-.062-.06-.13-.1-.188.096-.152.205-.31.315-.47.017-.348-.1-.7-.37-.971l-.177-.176c-.28.192-.561.387-.83.555-.345-.233-.746-.383-1.156-.5-.077-.507-.107-1.132-.187-1.625a5.44 5.44 0 00-.875-.063zm-9.247.608c-.087.068-.173.138-.254.205l.014.035z" style="marker:none" overflow="visible"/><path d="M8.707.293a1 1 0 00-1.415 0l-6.999 7a1 1 0 000 1.413l7 7.001a1 1 0 001.415 0l7-7a1 1 0 000-1.413zm-.708 2.121l5.587 5.587L8 13.586 2.414 7.999z" style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;shape-padding:0;isolation:auto;mix-blend-mode:normal;marker:none" font-weight="400" font-family="sans-serif" overflow="visible"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 4 5.992188 h 1 c 0.257812 0 0.527344 -0.128907 0.71875 -0.3125 l 1.28125 -1.28125 v 6.59375 h 2 v -6.59375 l 1.28125 1.28125 c 0.191406 0.183593 0.410156 0.3125 0.71875 0.3125 h 1 v -1 c 0 -0.308594 -0.089844 -0.550782 -0.28125 -0.75 l -3.71875 -3.65625 l -3.71875 3.65625 c -0.191406 0.199218 -0.28125 0.441406 -0.28125 0.75 z m 0 0"/><path d="m 4.039062 6.957031 c -1.664062 0 -3.03125 1.371094 -3.03125 3.035157 v 1.972656 c 0 1.664062 1.367188 3.035156 3.03125 3.035156 h 7.917969 c 1.664063 0 3.03125 -1.371094 3.03125 -3.035156 v -1.972656 c 0 -1.664063 -1.367187 -3.035157 -3.03125 -3.035157 h -0.957031 v 2 h 0.957031 c 0.589844 0 1.03125 0.445313 1.03125 1.035157 v 1.972656 c 0 0.589844 -0.441406 1.035156 -1.03125 1.035156 h -7.917969 c -0.589843 0 -1.03125 -0.445312 -1.03125 -1.035156 v -1.972656 c 0 -0.589844 0.441407 -1.035157 1.03125 -1.035157 h 0.960938 v -2 z m 0 0"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@ -1,4 +1,4 @@
|
||||
application_id = 'cz.bugsy.roster'
|
||||
application_id = 'cz.vesp.roster'
|
||||
|
||||
scalable_dir = 'hicolor' / 'scalable' / 'apps'
|
||||
install_data(
|
||||
@ -11,9 +11,3 @@ install_data(
|
||||
symbolic_dir / ('@0@-symbolic.svg').format(application_id),
|
||||
install_dir: get_option('datadir') / 'icons' / symbolic_dir
|
||||
)
|
||||
|
||||
# Install custom export icon
|
||||
install_data(
|
||||
symbolic_dir / 'export-symbolic.svg',
|
||||
install_dir: get_option('datadir') / 'icons' / symbolic_dir
|
||||
)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
desktop_file = i18n.merge_file(
|
||||
input: 'cz.bugsy.roster.desktop.in',
|
||||
output: 'cz.bugsy.roster.desktop',
|
||||
input: 'cz.vesp.roster.desktop.in',
|
||||
output: 'cz.vesp.roster.desktop',
|
||||
type: 'desktop',
|
||||
po_dir: '../po',
|
||||
install: true,
|
||||
@ -13,8 +13,8 @@ if desktop_utils.found()
|
||||
endif
|
||||
|
||||
appstream_file = i18n.merge_file(
|
||||
input: 'cz.bugsy.roster.metainfo.xml.in',
|
||||
output: 'cz.bugsy.roster.metainfo.xml',
|
||||
input: 'cz.vesp.roster.metainfo.xml.in',
|
||||
output: 'cz.vesp.roster.metainfo.xml',
|
||||
po_dir: '../po',
|
||||
install: true,
|
||||
install_dir: get_option('datadir') / 'metainfo'
|
||||
@ -24,7 +24,7 @@ appstreamcli = find_program('appstreamcli', required: false, disabler: true)
|
||||
test('Validate appstream file', appstreamcli,
|
||||
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'
|
||||
)
|
||||
|
||||
@ -37,8 +37,8 @@ test('Validate schema file',
|
||||
service_conf = configuration_data()
|
||||
service_conf.set('bindir', get_option('prefix') / get_option('bindir'))
|
||||
configure_file(
|
||||
input: 'cz.bugsy.roster.service.in',
|
||||
output: 'cz.bugsy.roster.service',
|
||||
input: 'cz.vesp.roster.service.in',
|
||||
output: 'cz.vesp.roster.service',
|
||||
configuration: service_conf,
|
||||
install_dir: get_option('datadir') / 'dbus-1' / 'services'
|
||||
)
|
||||
|
||||
@ -1,158 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example script demonstrating sensitive variable usage.
|
||||
|
||||
This script shows how to:
|
||||
1. Create a project with variables
|
||||
2. Mark some variables as sensitive
|
||||
3. Set values for both regular and sensitive variables
|
||||
4. Retrieve values from both storages
|
||||
5. Use variables in request substitution
|
||||
|
||||
Run this from the project root after building the app.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add src to path for testing
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
from roster.project_manager import ProjectManager
|
||||
from roster.models import HttpRequest
|
||||
from roster.variable_substitution import VariableSubstitution
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Sensitive Variables Example")
|
||||
print("=" * 60)
|
||||
|
||||
pm = ProjectManager()
|
||||
|
||||
# Create a test project
|
||||
print("\n1. Creating test project...")
|
||||
project = pm.add_project("API Test Project")
|
||||
project_id = project.id
|
||||
print(f" Created project: {project.name} (ID: {project_id})")
|
||||
|
||||
# Add environments
|
||||
print("\n2. Adding environments...")
|
||||
env_prod = pm.add_environment(project_id, "Production")
|
||||
env_dev = pm.add_environment(project_id, "Development")
|
||||
print(f" - Production (ID: {env_prod.id})")
|
||||
print(f" - Development (ID: {env_dev.id})")
|
||||
|
||||
# Add variables
|
||||
print("\n3. Adding variables...")
|
||||
pm.add_variable(project_id, "base_url")
|
||||
pm.add_variable(project_id, "api_key")
|
||||
pm.add_variable(project_id, "timeout")
|
||||
print(" - base_url (regular)")
|
||||
print(" - api_key (regular for now)")
|
||||
print(" - timeout (regular)")
|
||||
|
||||
# Set values for regular variables
|
||||
print("\n4. Setting values for regular variables...")
|
||||
pm.set_variable_value(project_id, env_prod.id, "base_url", "https://api.example.com")
|
||||
pm.set_variable_value(project_id, env_dev.id, "base_url", "https://dev.api.example.com")
|
||||
pm.set_variable_value(project_id, env_prod.id, "timeout", "30")
|
||||
pm.set_variable_value(project_id, env_dev.id, "timeout", "60")
|
||||
print(" ✓ Set base_url for both environments")
|
||||
print(" ✓ Set timeout for both environments")
|
||||
|
||||
# Set API key values (still regular)
|
||||
print("\n5. Setting API key values (in JSON - NOT SECURE YET)...")
|
||||
pm.set_variable_value(project_id, env_prod.id, "api_key", "prod-secret-key-12345")
|
||||
pm.set_variable_value(project_id, env_dev.id, "api_key", "dev-secret-key-67890")
|
||||
print(" ⚠ API keys stored in plain JSON file!")
|
||||
|
||||
# Mark API key as sensitive
|
||||
print("\n6. Marking api_key as SENSITIVE...")
|
||||
success = pm.mark_variable_as_sensitive(project_id, "api_key")
|
||||
if success:
|
||||
print(" ✓ api_key is now stored in GNOME Keyring (encrypted)")
|
||||
print(" ✓ Values moved from JSON to keyring")
|
||||
print(" ✓ JSON now contains empty placeholders")
|
||||
else:
|
||||
print(" ✗ Failed to mark as sensitive")
|
||||
return
|
||||
|
||||
# Verify storage
|
||||
print("\n7. Verifying storage...")
|
||||
is_sensitive = pm.is_variable_sensitive(project_id, "api_key")
|
||||
print(f" - api_key is sensitive: {is_sensitive}")
|
||||
|
||||
# Retrieve values
|
||||
print("\n8. Retrieving values...")
|
||||
base_url_prod = pm.get_variable_value(project_id, env_prod.id, "base_url")
|
||||
api_key_prod = pm.get_variable_value(project_id, env_prod.id, "api_key")
|
||||
api_key_dev = pm.get_variable_value(project_id, env_dev.id, "api_key")
|
||||
|
||||
print(f" - Production base_url: {base_url_prod}")
|
||||
print(f" - Production api_key: {api_key_prod} (from keyring)")
|
||||
print(f" - Development api_key: {api_key_dev} (from keyring)")
|
||||
|
||||
# Use in request substitution
|
||||
print("\n9. Using in request substitution...")
|
||||
request = HttpRequest(
|
||||
method="GET",
|
||||
url="{{base_url}}/users",
|
||||
headers={
|
||||
"Authorization": "Bearer {{api_key}}",
|
||||
"X-Timeout": "{{timeout}}"
|
||||
},
|
||||
body=""
|
||||
)
|
||||
print(f" Original request URL: {request.url}")
|
||||
print(f" Original request headers: {request.headers}")
|
||||
|
||||
# Get environment with secrets
|
||||
env_with_secrets = pm.get_environment_with_secrets(project_id, env_prod.id)
|
||||
|
||||
# Substitute variables
|
||||
vs = VariableSubstitution()
|
||||
substituted_request, undefined = vs.substitute_request(request, env_with_secrets)
|
||||
|
||||
print(f"\n Substituted request URL: {substituted_request.url}")
|
||||
print(f" Substituted headers: {substituted_request.headers}")
|
||||
print(f" Undefined variables: {undefined}")
|
||||
|
||||
# Demonstrate file storage
|
||||
print("\n10. Checking file storage...")
|
||||
projects = pm.load_projects()
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
print(f" Variables: {p.variable_names}")
|
||||
print(f" Sensitive variables: {p.sensitive_variables}")
|
||||
for env in p.environments:
|
||||
if env.id == env_prod.id:
|
||||
print(f" Production variables in JSON: {env.variables}")
|
||||
print(f" Notice: api_key is empty (value in keyring)")
|
||||
|
||||
# Cleanup
|
||||
print("\n11. Cleanup...")
|
||||
response = input(" Delete test project? (y/n): ")
|
||||
if response.lower() == 'y':
|
||||
pm.delete_project(project_id)
|
||||
print(" ✓ Project deleted (secrets also removed from keyring)")
|
||||
else:
|
||||
print(f" Project kept. ID: {project_id}")
|
||||
print(" You can view secrets in 'Passwords and Keys' app")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example completed!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nInterrupted by user")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n\nError: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@ -1,5 +1,5 @@
|
||||
project('roster',
|
||||
version: '0.8.2',
|
||||
version: '0.2.0',
|
||||
meson_version: '>= 1.0.0',
|
||||
default_options: [ 'warning_level=2', 'werror=false', ],
|
||||
)
|
||||
|
||||
@ -1,21 +1,8 @@
|
||||
# List of source files containing translatable strings.
|
||||
# Please keep this file sorted alphabetically.
|
||||
data/cz.bugsy.roster.desktop.in
|
||||
data/cz.bugsy.roster.metainfo.xml.in
|
||||
data/cz.bugsy.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
|
||||
data/cz.vesp.roster.desktop.in
|
||||
data/cz.vesp.roster.metainfo.xml.in
|
||||
data/cz.vesp.roster.gschema.xml
|
||||
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.ui
|
||||
|
||||
182
po/roster.pot
@ -1,182 +0,0 @@
|
||||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the roster package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: roster\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-13 17:55+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=CHARSET\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: data/cz.bugsy.roster.desktop.in:2 data/cz.bugsy.roster.metainfo.xml.in:7
|
||||
msgid "Roster"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.desktop.in:3 data/cz.bugsy.roster.metainfo.xml.in:8
|
||||
msgid "HTTP client for API testing"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.desktop.in:9
|
||||
msgid "HTTP;REST;API;Client;Request;JSON;"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.metainfo.xml.in:10
|
||||
msgid ""
|
||||
"Roster is a modern HTTP client for testing and debugging REST APIs. It "
|
||||
"provides a clean, GNOME-native interface for making HTTP requests and "
|
||||
"inspecting responses."
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.metainfo.xml.in:11
|
||||
msgid ""
|
||||
"Note: Application-specific icons included with this project are licensed "
|
||||
"under CC-BY-SA-3.0. GNOME system icons are provided by the system and are "
|
||||
"not distributed with this application. All other components are licensed "
|
||||
"under GPL-3.0-or-later."
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.metainfo.xml.in:15
|
||||
msgid "Pavel Baksy"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.metainfo.xml.in:32
|
||||
#: data/cz.bugsy.roster.metainfo.xml.in:36
|
||||
msgid "A caption"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.gschema.xml:6
|
||||
msgid "Force TLS verification"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.gschema.xml:7
|
||||
msgid ""
|
||||
"When enabled, TLS certificates will be verified. Disable to test endpoints "
|
||||
"with self-signed or invalid certificates."
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.gschema.xml:11
|
||||
msgid "Request timeout"
|
||||
msgstr ""
|
||||
|
||||
#: data/cz.bugsy.roster.gschema.xml:12
|
||||
msgid "Timeout for HTTP requests in seconds"
|
||||
msgstr ""
|
||||
|
||||
#. Translators: Replace "translator-credits" with your name/username, and optionally an email or URL.
|
||||
#: src/main.py:79
|
||||
msgid "translator-credits"
|
||||
msgstr ""
|
||||
|
||||
#: src/main-window.ui:132
|
||||
msgid "Main Menu"
|
||||
msgstr ""
|
||||
|
||||
#: src/main-window.ui:234
|
||||
msgid "_Preferences"
|
||||
msgstr ""
|
||||
|
||||
#: src/main-window.ui:238
|
||||
msgid "_Keyboard Shortcuts"
|
||||
msgstr ""
|
||||
|
||||
#: src/main-window.ui:242
|
||||
msgid "_About Roster"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:7
|
||||
msgid "Preferences"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:14
|
||||
msgid "General"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:19
|
||||
msgid "Network"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:20
|
||||
msgid "HTTP connection settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:24
|
||||
msgid "Force TLS Verification"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:25
|
||||
msgid ""
|
||||
"Verify TLS certificates for HTTPS connections. Disable to test endpoints "
|
||||
"with self-signed certificates."
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:31
|
||||
msgid "Request Timeout"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:32
|
||||
msgid "Maximum time to wait for a response (in seconds)"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:49
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:50
|
||||
msgid "Manage request history"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:54
|
||||
msgid "Clear All History"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:55
|
||||
msgid "Remove all saved request and response history"
|
||||
msgstr ""
|
||||
|
||||
#: src/preferences-dialog.ui:58
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:13
|
||||
msgctxt "shortcut window"
|
||||
msgid "New Tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:19
|
||||
msgctxt "shortcut window"
|
||||
msgid "Close Tab"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:25
|
||||
msgctxt "shortcut window"
|
||||
msgid "Save Request"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:31
|
||||
msgctxt "shortcut window"
|
||||
msgid "Focus URL Field"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:37
|
||||
msgctxt "shortcut window"
|
||||
msgid "Send Request"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:43
|
||||
msgctxt "shortcut window"
|
||||
msgid "Show Shortcuts"
|
||||
msgstr ""
|
||||
|
||||
#: src/shortcuts-dialog.ui:49
|
||||
msgctxt "shortcut window"
|
||||
msgid "Quit"
|
||||
msgstr ""
|
||||
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 136 KiB |
@ -7,7 +7,6 @@
|
||||
# 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
|
||||
@ -18,38 +17,6 @@
|
||||
#
|
||||
# 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)
|
||||
PROJECT_ICONS = [
|
||||
# Row 1: Folders & Organization
|
||||
|
||||
@ -5,9 +5,8 @@
|
||||
|
||||
<template class="EnvironmentsDialog" parent="AdwDialog">
|
||||
<property name="title">Manage Environments</property>
|
||||
<property name="content-width">1100</property>
|
||||
<property name="content-height">600</property>
|
||||
<property name="follows-content-size">false</property>
|
||||
<property name="content-width">700</property>
|
||||
<property name="content-height">500</property>
|
||||
|
||||
<child>
|
||||
<object class="AdwToolbarView">
|
||||
@ -21,20 +20,70 @@
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
<property name="maximum-size">1200</property>
|
||||
<property name="tightening-threshold">800</property>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">12</property>
|
||||
<property name="spacing">24</property>
|
||||
|
||||
<!-- Variables Section -->
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">12</property>
|
||||
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">12</property>
|
||||
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label">Variables</property>
|
||||
<property name="xalign">0</property>
|
||||
<property name="hexpand">True</property>
|
||||
<style>
|
||||
<class name="title-4"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkButton" id="add_variable_button">
|
||||
<property name="icon-name">list-add-symbolic</property>
|
||||
<property name="tooltip-text">Add variable</property>
|
||||
<signal name="clicked" handler="on_add_variable_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkFrame">
|
||||
<child>
|
||||
<object class="GtkListBox" id="variables_listbox">
|
||||
<property name="selection-mode">none</property>
|
||||
<style>
|
||||
<class name="boxed-list"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Environments Section -->
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">12</property>
|
||||
<property name="spacing">12</property>
|
||||
|
||||
<!-- Header Section -->
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
@ -53,7 +102,6 @@
|
||||
|
||||
<child>
|
||||
<object class="GtkButton" id="add_environment_button">
|
||||
<property name="label">Add Environment</property>
|
||||
<property name="icon-name">list-add-symbolic</property>
|
||||
<property name="tooltip-text">Add environment</property>
|
||||
<signal name="clicked" handler="on_add_environment_clicked"/>
|
||||
@ -65,12 +113,11 @@
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Table Frame -->
|
||||
<child>
|
||||
<object class="GtkFrame">
|
||||
<child>
|
||||
<object class="GtkBox" id="table_container">
|
||||
<property name="orientation">vertical</property>
|
||||
<object class="GtkListBox" id="environments_listbox">
|
||||
<property name="selection-mode">none</property>
|
||||
<style>
|
||||
<class name="boxed-list"/>
|
||||
</style>
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
# 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
|
||||
@ -21,111 +20,58 @@
|
||||
from gi.repository import Adw, Gtk, GObject
|
||||
from .widgets.variable_row import VariableRow
|
||||
from .widgets.environment_row import EnvironmentRow
|
||||
from .widgets.environment_header_row import EnvironmentHeaderRow
|
||||
from .widgets.variable_data_row import VariableDataRow
|
||||
|
||||
|
||||
@Gtk.Template(resource_path='/cz/bugsy/roster/environments-dialog.ui')
|
||||
@Gtk.Template(resource_path='/cz/vesp/roster/environments-dialog.ui')
|
||||
class EnvironmentsDialog(Adw.Dialog):
|
||||
"""Dialog for managing project environments and variables."""
|
||||
|
||||
__gtype_name__ = 'EnvironmentsDialog'
|
||||
|
||||
table_container = Gtk.Template.Child()
|
||||
variables_listbox = Gtk.Template.Child()
|
||||
add_variable_button = Gtk.Template.Child()
|
||||
environments_listbox = Gtk.Template.Child()
|
||||
add_environment_button = Gtk.Template.Child()
|
||||
|
||||
__gsignals__ = {
|
||||
'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
}
|
||||
|
||||
def __init__(self, project, project_manager, recently_updated_variables=None):
|
||||
def __init__(self, project, project_manager):
|
||||
super().__init__()
|
||||
self.project = project
|
||||
self.project_manager = project_manager
|
||||
self.recently_updated_variables = recently_updated_variables or {}
|
||||
self.size_group = Gtk.SizeGroup(mode=Gtk.SizeGroupMode.HORIZONTAL)
|
||||
self.header_row = None
|
||||
self.data_rows = []
|
||||
self._populate_table()
|
||||
self._populate_variables()
|
||||
self._populate_environments()
|
||||
|
||||
def _populate_table(self):
|
||||
"""Populate the transposed environment-variable table."""
|
||||
def _populate_variables(self):
|
||||
"""Populate variables list."""
|
||||
# Clear existing
|
||||
while child := self.table_container.get_first_child():
|
||||
self.table_container.remove(child)
|
||||
while child := self.variables_listbox.get_first_child():
|
||||
self.variables_listbox.remove(child)
|
||||
|
||||
self.data_rows = []
|
||||
|
||||
# Create header row
|
||||
self.header_row = EnvironmentHeaderRow(
|
||||
self.project.environments,
|
||||
self.size_group
|
||||
)
|
||||
self.header_row.connect('environment-edit-requested',
|
||||
self._on_environment_edit)
|
||||
self.header_row.connect('environment-delete-requested',
|
||||
self._on_environment_delete)
|
||||
self.table_container.append(self.header_row)
|
||||
|
||||
# Add separator
|
||||
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
self.table_container.append(separator)
|
||||
|
||||
# Create data rows for each variable
|
||||
# Add variables
|
||||
for var_name in self.project.variable_names:
|
||||
# Check if variable was recently updated
|
||||
update_timestamp = self.recently_updated_variables.get(var_name)
|
||||
row = VariableRow(var_name)
|
||||
row.connect('remove-requested', self._on_variable_remove, var_name)
|
||||
row.connect('changed', self._on_variable_changed, var_name, row)
|
||||
self.variables_listbox.append(row)
|
||||
|
||||
row = VariableDataRow(
|
||||
var_name,
|
||||
self.project.environments,
|
||||
self.size_group,
|
||||
self.project,
|
||||
self.project_manager,
|
||||
update_timestamp=update_timestamp
|
||||
)
|
||||
row.connect('variable-changed',
|
||||
self._on_variable_changed, var_name, row)
|
||||
row.connect('variable-delete-requested',
|
||||
self._on_variable_remove, var_name)
|
||||
row.connect('value-changed',
|
||||
self._on_value_changed, var_name)
|
||||
row.connect('sensitivity-changed',
|
||||
self._on_sensitivity_changed, var_name)
|
||||
def _populate_environments(self):
|
||||
"""Populate environments list."""
|
||||
# Clear existing
|
||||
while child := self.environments_listbox.get_first_child():
|
||||
self.environments_listbox.remove(child)
|
||||
|
||||
self.table_container.append(row)
|
||||
self.data_rows.append(row)
|
||||
|
||||
# Add "Add Variable" button row at the bottom
|
||||
add_var_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
add_var_row.set_margin_start(12)
|
||||
add_var_row.set_margin_end(12)
|
||||
add_var_row.set_margin_top(6)
|
||||
add_var_row.set_margin_bottom(6)
|
||||
|
||||
# Variable cell with add button
|
||||
var_cell = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
var_cell.set_size_request(150, -1)
|
||||
if self.size_group:
|
||||
self.size_group.add_widget(var_cell)
|
||||
|
||||
add_variable_button = Gtk.Button()
|
||||
add_variable_button.set_icon_name("list-add-symbolic")
|
||||
add_variable_button.set_tooltip_text("Add variable")
|
||||
add_variable_button.connect('clicked', self.on_add_variable_clicked)
|
||||
add_variable_button.add_css_class("flat")
|
||||
add_variable_button.add_css_class("circular")
|
||||
|
||||
var_cell.append(add_variable_button)
|
||||
add_var_row.append(var_cell)
|
||||
|
||||
# Empty space for value columns
|
||||
empty_space = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
empty_space.set_hexpand(True)
|
||||
add_var_row.append(empty_space)
|
||||
|
||||
self.table_container.append(add_var_row)
|
||||
# Add environments
|
||||
for env in self.project.environments:
|
||||
row = EnvironmentRow(env, self.project.variable_names)
|
||||
row.connect('edit-requested', self._on_environment_edit, env)
|
||||
row.connect('delete-requested', self._on_environment_delete, env)
|
||||
row.connect('value-changed', self._on_environment_value_changed, env)
|
||||
self.environments_listbox.append(row)
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_add_variable_clicked(self, button):
|
||||
"""Add new variable."""
|
||||
dialog = Adw.AlertDialog()
|
||||
@ -134,7 +80,6 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
|
||||
entry = Gtk.Entry()
|
||||
entry.set_placeholder_text("Variable name")
|
||||
entry.set_activates_default(True)
|
||||
dialog.set_extra_child(entry)
|
||||
|
||||
dialog.add_response("cancel", "Cancel")
|
||||
@ -150,7 +95,8 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
self.project_manager.add_variable(self.project.id, name)
|
||||
# Reload project data
|
||||
self._reload_project()
|
||||
self._populate_table()
|
||||
self._populate_variables()
|
||||
self._populate_environments()
|
||||
self.emit('environments-updated')
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
@ -169,12 +115,11 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
|
||||
def on_response(dlg, response):
|
||||
if response == "delete":
|
||||
def on_delete_complete():
|
||||
self._reload_project()
|
||||
self._populate_table()
|
||||
self.emit('environments-updated')
|
||||
|
||||
self.project_manager.delete_variable(self.project.id, var_name, on_delete_complete)
|
||||
self.project_manager.delete_variable(self.project.id, var_name)
|
||||
self._reload_project()
|
||||
self._populate_variables()
|
||||
self._populate_environments()
|
||||
self.emit('environments-updated')
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
dialog.present(self)
|
||||
@ -183,12 +128,11 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
"""Handle variable name change."""
|
||||
new_name = row.get_variable_name()
|
||||
if new_name and new_name != old_name and new_name not in self.project.variable_names:
|
||||
def on_rename_complete():
|
||||
self._reload_project()
|
||||
self._populate_table()
|
||||
self.emit('environments-updated')
|
||||
|
||||
self.project_manager.rename_variable(self.project.id, old_name, new_name, on_rename_complete)
|
||||
self.project_manager.rename_variable(self.project.id, old_name, new_name)
|
||||
self._reload_project()
|
||||
self._populate_variables()
|
||||
self._populate_environments()
|
||||
self.emit('environments-updated')
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_add_environment_clicked(self, button):
|
||||
@ -199,7 +143,6 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
|
||||
entry = Gtk.Entry()
|
||||
entry.set_placeholder_text("Environment name (e.g., Production)")
|
||||
entry.set_activates_default(True)
|
||||
dialog.set_extra_child(entry)
|
||||
|
||||
dialog.add_response("cancel", "Cancel")
|
||||
@ -214,7 +157,7 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
if name:
|
||||
self.project_manager.add_environment(self.project.id, name)
|
||||
self._reload_project()
|
||||
self._populate_table()
|
||||
self._populate_environments()
|
||||
self.emit('environments-updated')
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
@ -228,14 +171,11 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
|
||||
entry = Gtk.Entry()
|
||||
entry.set_text(environment.name)
|
||||
entry.set_activates_default(True)
|
||||
dialog.set_extra_child(entry)
|
||||
|
||||
dialog.add_response("cancel", "Cancel")
|
||||
dialog.add_response("save", "Save")
|
||||
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
|
||||
dialog.set_default_response("save")
|
||||
dialog.set_close_response("cancel")
|
||||
|
||||
def on_response(dlg, response):
|
||||
if response == "save":
|
||||
@ -243,7 +183,7 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
if new_name:
|
||||
self.project_manager.update_environment(self.project.id, environment.id, name=new_name)
|
||||
self._reload_project()
|
||||
self._populate_table()
|
||||
self._populate_environments()
|
||||
self.emit('environments-updated')
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
@ -271,38 +211,19 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
|
||||
def on_response(dlg, response):
|
||||
if response == "delete":
|
||||
def on_delete_complete():
|
||||
self._reload_project()
|
||||
self._populate_table()
|
||||
self.emit('environments-updated')
|
||||
|
||||
self.project_manager.delete_environment(self.project.id, environment.id, on_delete_complete)
|
||||
self.project_manager.delete_environment(self.project.id, environment.id)
|
||||
self._reload_project()
|
||||
self._populate_environments()
|
||||
self.emit('environments-updated')
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
dialog.present(self)
|
||||
|
||||
def _on_value_changed(self, widget, env_id, value, var_name):
|
||||
"""Handle value change in table cell."""
|
||||
# Note: value is already stored by VariableDataRow using set_variable_value
|
||||
# This handler is just for emitting the update signal
|
||||
def _on_environment_value_changed(self, widget, var_name, value, environment):
|
||||
"""Handle environment variable value change."""
|
||||
self.project_manager.update_environment_variable(self.project.id, environment.id, var_name, value)
|
||||
self.emit('environments-updated')
|
||||
|
||||
def _on_sensitivity_changed(self, widget, is_sensitive, var_name):
|
||||
"""Handle variable sensitivity toggle."""
|
||||
def on_complete(success):
|
||||
if success:
|
||||
# Reload and refresh UI
|
||||
self._reload_project()
|
||||
self._populate_table()
|
||||
self.emit('environments-updated')
|
||||
|
||||
if is_sensitive:
|
||||
# Mark as sensitive (move to keyring)
|
||||
self.project_manager.mark_variable_as_sensitive(self.project.id, var_name, on_complete)
|
||||
else:
|
||||
# Mark as non-sensitive (move to JSON)
|
||||
self.project_manager.mark_variable_as_nonsensitive(self.project.id, var_name, on_complete)
|
||||
|
||||
def _reload_project(self):
|
||||
"""Reload project data from manager."""
|
||||
projects = self.project_manager.load_projects()
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<requires lib="Adw" version="1.0"/>
|
||||
|
||||
<template class="ExportDialog" parent="AdwDialog">
|
||||
<property name="title">Export Request</property>
|
||||
<property name="content-width">600</property>
|
||||
<property name="content-height">400</property>
|
||||
|
||||
<property name="child">
|
||||
<object class="AdwToolbarView">
|
||||
<child type="top">
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-title">true</property>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="copy_button">
|
||||
<property name="label">Copy</property>
|
||||
<property name="tooltip-text">Copy to Clipboard</property>
|
||||
<signal name="clicked" handler="on_copy_clicked"/>
|
||||
<style>
|
||||
<class name="suggested-action"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<property name="content">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">12</property>
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">12</property>
|
||||
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">true</property>
|
||||
<property name="hexpand">true</property>
|
||||
<child>
|
||||
<object class="GtkTextView" id="export_textview">
|
||||
<property name="editable">false</property>
|
||||
<property name="monospace">true</property>
|
||||
<property name="wrap-mode">none</property>
|
||||
<property name="left-margin">12</property>
|
||||
<property name="right-margin">12</property>
|
||||
<property name="top-margin">12</property>
|
||||
<property name="bottom-margin">12</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</template>
|
||||
</interface>
|
||||
@ -1,90 +0,0 @@
|
||||
# export_dialog.py
|
||||
#
|
||||
# Copyright 2025 Pavel Baksy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from gi.repository import Adw, Gtk, Gdk, GLib
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@Gtk.Template(resource_path='/cz/bugsy/roster/export-dialog.ui')
|
||||
class ExportDialog(Adw.Dialog):
|
||||
"""Dialog for exporting HTTP requests in various formats."""
|
||||
|
||||
__gtype_name__ = 'ExportDialog'
|
||||
|
||||
export_textview = Gtk.Template.Child()
|
||||
copy_button = Gtk.Template.Child()
|
||||
|
||||
def __init__(self, request, **kwargs):
|
||||
"""
|
||||
Initialize export dialog.
|
||||
|
||||
Args:
|
||||
request: HttpRequest to export (should have variables substituted)
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.request = request
|
||||
self._populate_export()
|
||||
|
||||
def _populate_export(self):
|
||||
"""Generate and display the export content."""
|
||||
try:
|
||||
# Get cURL exporter
|
||||
from .exporters import ExporterRegistry
|
||||
exporter = ExporterRegistry.get_exporter('curl')
|
||||
|
||||
# Generate cURL command
|
||||
curl_command = exporter.export(self.request)
|
||||
|
||||
# Display in textview
|
||||
buffer = self.export_textview.get_buffer()
|
||||
buffer.set_text(curl_command)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Export generation failed: {e}")
|
||||
buffer = self.export_textview.get_buffer()
|
||||
buffer.set_text(f"Error generating export: {str(e)}")
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_copy_clicked(self, button):
|
||||
"""Copy export content to clipboard."""
|
||||
# Get text from buffer
|
||||
buffer = self.export_textview.get_buffer()
|
||||
start = buffer.get_start_iter()
|
||||
end = buffer.get_end_iter()
|
||||
text = buffer.get_text(start, end, False)
|
||||
|
||||
# Copy to clipboard using Gdk.Clipboard API
|
||||
display = Gdk.Display.get_default()
|
||||
clipboard = display.get_clipboard()
|
||||
clipboard.set(text)
|
||||
|
||||
# Visual feedback: temporarily change button label
|
||||
original_label = button.get_label()
|
||||
button.set_label("Copied!")
|
||||
|
||||
# Reset after 2 seconds
|
||||
def reset_label():
|
||||
button.set_label(original_label)
|
||||
return False # Don't repeat
|
||||
|
||||
GLib.timeout_add(2000, reset_label)
|
||||
|
||||
logger.debug("Export copied to clipboard")
|
||||
@ -1,24 +0,0 @@
|
||||
# exporters package
|
||||
#
|
||||
# Copyright 2025 Pavel Baksy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .registry import ExporterRegistry
|
||||
from .base_exporter import BaseExporter
|
||||
from .curl_exporter import CurlExporter
|
||||
|
||||
__all__ = ['ExporterRegistry', 'BaseExporter', 'CurlExporter']
|
||||
@ -1,50 +0,0 @@
|
||||
# base_exporter.py
|
||||
#
|
||||
# Copyright 2025 Pavel Baksy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from ..models import HttpRequest
|
||||
|
||||
|
||||
class BaseExporter(ABC):
|
||||
"""Abstract base class for request exporters."""
|
||||
|
||||
@abstractmethod
|
||||
def export(self, request: HttpRequest) -> str:
|
||||
"""
|
||||
Export HTTP request to target format.
|
||||
|
||||
Args:
|
||||
request: HttpRequest with method, url, headers, body
|
||||
|
||||
Returns:
|
||||
Formatted export string
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def format_name(self) -> str:
|
||||
"""Human-readable format name (e.g., 'cURL', 'Insomnia')."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def file_extension(self) -> str:
|
||||
"""File extension for export (e.g., 'sh', 'json')."""
|
||||
pass
|
||||
@ -1,70 +0,0 @@
|
||||
# curl_exporter.py
|
||||
#
|
||||
# Copyright 2025 Pavel Baksy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import shlex
|
||||
from .base_exporter import BaseExporter
|
||||
from ..models import HttpRequest
|
||||
|
||||
|
||||
class CurlExporter(BaseExporter):
|
||||
"""Export HTTP requests as cURL commands."""
|
||||
|
||||
@property
|
||||
def format_name(self) -> str:
|
||||
return "cURL"
|
||||
|
||||
@property
|
||||
def file_extension(self) -> str:
|
||||
return "sh"
|
||||
|
||||
def export(self, request: HttpRequest) -> str:
|
||||
"""
|
||||
Generate cURL command with proper shell escaping.
|
||||
|
||||
Format:
|
||||
curl -X POST \
|
||||
'https://api.example.com/endpoint' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"key": "value"}'
|
||||
"""
|
||||
parts = ["curl"]
|
||||
|
||||
# Add HTTP method (skip if GET - it's default)
|
||||
if request.method != "GET":
|
||||
parts.append(f"-X {request.method}")
|
||||
|
||||
# Add URL (always quoted for safety)
|
||||
parts.append(shlex.quote(request.url))
|
||||
|
||||
# Add headers
|
||||
for key, value in request.headers.items():
|
||||
header_str = f"{key}: {value}"
|
||||
parts.append(f"-H {shlex.quote(header_str)}")
|
||||
|
||||
# Add body for POST/PUT/PATCH/DELETE methods
|
||||
if request.body and request.method in ["POST", "PUT", "PATCH", "DELETE"]:
|
||||
parts.append(f"--data {shlex.quote(request.body)}")
|
||||
|
||||
# Format as multiline with backslash continuations
|
||||
return " \\\n ".join(parts)
|
||||
|
||||
|
||||
# Auto-register on import
|
||||
from .registry import ExporterRegistry
|
||||
ExporterRegistry.register('curl', CurlExporter)
|
||||
@ -1,46 +0,0 @@
|
||||
# registry.py
|
||||
#
|
||||
# Copyright 2025 Pavel Baksy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from typing import Dict, List, Tuple, Type
|
||||
from .base_exporter import BaseExporter
|
||||
|
||||
|
||||
class ExporterRegistry:
|
||||
"""Registry for managing export formats."""
|
||||
|
||||
_exporters: Dict[str, Type[BaseExporter]] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, format_id: str, exporter_class: Type[BaseExporter]):
|
||||
"""Register an exporter class."""
|
||||
cls._exporters[format_id] = exporter_class
|
||||
|
||||
@classmethod
|
||||
def get_exporter(cls, format_id: str) -> BaseExporter:
|
||||
"""Get exporter instance by format ID."""
|
||||
exporter_class = cls._exporters.get(format_id)
|
||||
if not exporter_class:
|
||||
raise ValueError(f"Unknown export format: {format_id}")
|
||||
return exporter_class()
|
||||
|
||||
@classmethod
|
||||
def get_all_formats(cls) -> List[Tuple[str, str]]:
|
||||
"""Get all registered formats as [(id, name), ...]."""
|
||||
return [(format_id, cls._exporters[format_id]().format_name)
|
||||
for format_id in cls._exporters.keys()]
|
||||
@ -7,7 +7,6 @@
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (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
|
||||
@ -20,16 +19,12 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
import gi
|
||||
gi.require_version('GLib', '2.0')
|
||||
from gi.repository import GLib
|
||||
from .models import HistoryEntry
|
||||
from .constants import HISTORY_MAX_ENTRIES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HistoryManager:
|
||||
@ -37,9 +32,9 @@ class HistoryManager:
|
||||
|
||||
def __init__(self):
|
||||
# Use XDG config directory (works for both Flatpak and native)
|
||||
# Flatpak: ~/.var/app/cz.bugsy.roster/config/cz.bugsy.roster
|
||||
# Native: ~/.config/cz.bugsy.roster
|
||||
self.config_dir = Path(GLib.get_user_config_dir()) / 'cz.bugsy.roster'
|
||||
# Flatpak: ~/.var/app/cz.vesp.roster/config/cz.vesp.roster
|
||||
# Native: ~/.config/cz.vesp.roster
|
||||
self.config_dir = Path(GLib.get_user_config_dir()) / 'cz.vesp.roster'
|
||||
self.history_file = self.config_dir / 'history.json'
|
||||
self._ensure_config_dir()
|
||||
|
||||
@ -58,7 +53,7 @@ class HistoryManager:
|
||||
|
||||
return [HistoryEntry.from_dict(entry) for entry in data.get('entries', [])]
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading history: {e}")
|
||||
print(f"Error loading history: {e}")
|
||||
return []
|
||||
|
||||
def save_history(self, entries: List[HistoryEntry]):
|
||||
@ -72,23 +67,27 @@ class HistoryManager:
|
||||
with open(self.history_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving history: {e}")
|
||||
print(f"Error saving history: {e}")
|
||||
|
||||
def add_entry(self, entry: HistoryEntry):
|
||||
"""Add new entry to history and save."""
|
||||
entries = self.load_history()
|
||||
entries.insert(0, entry) # Most recent first
|
||||
|
||||
# Limit history size
|
||||
entries = entries[:HISTORY_MAX_ENTRIES]
|
||||
# Limit history size to 100 entries
|
||||
entries = entries[:100]
|
||||
|
||||
self.save_history(entries)
|
||||
|
||||
def delete_entry(self, entry: HistoryEntry):
|
||||
"""Delete a specific entry from history by ID."""
|
||||
"""Delete a specific entry from history."""
|
||||
entries = self.load_history()
|
||||
# Filter out the entry by comparing unique IDs
|
||||
entries = [e for e in entries if e.id != entry.id]
|
||||
# Filter out the entry by comparing timestamps and URLs
|
||||
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)
|
||||
|
||||
def clear_history(self):
|
||||
|
||||
@ -34,7 +34,7 @@ class HttpClient:
|
||||
self.session = Soup.Session.new()
|
||||
|
||||
# Load settings
|
||||
self.settings = Gio.Settings.new('cz.bugsy.roster')
|
||||
self.settings = Gio.Settings.new('cz.vesp.roster')
|
||||
|
||||
# Initialize TLS verification flag
|
||||
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...)
|
||||
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
|
||||
body = body_bytes.decode('utf-8', errors='replace')
|
||||
body = bytes_data.get_data().decode('utf-8', errors='replace')
|
||||
|
||||
return HttpResponse(
|
||||
status_code=status_code,
|
||||
status_text=status_text,
|
||||
headers=headers_text,
|
||||
body=body.strip(),
|
||||
response_time_ms=response_time,
|
||||
response_size_bytes=total_size
|
||||
response_time_ms=response_time
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
if "timeout" in error_str:
|
||||
timeout_seconds = self.session.get_timeout()
|
||||
return f"Request timeout ({timeout_seconds} seconds)"
|
||||
return "Request timeout (30 seconds)"
|
||||
elif "resolve" in error_str:
|
||||
return f"Could not resolve hostname: {error}"
|
||||
elif "connect" in error_str:
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
# 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
|
||||
@ -22,7 +21,7 @@ from gi.repository import Adw, Gtk, GObject
|
||||
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):
|
||||
"""Dialog for selecting a project icon."""
|
||||
|
||||
|
||||
@ -7,11 +7,9 @@
|
||||
<property name="default-width">1200</property>
|
||||
<property name="default-height">800</property>
|
||||
<property name="content">
|
||||
<object class="AdwToastOverlay" id="toast_overlay">
|
||||
<property name="child">
|
||||
<object class="GtkPaned" id="main_pane">
|
||||
<object class="GtkPaned" id="main_pane">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="position">180</property>
|
||||
<property name="position">300</property>
|
||||
<property name="shrink-start-child">False</property>
|
||||
<property name="resize-start-child">True</property>
|
||||
<property name="shrink-end-child">False</property>
|
||||
@ -32,9 +30,8 @@
|
||||
<child type="start">
|
||||
<object class="GtkLabel">
|
||||
<property name="label">Projects</property>
|
||||
<property name="margin-start">6</property>
|
||||
<style>
|
||||
<class name="title"/>
|
||||
<class name="heading"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
@ -69,6 +66,20 @@
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Save Current Request Button -->
|
||||
<child>
|
||||
<object class="GtkButton" id="save_request_button">
|
||||
<property name="label">Save Current Request</property>
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<property name="margin-bottom">12</property>
|
||||
<signal name="clicked" handler="on_save_request_clicked"/>
|
||||
<style>
|
||||
<class name="suggested-action"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
@ -85,29 +96,6 @@
|
||||
<property name="show-start-title-buttons">False</property>
|
||||
<property name="show-end-title-buttons">False</property>
|
||||
|
||||
<!-- Left side buttons -->
|
||||
<child type="start">
|
||||
<object class="GtkButton" id="save_request_button">
|
||||
<property name="icon-name">document-save-symbolic</property>
|
||||
<property name="tooltip-text">Save Current Request (Ctrl+S)</property>
|
||||
<signal name="clicked" handler="on_save_request_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child type="start">
|
||||
<object class="GtkButton" id="export_request_button">
|
||||
<property name="icon-name">export-symbolic</property>
|
||||
<property name="tooltip-text">Export as cURL</property>
|
||||
<signal name="clicked" handler="on_export_request_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Right side buttons -->
|
||||
<child type="end">
|
||||
<object class="GtkBox">
|
||||
@ -160,8 +148,7 @@
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="position">600</property>
|
||||
<property name="shrink-start-child">False</property>
|
||||
<!-- Don't allow history panel to shrink below its minimum size -->
|
||||
<property name="shrink-end-child">False</property>
|
||||
<property name="shrink-end-child">True</property>
|
||||
<property name="resize-start-child">True</property>
|
||||
<property name="resize-end-child">True</property>
|
||||
|
||||
@ -176,8 +163,6 @@
|
||||
<property name="end-child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<!-- Set minimum height to ensure header is always visible -->
|
||||
<property name="height-request">50</property>
|
||||
|
||||
<!-- History Header -->
|
||||
<child>
|
||||
@ -205,7 +190,7 @@
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">True</property>
|
||||
<property name="min-content-height">50</property>
|
||||
<property name="min-content-height">150</property>
|
||||
|
||||
<child>
|
||||
<object class="GtkListBox" id="history_listbox">
|
||||
@ -223,8 +208,6 @@
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</template>
|
||||
|
||||
|
||||
24
src/main.py
@ -26,16 +26,15 @@ gi.require_version('Adw', '1')
|
||||
from gi.repository import Gtk, Gio, Adw
|
||||
from .window import RosterWindow
|
||||
from .preferences_dialog import PreferencesDialog
|
||||
from . import constants
|
||||
|
||||
|
||||
class RosterApplication(Adw.Application):
|
||||
"""The main application singleton class."""
|
||||
|
||||
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,
|
||||
resource_base_path='/cz/bugsy/roster')
|
||||
resource_base_path='/cz/vesp/roster')
|
||||
self.version = version
|
||||
self.create_action('quit', lambda *_: self.quit(), ['<control>q'])
|
||||
self.create_action('about', self.on_about_action)
|
||||
@ -56,25 +55,14 @@ class RosterApplication(Adw.Application):
|
||||
def on_about_action(self, *args):
|
||||
"""Callback for the app.about action."""
|
||||
about = Adw.AboutDialog(application_name='Roster',
|
||||
application_icon='cz.bugsy.roster',
|
||||
application_icon='cz.vesp.roster',
|
||||
developer_name='Pavel Baksy',
|
||||
version=self.version,
|
||||
developers=['Pavel Baksy'],
|
||||
copyright='© 2025 Pavel Baksy',
|
||||
comments='HTTP client for testing APIs')
|
||||
about.set_website('https://git.bugsy.cz/beval/roster')
|
||||
about.set_issue_url('https://git.bugsy.cz/beval/roster/issues')
|
||||
about.set_website('https://github.com/pavelb/roster')
|
||||
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.
|
||||
about.set_translator_credits(_('translator-credits'))
|
||||
about.present(self.props.active_window)
|
||||
@ -89,7 +77,7 @@ class RosterApplication(Adw.Application):
|
||||
|
||||
def on_shortcuts_action(self, widget, _):
|
||||
"""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.set_transient_for(self.props.active_window)
|
||||
shortcuts_window.present()
|
||||
@ -112,7 +100,5 @@ class RosterApplication(Adw.Application):
|
||||
|
||||
def main(version):
|
||||
"""The application's entry point."""
|
||||
# Set the version constant for use throughout the application
|
||||
constants.VERSION = version
|
||||
app = RosterApplication(version=version)
|
||||
return app.run(sys.argv)
|
||||
|
||||
@ -35,29 +35,16 @@ roster_sources = [
|
||||
'http_client.py',
|
||||
'history_manager.py',
|
||||
'project_manager.py',
|
||||
'secret_manager.py',
|
||||
'tab_manager.py',
|
||||
'constants.py',
|
||||
'icon_picker_dialog.py',
|
||||
'environments_dialog.py',
|
||||
'export_dialog.py',
|
||||
'preferences_dialog.py',
|
||||
'request_tab_widget.py',
|
||||
'script_executor.py',
|
||||
]
|
||||
|
||||
install_data(roster_sources, install_dir: moduledir)
|
||||
|
||||
# Install exporters submodule
|
||||
exporters_sources = [
|
||||
'exporters/__init__.py',
|
||||
'exporters/base_exporter.py',
|
||||
'exporters/curl_exporter.py',
|
||||
'exporters/registry.py',
|
||||
]
|
||||
|
||||
install_data(exporters_sources, install_dir: moduledir / 'exporters')
|
||||
|
||||
# Install widgets submodule
|
||||
widgets_sources = [
|
||||
'widgets/__init__.py',
|
||||
@ -67,9 +54,6 @@ widgets_sources = [
|
||||
'widgets/request_item.py',
|
||||
'widgets/variable_row.py',
|
||||
'widgets/environment_row.py',
|
||||
'widgets/environment_column_header.py',
|
||||
'widgets/environment_header_row.py',
|
||||
'widgets/variable_data_row.py',
|
||||
]
|
||||
|
||||
install_data(widgets_sources, install_dir: moduledir / 'widgets')
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
# 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
|
||||
@ -20,7 +19,6 @@
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Dict, Optional, List
|
||||
from . import constants
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -32,16 +30,6 @@ class HttpRequest:
|
||||
body: str # Raw text body
|
||||
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):
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return asdict(self)
|
||||
@ -63,7 +51,6 @@ class HttpResponse:
|
||||
headers: str # Raw header text from libsoup3
|
||||
body: str # Raw body text
|
||||
response_time_ms: float
|
||||
response_size_bytes: int = 0 # Total response size in bytes
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
@ -82,36 +69,24 @@ class HistoryEntry:
|
||||
request: HttpRequest
|
||||
response: Optional[HttpResponse]
|
||||
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):
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'timestamp': self.timestamp,
|
||||
'request': self.request.to_dict(),
|
||||
'response': self.response.to_dict() if self.response else None,
|
||||
'error': self.error,
|
||||
'has_redacted_variables': self.has_redacted_variables
|
||||
'error': self.error
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data):
|
||||
"""Create instance from dictionary."""
|
||||
return cls(
|
||||
id=data.get('id'), # Backwards compatible - will generate if missing
|
||||
timestamp=data['timestamp'],
|
||||
request=HttpRequest.from_dict(data['request']),
|
||||
response=HttpResponse.from_dict(data['response']) if data.get('response') else None,
|
||||
error=data.get('error'),
|
||||
has_redacted_variables=data.get('has_redacted_variables', False)
|
||||
error=data.get('error')
|
||||
)
|
||||
|
||||
|
||||
@ -123,7 +98,6 @@ class SavedRequest:
|
||||
request: HttpRequest
|
||||
created_at: str # ISO format
|
||||
modified_at: str # ISO format
|
||||
scripts: Optional['Scripts'] = None # Scripts for preprocessing and postprocessing
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
@ -132,8 +106,7 @@ class SavedRequest:
|
||||
'name': self.name,
|
||||
'request': self.request.to_dict(),
|
||||
'created_at': self.created_at,
|
||||
'modified_at': self.modified_at,
|
||||
'scripts': self.scripts.to_dict() if self.scripts else None
|
||||
'modified_at': self.modified_at
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@ -144,8 +117,7 @@ class SavedRequest:
|
||||
name=data['name'],
|
||||
request=HttpRequest.from_dict(data['request']),
|
||||
created_at=data['created_at'],
|
||||
modified_at=data['modified_at'],
|
||||
scripts=Scripts.from_dict(data['scripts']) if data.get('scripts') else None
|
||||
modified_at=data['modified_at']
|
||||
)
|
||||
|
||||
|
||||
@ -187,7 +159,6 @@ class Project:
|
||||
icon: str = "folder-symbolic" # Icon name
|
||||
variable_names: List[str] = None # List of variable names defined for this project
|
||||
environments: List[Environment] = None # List of environments
|
||||
sensitive_variables: List[str] = None # List of variable names marked as sensitive (stored in keyring)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize optional fields."""
|
||||
@ -195,8 +166,6 @@ class Project:
|
||||
self.variable_names = []
|
||||
if self.environments is None:
|
||||
self.environments = []
|
||||
if self.sensitive_variables is None:
|
||||
self.sensitive_variables = []
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
@ -207,8 +176,7 @@ class Project:
|
||||
'created_at': self.created_at,
|
||||
'icon': self.icon,
|
||||
'variable_names': self.variable_names,
|
||||
'environments': [env.to_dict() for env in self.environments],
|
||||
'sensitive_variables': self.sensitive_variables
|
||||
'environments': [env.to_dict() for env in self.environments]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@ -221,8 +189,7 @@ class Project:
|
||||
created_at=data['created_at'],
|
||||
icon=data.get('icon', 'folder-symbolic'), # Default for old data
|
||||
variable_names=data.get('variable_names', []),
|
||||
environments=[Environment.from_dict(e) for e in data.get('environments', [])],
|
||||
sensitive_variables=data.get('sensitive_variables', []) # Default for old data
|
||||
environments=[Environment.from_dict(e) for e in data.get('environments', [])]
|
||||
)
|
||||
|
||||
|
||||
@ -238,7 +205,6 @@ class RequestTab:
|
||||
original_request: Optional[HttpRequest] = None # For change detection
|
||||
project_id: Optional[str] = None # Project association for environment variables
|
||||
selected_environment_id: Optional[str] = None # Selected environment for variable substitution
|
||||
scripts: Optional['Scripts'] = None # Scripts for preprocessing and postprocessing
|
||||
|
||||
def is_modified(self) -> bool:
|
||||
"""Check if current request differs from original."""
|
||||
@ -265,8 +231,7 @@ class RequestTab:
|
||||
'modified': self.modified,
|
||||
'original_request': self.original_request.to_dict() if self.original_request else None,
|
||||
'project_id': self.project_id,
|
||||
'selected_environment_id': self.selected_environment_id,
|
||||
'scripts': self.scripts.to_dict() if self.scripts else None
|
||||
'selected_environment_id': self.selected_environment_id
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@ -281,28 +246,5 @@ class RequestTab:
|
||||
modified=data.get('modified', False),
|
||||
original_request=HttpRequest.from_dict(data['original_request']) if data.get('original_request') else None,
|
||||
project_id=data.get('project_id'),
|
||||
selected_environment_id=data.get('selected_environment_id'),
|
||||
scripts=Scripts.from_dict(data['scripts']) if data.get('scripts') else None
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scripts:
|
||||
"""Scripts for request preprocessing and postprocessing."""
|
||||
preprocessing: str = ""
|
||||
postprocessing: str = ""
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'preprocessing': self.preprocessing,
|
||||
'postprocessing': self.postprocessing
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data):
|
||||
"""Create instance from dictionary."""
|
||||
return cls(
|
||||
preprocessing=data.get('preprocessing', ''),
|
||||
postprocessing=data.get('postprocessing', '')
|
||||
selected_environment_id=data.get('selected_environment_id')
|
||||
)
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
# 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
|
||||
@ -21,7 +20,7 @@
|
||||
from gi.repository import Adw, Gtk, Gio, GObject
|
||||
|
||||
|
||||
@Gtk.Template(resource_path='/cz/bugsy/roster/preferences-dialog.ui')
|
||||
@Gtk.Template(resource_path='/cz/vesp/roster/preferences-dialog.ui')
|
||||
class PreferencesDialog(Adw.PreferencesWindow):
|
||||
__gtype_name__ = 'PreferencesDialog'
|
||||
|
||||
@ -39,7 +38,7 @@ class PreferencesDialog(Adw.PreferencesWindow):
|
||||
self.history_manager = history_manager
|
||||
|
||||
# Get settings
|
||||
self.settings = Gio.Settings.new('cz.bugsy.roster')
|
||||
self.settings = Gio.Settings.new('cz.vesp.roster')
|
||||
|
||||
# Bind settings to UI
|
||||
self.settings.bind(
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
# 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
|
||||
@ -20,17 +19,13 @@
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Callable
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone
|
||||
import gi
|
||||
gi.require_version('GLib', '2.0')
|
||||
from gi.repository import GLib
|
||||
from .models import Project, SavedRequest, HttpRequest, Environment
|
||||
from .secret_manager import get_secret_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProjectManager:
|
||||
@ -38,50 +33,31 @@ class ProjectManager:
|
||||
|
||||
def __init__(self):
|
||||
# Use XDG data directory (works for both Flatpak and native)
|
||||
# Flatpak: ~/.var/app/cz.bugsy.roster/data/cz.bugsy.roster
|
||||
# Native: ~/.local/share/cz.bugsy.roster
|
||||
self.data_dir = Path(GLib.get_user_data_dir()) / 'cz.bugsy.roster'
|
||||
# Flatpak: ~/.var/app/cz.vesp.roster/data/cz.vesp.roster
|
||||
# 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()
|
||||
|
||||
# In-memory cache for projects to reduce disk I/O
|
||||
self._projects_cache: Optional[List[Project]] = None
|
||||
|
||||
def _ensure_data_dir(self):
|
||||
"""Create data directory if it doesn't exist."""
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def load_projects(self, force_reload: bool = False) -> List[Project]:
|
||||
"""
|
||||
Load projects from JSON file with in-memory caching.
|
||||
|
||||
Args:
|
||||
force_reload: If True, bypass cache and reload from disk
|
||||
|
||||
Returns:
|
||||
List of Project objects
|
||||
"""
|
||||
# Return cached data if available and not forcing reload
|
||||
if self._projects_cache is not None and not force_reload:
|
||||
return self._projects_cache
|
||||
|
||||
# Load from disk
|
||||
def load_projects(self) -> List[Project]:
|
||||
"""Load projects from JSON file."""
|
||||
if not self.projects_file.exists():
|
||||
self._projects_cache = []
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(self.projects_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
self._projects_cache = [Project.from_dict(p) for p in data.get('projects', [])]
|
||||
return self._projects_cache
|
||||
return [Project.from_dict(p) for p in data.get('projects', [])]
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading projects: {e}")
|
||||
self._projects_cache = []
|
||||
print(f"Error loading projects: {e}")
|
||||
return []
|
||||
|
||||
def save_projects(self, projects: List[Project]):
|
||||
"""Save projects to JSON file and update cache."""
|
||||
"""Save projects to JSON file."""
|
||||
try:
|
||||
data = {
|
||||
'version': 1,
|
||||
@ -89,11 +65,8 @@ class ProjectManager:
|
||||
}
|
||||
with open(self.projects_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
# Update cache with the saved data
|
||||
self._projects_cache = projects
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving projects: {e}")
|
||||
print(f"Error saving projects: {e}")
|
||||
|
||||
def add_project(self, name: str) -> Project:
|
||||
"""Create new project with default environment."""
|
||||
@ -132,24 +105,13 @@ class ProjectManager:
|
||||
break
|
||||
self.save_projects(projects)
|
||||
|
||||
def delete_project(self, project_id: str,
|
||||
callback: Optional[Callable[[], None]] = None):
|
||||
"""Delete a project and all its requests (async for secret cleanup)."""
|
||||
def delete_project(self, project_id: str):
|
||||
"""Delete a project and all its requests."""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
# Remove project from list and save immediately
|
||||
projects = [p for p in projects if p.id != project_id]
|
||||
self.save_projects(projects)
|
||||
|
||||
# Delete all secrets for this project asynchronously
|
||||
def on_secrets_deleted(success):
|
||||
if callback:
|
||||
callback()
|
||||
|
||||
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) -> SavedRequest:
|
||||
"""Add request to a project."""
|
||||
projects = self.load_projects()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
@ -158,8 +120,7 @@ class ProjectManager:
|
||||
name=name,
|
||||
request=request,
|
||||
created_at=now,
|
||||
modified_at=now,
|
||||
scripts=scripts
|
||||
modified_at=now
|
||||
)
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
@ -179,7 +140,7 @@ class ProjectManager:
|
||||
break
|
||||
return None
|
||||
|
||||
def update_request(self, project_id: str, request_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest:
|
||||
def update_request(self, project_id: str, request_id: str, name: str, request: HttpRequest) -> SavedRequest:
|
||||
"""Update an existing request."""
|
||||
projects = self.load_projects()
|
||||
updated_request = None
|
||||
@ -189,7 +150,6 @@ class ProjectManager:
|
||||
if req.id == request_id:
|
||||
req.name = name
|
||||
req.request = request
|
||||
req.scripts = scripts
|
||||
req.modified_at = datetime.now(timezone.utc).isoformat()
|
||||
updated_request = req
|
||||
break
|
||||
@ -244,27 +204,15 @@ class ProjectManager:
|
||||
break
|
||||
self.save_projects(projects)
|
||||
|
||||
def delete_environment(self, project_id: str, env_id: str,
|
||||
callback: Optional[Callable[[], None]] = None):
|
||||
"""Delete an environment (async for secret cleanup)."""
|
||||
def delete_environment(self, project_id: str, env_id: str):
|
||||
"""Delete an environment."""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
# Remove environment from list
|
||||
p.environments = [e for e in p.environments if e.id != env_id]
|
||||
break
|
||||
|
||||
self.save_projects(projects)
|
||||
|
||||
# Delete all secrets for this environment asynchronously
|
||||
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):
|
||||
"""Add a variable to the project and all environments."""
|
||||
projects = self.load_projects()
|
||||
@ -278,81 +226,36 @@ class ProjectManager:
|
||||
break
|
||||
self.save_projects(projects)
|
||||
|
||||
def rename_variable(self, project_id: str, old_name: str, new_name: str,
|
||||
callback: Optional[Callable[[], None]] = None):
|
||||
"""Rename a variable in the project and all environments (async for secrets)."""
|
||||
def rename_variable(self, project_id: str, old_name: str, new_name: str):
|
||||
"""Rename a variable in the project and all environments."""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
is_sensitive = False
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
# Update variable_names list
|
||||
if old_name in p.variable_names:
|
||||
idx = p.variable_names.index(old_name)
|
||||
p.variable_names[idx] = new_name
|
||||
|
||||
# Check if sensitive and update list
|
||||
if old_name in p.sensitive_variables:
|
||||
is_sensitive = True
|
||||
idx_sensitive = p.sensitive_variables.index(old_name)
|
||||
p.sensitive_variables[idx_sensitive] = new_name
|
||||
|
||||
# Update all environments
|
||||
for env in p.environments:
|
||||
if old_name in env.variables:
|
||||
env.variables[new_name] = env.variables.pop(old_name)
|
||||
break
|
||||
|
||||
self.save_projects(projects)
|
||||
|
||||
# Rename secrets in keyring if sensitive
|
||||
if is_sensitive:
|
||||
secret_manager.rename_variable_secrets(project_id, old_name, new_name, lambda success: callback() if callback else None)
|
||||
elif callback:
|
||||
callback()
|
||||
|
||||
def delete_variable(self, project_id: str, variable_name: str,
|
||||
callback: Optional[Callable[[], None]] = None):
|
||||
"""Delete a variable from the project and all environments (async for secrets)."""
|
||||
def delete_variable(self, project_id: str, variable_name: str):
|
||||
"""Delete a variable from the project and all environments."""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
is_sensitive = False
|
||||
environments_to_cleanup = []
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
# Remove from variable_names
|
||||
if variable_name in p.variable_names:
|
||||
p.variable_names.remove(variable_name)
|
||||
|
||||
# Check if sensitive and get environments for cleanup
|
||||
if variable_name in p.sensitive_variables:
|
||||
is_sensitive = True
|
||||
p.sensitive_variables.remove(variable_name)
|
||||
environments_to_cleanup = [env.id for env in p.environments]
|
||||
|
||||
# Remove from all environments
|
||||
for env in p.environments:
|
||||
env.variables.pop(variable_name, None)
|
||||
break
|
||||
|
||||
self.save_projects(projects)
|
||||
|
||||
# Delete secrets for this variable asynchronously
|
||||
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):
|
||||
"""Update a single variable value in an environment."""
|
||||
projects = self.load_projects()
|
||||
@ -364,318 +267,3 @@ class ProjectManager:
|
||||
break
|
||||
break
|
||||
self.save_projects(projects)
|
||||
|
||||
def batch_update_environment_variables(self, project_id: str, env_id: str, variables: dict):
|
||||
"""
|
||||
Update multiple variables in an environment in one operation.
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
env_id: Environment ID
|
||||
variables: Dict of variable_name -> value pairs to update
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
for env in p.environments:
|
||||
if env.id == env_id:
|
||||
# Update all variables
|
||||
for var_name, var_value in variables.items():
|
||||
env.variables[var_name] = var_value
|
||||
break
|
||||
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):
|
||||
"""
|
||||
Mark a variable as sensitive (move values from JSON to keyring) - async.
|
||||
|
||||
This will:
|
||||
1. Add variable to sensitive_variables list
|
||||
2. Move all environment values to keyring
|
||||
3. Clear values from JSON (store empty string as placeholder)
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
variable_name: Variable name to mark as sensitive
|
||||
callback: Optional callback called with success boolean
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
secrets_to_store = []
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
# Add to sensitive list if not already there
|
||||
if variable_name not in p.sensitive_variables:
|
||||
p.sensitive_variables.append(variable_name)
|
||||
|
||||
# Collect all environment values to store in keyring
|
||||
for env in p.environments:
|
||||
if variable_name in env.variables:
|
||||
value = env.variables[variable_name]
|
||||
secrets_to_store.append({
|
||||
'environment_id': env.id,
|
||||
'environment_name': env.name,
|
||||
'value': value,
|
||||
'project_name': p.name
|
||||
})
|
||||
# Clear from JSON (keep empty placeholder)
|
||||
env.variables[variable_name] = ""
|
||||
|
||||
self.save_projects(projects)
|
||||
|
||||
# Store all secrets asynchronously
|
||||
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):
|
||||
"""
|
||||
Mark a variable as non-sensitive (move values from keyring to JSON) - async.
|
||||
|
||||
This will:
|
||||
1. Remove variable from sensitive_variables list
|
||||
2. Move all environment values from keyring to JSON
|
||||
3. Delete values from keyring
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
variable_name: Variable name to mark as non-sensitive
|
||||
callback: Optional callback called with success boolean
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
target_project = None
|
||||
environments_to_migrate = []
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
target_project = p
|
||||
# Remove from sensitive list
|
||||
if variable_name in p.sensitive_variables:
|
||||
p.sensitive_variables.remove(variable_name)
|
||||
|
||||
environments_to_migrate = list(p.environments)
|
||||
break
|
||||
|
||||
if not target_project or not environments_to_migrate:
|
||||
if callback:
|
||||
callback(False)
|
||||
return
|
||||
|
||||
# Retrieve all secrets, then update JSON, then delete from keyring
|
||||
pending_count = [len(environments_to_migrate)]
|
||||
all_success = [True]
|
||||
|
||||
def on_single_migrate(env):
|
||||
def on_retrieve(value):
|
||||
# Store in JSON
|
||||
env.variables[variable_name] = value or ""
|
||||
self.save_projects(projects)
|
||||
|
||||
# Delete from keyring
|
||||
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:
|
||||
"""Check if a variable is marked as sensitive."""
|
||||
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]):
|
||||
"""
|
||||
Get variable value from appropriate storage (keyring or JSON) - async.
|
||||
|
||||
This is a convenience method that handles both sensitive and non-sensitive variables.
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
env_id: Environment ID
|
||||
variable_name: Variable name
|
||||
callback: Callback called with variable value (empty string if not found)
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
# Check if sensitive
|
||||
if variable_name in p.sensitive_variables:
|
||||
# Retrieve from keyring asynchronously
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
def on_retrieve(value):
|
||||
callback(value or "")
|
||||
|
||||
secret_manager.retrieve_secret(
|
||||
project_id=project_id,
|
||||
environment_id=env_id,
|
||||
variable_name=variable_name,
|
||||
callback=on_retrieve
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Retrieve from JSON synchronously
|
||||
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):
|
||||
"""
|
||||
Set variable value in appropriate storage (keyring or JSON) - async.
|
||||
|
||||
This is a convenience method that handles both sensitive and non-sensitive variables.
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
env_id: Environment ID
|
||||
variable_name: Variable name
|
||||
value: Value to set
|
||||
callback: Optional callback called with success boolean
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
# Check if sensitive
|
||||
if variable_name in p.sensitive_variables:
|
||||
# Store in keyring asynchronously
|
||||
for env in p.environments:
|
||||
if env.id == env_id:
|
||||
secret_manager.store_secret(
|
||||
project_id=project_id,
|
||||
environment_id=env.id,
|
||||
variable_name=variable_name,
|
||||
value=value,
|
||||
project_name=p.name,
|
||||
environment_name=env.name,
|
||||
callback=callback
|
||||
)
|
||||
# Keep empty placeholder in JSON
|
||||
env.variables[variable_name] = ""
|
||||
self.save_projects(projects)
|
||||
return
|
||||
else:
|
||||
# Store in JSON synchronously
|
||||
for env in p.environments:
|
||||
if env.id == env_id:
|
||||
env.variables[variable_name] = value
|
||||
break
|
||||
|
||||
self.save_projects(projects)
|
||||
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]):
|
||||
"""
|
||||
Get an environment with all variable values (including secrets from keyring) - async.
|
||||
|
||||
This is useful for variable substitution - it returns a complete Environment
|
||||
object with all values populated from both JSON and keyring.
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
env_id: Environment ID
|
||||
callback: Callback called with Environment object (or None if not found)
|
||||
"""
|
||||
projects = self.load_projects()
|
||||
secret_manager = get_secret_manager()
|
||||
|
||||
for p in projects:
|
||||
if p.id == project_id:
|
||||
for env in p.environments:
|
||||
if env.id == env_id:
|
||||
# Create a copy of the environment
|
||||
complete_env = Environment(
|
||||
id=env.id,
|
||||
name=env.name,
|
||||
variables=env.variables.copy(),
|
||||
created_at=env.created_at
|
||||
)
|
||||
|
||||
# If no sensitive variables, return immediately
|
||||
if not p.sensitive_variables:
|
||||
callback(complete_env)
|
||||
return
|
||||
|
||||
# Fill in sensitive variable values from keyring
|
||||
def on_secrets_retrieved(secrets_dict):
|
||||
for var_name, value in secrets_dict.items():
|
||||
if value is not None:
|
||||
complete_env.variables[var_name] = value
|
||||
callback(complete_env)
|
||||
|
||||
secret_manager.retrieve_multiple_secrets(
|
||||
project_id=project_id,
|
||||
environment_id=env_id,
|
||||
variable_names=list(p.sensitive_variables),
|
||||
callback=on_secrets_retrieved
|
||||
)
|
||||
return
|
||||
|
||||
callback(None)
|
||||
|
||||
@ -1,21 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gresources>
|
||||
<gresource prefix="/cz/bugsy/roster">
|
||||
<gresource prefix="/cz/vesp/roster">
|
||||
<file preprocess="xml-stripblanks">main-window.ui</file>
|
||||
<file preprocess="xml-stripblanks">shortcuts-dialog.ui</file>
|
||||
<file preprocess="xml-stripblanks">preferences-dialog.ui</file>
|
||||
<file preprocess="xml-stripblanks">icon-picker-dialog.ui</file>
|
||||
<file preprocess="xml-stripblanks">environments-dialog.ui</file>
|
||||
<file preprocess="xml-stripblanks">export-dialog.ui</file>
|
||||
<file alias="icons/export-symbolic.svg">../data/icons/hicolor/symbolic/apps/export-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">widgets/header-row.ui</file>
|
||||
<file preprocess="xml-stripblanks">widgets/history-item.ui</file>
|
||||
<file preprocess="xml-stripblanks">widgets/project-item.ui</file>
|
||||
<file preprocess="xml-stripblanks">widgets/request-item.ui</file>
|
||||
<file preprocess="xml-stripblanks">widgets/variable-row.ui</file>
|
||||
<file preprocess="xml-stripblanks">widgets/environment-row.ui</file>
|
||||
<file preprocess="xml-stripblanks">widgets/environment-column-header.ui</file>
|
||||
<file preprocess="xml-stripblanks">widgets/environment-header-row.ui</file>
|
||||
<file preprocess="xml-stripblanks">widgets/variable-data-row.ui</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
|
||||
@ -1,440 +0,0 @@
|
||||
# script_executor.py
|
||||
#
|
||||
# Copyright 2025 Pavel Baksy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import tempfile
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional, Dict, List, TYPE_CHECKING
|
||||
from dataclasses import dataclass, field
|
||||
from .constants import SCRIPT_EXECUTION_TIMEOUT_SECONDS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .project_manager import ProjectManager
|
||||
from .models import HttpRequest
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScriptResult:
|
||||
"""Result of script execution."""
|
||||
success: bool
|
||||
output: str # console.log output or error message
|
||||
error: Optional[str] = None
|
||||
variable_updates: Dict[str, str] = field(default_factory=dict) # Variables set by script
|
||||
warnings: List[str] = field(default_factory=list) # Warnings (e.g., no env selected)
|
||||
modified_request: Optional['HttpRequest'] = None # Modified request from preprocessing
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScriptContext:
|
||||
"""Context passed to script executor for variable updates."""
|
||||
project_id: Optional[str]
|
||||
environment_id: Optional[str]
|
||||
project_manager: 'ProjectManager'
|
||||
|
||||
|
||||
class ScriptExecutor:
|
||||
"""Executes JavaScript code using gjs (GNOME JavaScript)."""
|
||||
|
||||
TIMEOUT_SECONDS = SCRIPT_EXECUTION_TIMEOUT_SECONDS
|
||||
|
||||
@staticmethod
|
||||
def execute_postprocessing_script(
|
||||
script_code: str,
|
||||
response,
|
||||
context: Optional[ScriptContext] = None
|
||||
) -> ScriptResult:
|
||||
"""
|
||||
Execute postprocessing script with response object.
|
||||
|
||||
Args:
|
||||
script_code: JavaScript code to execute
|
||||
response: HttpResponse object
|
||||
context: Optional context for variable updates
|
||||
|
||||
Returns:
|
||||
ScriptResult with output, variable updates, or error
|
||||
"""
|
||||
if not script_code.strip():
|
||||
return ScriptResult(success=True, output="")
|
||||
|
||||
# Create response object for JavaScript
|
||||
response_obj = ScriptExecutor._create_response_object(response)
|
||||
|
||||
# Build complete script with context
|
||||
script_with_context = f"""
|
||||
const response = {json.dumps(response_obj)};
|
||||
|
||||
// Roster API for variable management
|
||||
const roster = {{
|
||||
__variables: {{}},
|
||||
setVariable: function(name, value) {{
|
||||
this.__variables[String(name)] = String(value);
|
||||
}},
|
||||
setVariables: function(obj) {{
|
||||
for (let key in obj) {{
|
||||
if (obj.hasOwnProperty(key)) {{
|
||||
this.__variables[String(key)] = String(obj[key]);
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}};
|
||||
|
||||
// Capture console.log output
|
||||
let consoleOutput = [];
|
||||
const console = {{
|
||||
log: function(...args) {{
|
||||
consoleOutput.push(args.map(arg => String(arg)).join(' '));
|
||||
}}
|
||||
}};
|
||||
|
||||
// User script
|
||||
try {{
|
||||
{script_code}
|
||||
}} catch (error) {{
|
||||
consoleOutput.push('Error: ' + error.message);
|
||||
throw error;
|
||||
}}
|
||||
|
||||
// Output results: console logs + separator + variables JSON
|
||||
print(consoleOutput.join('\\n'));
|
||||
print('___ROSTER_VARIABLES___');
|
||||
print(JSON.stringify(roster.__variables));
|
||||
"""
|
||||
|
||||
# Execute with gjs
|
||||
output, error, returncode = ScriptExecutor._run_gjs_script(
|
||||
script_with_context,
|
||||
timeout=ScriptExecutor.TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
# Parse output and extract variables
|
||||
console_output = ""
|
||||
variable_updates = {}
|
||||
warnings = []
|
||||
|
||||
if returncode == 0:
|
||||
# Split output into console logs and variables JSON
|
||||
parts = output.split('___ROSTER_VARIABLES___')
|
||||
console_output = parts[0].strip()
|
||||
|
||||
if len(parts) > 1:
|
||||
try:
|
||||
variables_json = parts[1].strip()
|
||||
raw_variables = json.loads(variables_json)
|
||||
|
||||
# Validate variable names and add to updates
|
||||
for name, value in raw_variables.items():
|
||||
if ScriptExecutor._is_valid_variable_name(name):
|
||||
variable_updates[name] = value
|
||||
else:
|
||||
warnings.append(f"Invalid variable name '{name}' (must be alphanumeric + underscore)")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
warnings.append("Failed to parse variable updates from script")
|
||||
|
||||
return ScriptResult(
|
||||
success=True,
|
||||
output=console_output,
|
||||
variable_updates=variable_updates,
|
||||
warnings=warnings
|
||||
)
|
||||
else:
|
||||
error_msg = error.strip() if error else "Script execution failed"
|
||||
return ScriptResult(success=False, output="", error=error_msg)
|
||||
|
||||
@staticmethod
|
||||
def execute_preprocessing_script(
|
||||
script_code: str,
|
||||
request,
|
||||
context: Optional[ScriptContext] = None
|
||||
) -> ScriptResult:
|
||||
"""
|
||||
Execute preprocessing script with request object.
|
||||
|
||||
Args:
|
||||
script_code: JavaScript code to execute
|
||||
request: HttpRequest object (will be modified)
|
||||
context: Optional context for variable access and updates
|
||||
|
||||
Returns:
|
||||
ScriptResult with output, modified request, variable updates, or error
|
||||
"""
|
||||
if not script_code.strip():
|
||||
return ScriptResult(success=True, output="")
|
||||
|
||||
# Import here to avoid circular import
|
||||
from .models import HttpRequest
|
||||
|
||||
# Create request object for JavaScript
|
||||
request_obj = ScriptExecutor._create_request_object(request)
|
||||
|
||||
# Get environment variables (read-only)
|
||||
env_vars = ScriptExecutor._get_environment_variables(context)
|
||||
|
||||
# Get project metadata
|
||||
project_meta = ScriptExecutor._get_project_metadata(context)
|
||||
|
||||
# Build complete script with context
|
||||
script_with_context = f"""
|
||||
const request = {json.dumps(request_obj)};
|
||||
|
||||
// Roster API for variable management
|
||||
const roster = {{
|
||||
__variables: {{}}, // For setVariable
|
||||
__envVariables: {json.dumps(env_vars)}, // Read-only current environment
|
||||
|
||||
getVariable: function(name) {{
|
||||
return this.__envVariables[name] || null;
|
||||
}},
|
||||
|
||||
setVariable: function(name, value) {{
|
||||
this.__variables[String(name)] = String(value);
|
||||
}},
|
||||
|
||||
setVariables: function(obj) {{
|
||||
for (let key in obj) {{
|
||||
if (obj.hasOwnProperty(key)) {{
|
||||
this.__variables[String(key)] = String(obj[key]);
|
||||
}}
|
||||
}}
|
||||
}},
|
||||
|
||||
project: {json.dumps(project_meta)}
|
||||
}};
|
||||
|
||||
// Capture console.log output
|
||||
let consoleOutput = [];
|
||||
const console = {{
|
||||
log: function(...args) {{
|
||||
consoleOutput.push(args.map(arg => String(arg)).join(' '));
|
||||
}}
|
||||
}};
|
||||
|
||||
// User script
|
||||
try {{
|
||||
{script_code}
|
||||
}} catch (error) {{
|
||||
consoleOutput.push('Error: ' + error.message);
|
||||
throw error;
|
||||
}}
|
||||
|
||||
// Output results: modified request + separator + console logs + variables
|
||||
print(JSON.stringify(request));
|
||||
print('___ROSTER_SEPARATOR___');
|
||||
print(consoleOutput.join('\\n'));
|
||||
print('___ROSTER_VARIABLES___');
|
||||
print(JSON.stringify(roster.__variables));
|
||||
"""
|
||||
|
||||
# Execute with gjs
|
||||
output, error, returncode = ScriptExecutor._run_gjs_script(
|
||||
script_with_context,
|
||||
timeout=ScriptExecutor.TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
# Parse output and extract results
|
||||
console_output = ""
|
||||
variable_updates = {}
|
||||
warnings = []
|
||||
modified_request = None
|
||||
|
||||
if returncode == 0:
|
||||
# Split output into: request JSON + console logs + variables JSON
|
||||
parts = output.split('___ROSTER_SEPARATOR___')
|
||||
if len(parts) >= 2:
|
||||
request_json_str = parts[0].strip()
|
||||
rest = parts[1]
|
||||
|
||||
# Parse modified request
|
||||
try:
|
||||
request_data = json.loads(request_json_str)
|
||||
|
||||
# Validate required fields
|
||||
if not request_data.get('url') or not request_data.get('url').strip():
|
||||
warnings.append("Modified request has empty URL")
|
||||
return ScriptResult(
|
||||
success=False,
|
||||
output="",
|
||||
error="Script produced invalid request: URL is empty",
|
||||
warnings=warnings
|
||||
)
|
||||
|
||||
# Create HttpRequest from modified data
|
||||
modified_request = HttpRequest(
|
||||
method=request_data.get('method', 'GET'),
|
||||
url=request_data.get('url', ''),
|
||||
headers=request_data.get('headers', {}),
|
||||
body=request_data.get('body', ''),
|
||||
syntax=request.syntax # Preserve syntax from original
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
warnings.append(f"Failed to parse modified request: {str(e)}")
|
||||
return ScriptResult(
|
||||
success=False,
|
||||
output="",
|
||||
error="Script produced invalid request JSON",
|
||||
warnings=warnings
|
||||
)
|
||||
|
||||
# Parse console output and variables
|
||||
var_parts = rest.split('___ROSTER_VARIABLES___')
|
||||
console_output = var_parts[0].strip()
|
||||
|
||||
if len(var_parts) > 1:
|
||||
try:
|
||||
variables_json = var_parts[1].strip()
|
||||
raw_variables = json.loads(variables_json)
|
||||
|
||||
# Validate variable names and add to updates
|
||||
for name, value in raw_variables.items():
|
||||
if ScriptExecutor._is_valid_variable_name(name):
|
||||
variable_updates[name] = value
|
||||
else:
|
||||
warnings.append(f"Invalid variable name '{name}' (must be alphanumeric + underscore)")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
warnings.append("Failed to parse variable updates from script")
|
||||
|
||||
return ScriptResult(
|
||||
success=True,
|
||||
output=console_output,
|
||||
variable_updates=variable_updates,
|
||||
warnings=warnings,
|
||||
modified_request=modified_request
|
||||
)
|
||||
else:
|
||||
error_msg = error.strip() if error else "Script execution failed"
|
||||
return ScriptResult(success=False, output="", error=error_msg)
|
||||
|
||||
@staticmethod
|
||||
def _create_response_object(response) -> dict:
|
||||
"""Convert HttpResponse to JavaScript-compatible dict."""
|
||||
# Parse headers text into dict
|
||||
headers = {}
|
||||
if response.headers:
|
||||
for line in response.headers.split('\n')[1:]: # Skip status line
|
||||
line = line.strip()
|
||||
if ':' in line:
|
||||
key, value = line.split(':', 1)
|
||||
headers[key.strip()] = value.strip()
|
||||
|
||||
return {
|
||||
'body': response.body,
|
||||
'headers': headers,
|
||||
'statusCode': response.status_code,
|
||||
'statusText': response.status_text,
|
||||
'responseTime': response.response_time_ms
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _create_request_object(request) -> dict:
|
||||
"""Convert HttpRequest to JavaScript-compatible dict."""
|
||||
return {
|
||||
'method': request.method,
|
||||
'url': request.url,
|
||||
'headers': dict(request.headers), # Convert to regular dict
|
||||
'body': request.body
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_environment_variables(context: Optional[ScriptContext]) -> dict:
|
||||
"""Get environment variables from context."""
|
||||
if not context or not context.project_id or not context.environment_id:
|
||||
return {}
|
||||
|
||||
# Load projects and find the environment
|
||||
projects = context.project_manager.load_projects()
|
||||
for project in projects:
|
||||
if project.id == context.project_id:
|
||||
for env in project.environments:
|
||||
if env.id == context.environment_id:
|
||||
return env.variables
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _get_project_metadata(context: Optional[ScriptContext]) -> dict:
|
||||
"""Get project metadata (name and environments)."""
|
||||
if not context or not context.project_id:
|
||||
return {'name': '', 'environments': []}
|
||||
|
||||
# Load projects and find the project
|
||||
projects = context.project_manager.load_projects()
|
||||
for project in projects:
|
||||
if project.id == context.project_id:
|
||||
return {
|
||||
'name': project.name,
|
||||
'environments': [env.name for env in project.environments]
|
||||
}
|
||||
return {'name': '', 'environments': []}
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_variable_name(name: str) -> bool:
|
||||
"""
|
||||
Validate variable name (alphanumeric + underscore).
|
||||
|
||||
Args:
|
||||
name: Variable name to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
return bool(re.match(r'^\w+$', name))
|
||||
|
||||
@staticmethod
|
||||
def _run_gjs_script(script_code: str, timeout: int = 5) -> Tuple[str, str, int]:
|
||||
"""
|
||||
Run JavaScript code using gjs.
|
||||
|
||||
Args:
|
||||
script_code: Complete JavaScript code
|
||||
timeout: Execution timeout in seconds
|
||||
|
||||
Returns:
|
||||
Tuple of (stdout, stderr, returncode)
|
||||
"""
|
||||
try:
|
||||
# Write script to temporary file
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False) as f:
|
||||
f.write(script_code)
|
||||
script_path = f.name
|
||||
|
||||
try:
|
||||
# Execute with gjs
|
||||
result = subprocess.run(
|
||||
['gjs', script_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
return result.stdout, result.stderr, result.returncode
|
||||
|
||||
finally:
|
||||
# Clean up temp file
|
||||
Path(script_path).unlink(missing_ok=True)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", f"Script execution timeout ({timeout}s)", 1
|
||||
except FileNotFoundError:
|
||||
return "", "gjs not found. Please ensure GNOME JavaScript is installed.", 1
|
||||
except Exception as e:
|
||||
return "", f"Script execution error: {str(e)}", 1
|
||||
@ -1,455 +0,0 @@
|
||||
# secret_manager.py
|
||||
#
|
||||
# Copyright 2025 Pavel Baksy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
"""
|
||||
Manager for storing and retrieving sensitive variable values using GNOME Keyring.
|
||||
|
||||
This module provides a clean interface to libsecret for storing environment
|
||||
variable values that contain sensitive data (API keys, passwords, tokens).
|
||||
|
||||
Uses async APIs for compatibility with the XDG Secrets Portal in sandboxed
|
||||
environments (Flatpak).
|
||||
|
||||
Each secret is identified by:
|
||||
- project_id: UUID of the project
|
||||
- environment_id: UUID of the environment
|
||||
- variable_name: Name of the variable
|
||||
"""
|
||||
|
||||
import gi
|
||||
gi.require_version('Secret', '1')
|
||||
from gi.repository import Secret, GLib, Gio
|
||||
import logging
|
||||
from typing import Callable, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define schema for Roster environment variables
|
||||
# This separates them from other secrets in GNOME Keyring
|
||||
ROSTER_SCHEMA = Secret.Schema.new(
|
||||
"cz.bugsy.roster.EnvironmentVariable",
|
||||
Secret.SchemaFlags.NONE,
|
||||
{
|
||||
"project_id": Secret.SchemaAttributeType.STRING,
|
||||
"environment_id": Secret.SchemaAttributeType.STRING,
|
||||
"variable_name": Secret.SchemaAttributeType.STRING,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SecretManager:
|
||||
"""Manages sensitive variable storage using GNOME Keyring via libsecret.
|
||||
|
||||
All methods use async APIs for compatibility with the XDG Secrets Portal.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the secret manager."""
|
||||
self.schema = ROSTER_SCHEMA
|
||||
|
||||
def store_secret(self, project_id: str, environment_id: str,
|
||||
variable_name: str, value: str, project_name: str = "",
|
||||
environment_name: str = "",
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
"""
|
||||
Store a sensitive variable value in GNOME Keyring (async).
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project
|
||||
environment_id: UUID of the environment
|
||||
variable_name: Name of the variable
|
||||
value: The secret value to store
|
||||
project_name: Optional human-readable project name (for display in Seahorse)
|
||||
environment_name: Optional human-readable environment name (for display)
|
||||
callback: Optional callback function called with success boolean
|
||||
"""
|
||||
# Create a descriptive label for the keyring UI
|
||||
label = f"Roster: {project_name}/{environment_name}/{variable_name}" if project_name else \
|
||||
f"Roster Variable: {variable_name}"
|
||||
|
||||
# Build attributes dictionary for the secret
|
||||
attributes = {
|
||||
"project_id": project_id,
|
||||
"environment_id": environment_id,
|
||||
"variable_name": variable_name,
|
||||
}
|
||||
|
||||
def on_store_complete(source, result, user_data):
|
||||
try:
|
||||
Secret.password_store_finish(result)
|
||||
logger.info(f"Stored secret for {variable_name} in {environment_id}")
|
||||
if callback:
|
||||
callback(True)
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Failed to store secret: {e.message}")
|
||||
if callback:
|
||||
callback(False)
|
||||
|
||||
# Store the secret asynchronously
|
||||
Secret.password_store(
|
||||
self.schema,
|
||||
attributes,
|
||||
Secret.COLLECTION_DEFAULT,
|
||||
label,
|
||||
value,
|
||||
None, # cancellable
|
||||
on_store_complete,
|
||||
None # user_data
|
||||
)
|
||||
|
||||
def retrieve_secret(self, project_id: str, environment_id: str,
|
||||
variable_name: str,
|
||||
callback: Callable[[Optional[str]], None]):
|
||||
"""
|
||||
Retrieve a sensitive variable value from GNOME Keyring (async).
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project
|
||||
environment_id: UUID of the environment
|
||||
variable_name: Name of the variable
|
||||
callback: Callback function called with the secret value (or None if not found)
|
||||
"""
|
||||
attributes = {
|
||||
"project_id": project_id,
|
||||
"environment_id": environment_id,
|
||||
"variable_name": variable_name,
|
||||
}
|
||||
|
||||
def on_lookup_complete(source, result, user_data):
|
||||
try:
|
||||
value = Secret.password_lookup_finish(result)
|
||||
if value is None:
|
||||
logger.debug(f"No secret found for {variable_name}")
|
||||
else:
|
||||
logger.debug(f"Retrieved secret for {variable_name}")
|
||||
callback(value)
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Failed to retrieve secret: {e.message}")
|
||||
callback(None)
|
||||
|
||||
# Retrieve the secret asynchronously
|
||||
Secret.password_lookup(
|
||||
self.schema,
|
||||
attributes,
|
||||
None, # cancellable
|
||||
on_lookup_complete,
|
||||
None # user_data
|
||||
)
|
||||
|
||||
def delete_secret(self, project_id: str, environment_id: str,
|
||||
variable_name: str,
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
"""
|
||||
Delete a sensitive variable value from GNOME Keyring (async).
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project
|
||||
environment_id: UUID of the environment
|
||||
variable_name: Name of the variable
|
||||
callback: Optional callback function called with success boolean
|
||||
"""
|
||||
attributes = {
|
||||
"project_id": project_id,
|
||||
"environment_id": environment_id,
|
||||
"variable_name": variable_name,
|
||||
}
|
||||
|
||||
def on_clear_complete(source, result, user_data):
|
||||
try:
|
||||
deleted = Secret.password_clear_finish(result)
|
||||
if deleted:
|
||||
logger.info(f"Deleted secret for {variable_name}")
|
||||
else:
|
||||
logger.debug(f"No secret to delete for {variable_name}")
|
||||
if callback:
|
||||
callback(True)
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Failed to delete secret: {e.message}")
|
||||
if callback:
|
||||
callback(False)
|
||||
|
||||
# Delete the secret asynchronously
|
||||
Secret.password_clear(
|
||||
self.schema,
|
||||
attributes,
|
||||
None, # cancellable
|
||||
on_clear_complete,
|
||||
None # user_data
|
||||
)
|
||||
|
||||
def delete_all_project_secrets(self, project_id: str,
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
"""
|
||||
Delete all secrets for a project (when project is deleted).
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project
|
||||
callback: Optional callback function called with success boolean
|
||||
"""
|
||||
attributes = {
|
||||
"project_id": project_id,
|
||||
}
|
||||
|
||||
def on_clear_complete(source, result, user_data):
|
||||
try:
|
||||
Secret.password_clear_finish(result)
|
||||
logger.info(f"Deleted all secrets for project {project_id}")
|
||||
if callback:
|
||||
callback(True)
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Failed to delete project secrets: {e.message}")
|
||||
if callback:
|
||||
callback(False)
|
||||
|
||||
# Delete all matching secrets asynchronously
|
||||
Secret.password_clear(
|
||||
self.schema,
|
||||
attributes,
|
||||
None,
|
||||
on_clear_complete,
|
||||
None
|
||||
)
|
||||
|
||||
def delete_all_environment_secrets(self, project_id: str, environment_id: str,
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
"""
|
||||
Delete all secrets for an environment (when environment is deleted).
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project
|
||||
environment_id: UUID of the environment
|
||||
callback: Optional callback function called with success boolean
|
||||
"""
|
||||
attributes = {
|
||||
"project_id": project_id,
|
||||
"environment_id": environment_id,
|
||||
}
|
||||
|
||||
def on_clear_complete(source, result, user_data):
|
||||
try:
|
||||
Secret.password_clear_finish(result)
|
||||
logger.info(f"Deleted all secrets for environment {environment_id}")
|
||||
if callback:
|
||||
callback(True)
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Failed to delete environment secrets: {e.message}")
|
||||
if callback:
|
||||
callback(False)
|
||||
|
||||
Secret.password_clear(
|
||||
self.schema,
|
||||
attributes,
|
||||
None,
|
||||
on_clear_complete,
|
||||
None
|
||||
)
|
||||
|
||||
def rename_variable_secrets(self, project_id: str, old_name: str, new_name: str,
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
"""
|
||||
Rename a variable across all environments (updates secret keys).
|
||||
|
||||
This is called when a variable is renamed. We need to:
|
||||
1. Find all secrets with old_name
|
||||
2. Store them with new_name
|
||||
3. Delete old secrets
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project
|
||||
old_name: Current variable name
|
||||
new_name: New variable name
|
||||
callback: Optional callback function called with success boolean
|
||||
"""
|
||||
attributes = {
|
||||
"project_id": project_id,
|
||||
"variable_name": old_name,
|
||||
}
|
||||
|
||||
def on_search_complete(source, result, user_data):
|
||||
try:
|
||||
secrets = Secret.password_search_finish(result)
|
||||
|
||||
if not secrets:
|
||||
logger.debug(f"No secrets found for variable {old_name}")
|
||||
if callback:
|
||||
callback(True)
|
||||
return
|
||||
|
||||
# Track how many secrets we need to process
|
||||
pending_count = [len(secrets)]
|
||||
all_success = [True]
|
||||
|
||||
def on_rename_done(success):
|
||||
pending_count[0] -= 1
|
||||
if not success:
|
||||
all_success[0] = False
|
||||
if pending_count[0] == 0:
|
||||
logger.info(f"Renamed variable secrets from {old_name} to {new_name}")
|
||||
if callback:
|
||||
callback(all_success[0])
|
||||
|
||||
# For each secret, retrieve value, store with new name, delete old
|
||||
for item in secrets:
|
||||
self._rename_single_secret(item, project_id, old_name, new_name, on_rename_done)
|
||||
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Failed to search for variable secrets: {e.message}")
|
||||
if callback:
|
||||
callback(False)
|
||||
|
||||
# Search for matching secrets asynchronously
|
||||
Secret.password_search(
|
||||
self.schema,
|
||||
attributes,
|
||||
Secret.SearchFlags.ALL,
|
||||
None,
|
||||
on_search_complete,
|
||||
None
|
||||
)
|
||||
|
||||
def _rename_single_secret(self, item, project_id: str, old_name: str,
|
||||
new_name: str, callback: Callable[[bool], None]):
|
||||
"""Helper to rename a single secret item."""
|
||||
|
||||
def on_load_complete(item, result, user_data):
|
||||
try:
|
||||
item.load_secret_finish(result)
|
||||
secret = item.get_secret()
|
||||
if secret is None:
|
||||
callback(False)
|
||||
return
|
||||
|
||||
value = secret.get_text()
|
||||
attrs = item.get_attributes()
|
||||
environment_id = attrs.get("environment_id")
|
||||
|
||||
if not environment_id:
|
||||
logger.warning("Secret missing environment_id, skipping")
|
||||
callback(False)
|
||||
return
|
||||
|
||||
# Store with new name, then delete old
|
||||
def on_store_done(success):
|
||||
if success:
|
||||
self.delete_secret(project_id, environment_id, old_name, callback)
|
||||
else:
|
||||
callback(False)
|
||||
|
||||
self.store_secret(
|
||||
project_id=project_id,
|
||||
environment_id=environment_id,
|
||||
variable_name=new_name,
|
||||
value=value,
|
||||
callback=on_store_done
|
||||
)
|
||||
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Failed to load secret for rename: {e.message}")
|
||||
callback(False)
|
||||
|
||||
item.load_secret(None, on_load_complete, None)
|
||||
|
||||
# ========== Batch Operations ==========
|
||||
|
||||
def retrieve_multiple_secrets(self, project_id: str, environment_id: str,
|
||||
variable_names: list,
|
||||
callback: Callable[[dict], None]):
|
||||
"""
|
||||
Retrieve multiple secrets at once (async).
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project
|
||||
environment_id: UUID of the environment
|
||||
variable_names: List of variable names to retrieve
|
||||
callback: Callback function called with dict of {variable_name: value}
|
||||
"""
|
||||
if not variable_names:
|
||||
callback({})
|
||||
return
|
||||
|
||||
results = {}
|
||||
pending_count = [len(variable_names)]
|
||||
|
||||
def on_single_retrieve(var_name):
|
||||
def handler(value):
|
||||
results[var_name] = value
|
||||
pending_count[0] -= 1
|
||||
if pending_count[0] == 0:
|
||||
callback(results)
|
||||
return handler
|
||||
|
||||
for var_name in variable_names:
|
||||
self.retrieve_secret(
|
||||
project_id=project_id,
|
||||
environment_id=environment_id,
|
||||
variable_name=var_name,
|
||||
callback=on_single_retrieve(var_name)
|
||||
)
|
||||
|
||||
def store_multiple_secrets(self, project_id: str, environment_id: str,
|
||||
variables: dict, project_name: str = "",
|
||||
environment_name: str = "",
|
||||
callback: Optional[Callable[[bool], None]] = None):
|
||||
"""
|
||||
Store multiple secrets at once (async).
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project
|
||||
environment_id: UUID of the environment
|
||||
variables: Dict of {variable_name: value} to store
|
||||
project_name: Optional project name for display
|
||||
environment_name: Optional environment name for display
|
||||
callback: Optional callback called with overall success boolean
|
||||
"""
|
||||
if not variables:
|
||||
if callback:
|
||||
callback(True)
|
||||
return
|
||||
|
||||
pending_count = [len(variables)]
|
||||
all_success = [True]
|
||||
|
||||
def on_single_store(success):
|
||||
if not success:
|
||||
all_success[0] = False
|
||||
pending_count[0] -= 1
|
||||
if pending_count[0] == 0 and callback:
|
||||
callback(all_success[0])
|
||||
|
||||
for var_name, value in variables.items():
|
||||
self.store_secret(
|
||||
project_id=project_id,
|
||||
environment_id=environment_id,
|
||||
variable_name=var_name,
|
||||
value=value,
|
||||
project_name=project_name,
|
||||
environment_name=environment_name,
|
||||
callback=on_single_store
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_secret_manager = None
|
||||
|
||||
def get_secret_manager() -> SecretManager:
|
||||
"""Get the global SecretManager instance."""
|
||||
global _secret_manager
|
||||
if _secret_manager is None:
|
||||
_secret_manager = SecretManager()
|
||||
return _secret_manager
|
||||
@ -26,18 +26,6 @@
|
||||
<property name="accelerator"><Control>s</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title" translatable="yes" context="shortcut window">Focus URL Field</property>
|
||||
<property name="accelerator"><Control>l</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title" translatable="yes" context="shortcut window">Send Request</property>
|
||||
<property name="accelerator"><Control>Return</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title" translatable="yes" context="shortcut window">Show Shortcuts</property>
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (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
|
||||
@ -21,13 +20,10 @@
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from .models import RequestTab, HttpRequest, HttpResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TabManager:
|
||||
"""Manages open request tabs."""
|
||||
@ -40,8 +36,7 @@ class TabManager:
|
||||
saved_request_id: Optional[str] = None,
|
||||
response: Optional[HttpResponse] = None,
|
||||
project_id: Optional[str] = None,
|
||||
selected_environment_id: Optional[str] = None,
|
||||
scripts=None) -> RequestTab:
|
||||
selected_environment_id: Optional[str] = None) -> RequestTab:
|
||||
"""Create a new tab and add it to the collection."""
|
||||
tab_id = str(uuid.uuid4())
|
||||
|
||||
@ -49,10 +44,7 @@ class TabManager:
|
||||
# Make a copy to avoid reference issues
|
||||
# Create original for saved requests or loaded history items (anything with content)
|
||||
# Only skip for truly blank new requests
|
||||
# Consider headers empty if they only contain default 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)
|
||||
has_content = bool(request.url or request.body or request.headers)
|
||||
original_request = HttpRequest(
|
||||
method=request.method,
|
||||
url=request.url,
|
||||
@ -70,8 +62,7 @@ class TabManager:
|
||||
modified=False,
|
||||
original_request=original_request,
|
||||
project_id=project_id,
|
||||
selected_environment_id=selected_environment_id,
|
||||
scripts=scripts
|
||||
selected_environment_id=selected_environment_id
|
||||
)
|
||||
|
||||
self.tabs.append(tab)
|
||||
@ -154,6 +145,6 @@ class TabManager:
|
||||
|
||||
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
||||
# 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.active_tab_id = None
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
# 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
|
||||
@ -65,11 +64,8 @@ class VariableSubstitution:
|
||||
var_name = match.group(1)
|
||||
if var_name in variables:
|
||||
value = variables[var_name]
|
||||
# Check if value is empty/None - treat as undefined for warning purposes
|
||||
if not value:
|
||||
undefined_vars.append(var_name)
|
||||
return ""
|
||||
return value
|
||||
# Replace with value or empty string if value is None/empty
|
||||
return value if value else ""
|
||||
else:
|
||||
# Variable not defined - track it and replace with empty string
|
||||
undefined_vars.append(var_name)
|
||||
@ -127,118 +123,3 @@ class VariableSubstitution:
|
||||
)
|
||||
|
||||
return new_request, all_undefined
|
||||
|
||||
@staticmethod
|
||||
def substitute_excluding_variables(text: str, variables: Dict[str, str], excluded_vars: List[str]) -> Tuple[str, List[str]]:
|
||||
"""
|
||||
Replace {{variable_name}} with values, but skip excluded variables (leave as {{var}}).
|
||||
|
||||
Args:
|
||||
text: The text containing variable placeholders
|
||||
variables: Dictionary mapping variable names to their values
|
||||
excluded_vars: List of variable names to skip (leave as placeholders)
|
||||
|
||||
Returns:
|
||||
Tuple of (substituted_text, list_of_undefined_variables)
|
||||
"""
|
||||
if not text:
|
||||
return text, []
|
||||
|
||||
undefined_vars = []
|
||||
|
||||
def replace_var(match):
|
||||
var_name = match.group(1)
|
||||
|
||||
# Skip excluded variables - leave as {{var_name}}
|
||||
if var_name in excluded_vars:
|
||||
return match.group(0) # Return original {{var_name}}
|
||||
|
||||
# Substitute non-excluded variables
|
||||
if var_name in variables:
|
||||
value = variables[var_name]
|
||||
# Check if value is empty/None - treat as undefined for warning purposes
|
||||
if not value:
|
||||
undefined_vars.append(var_name)
|
||||
return ""
|
||||
return value
|
||||
else:
|
||||
# Variable not defined - track it and replace with empty string
|
||||
undefined_vars.append(var_name)
|
||||
return ""
|
||||
|
||||
substituted = VariableSubstitution.VARIABLE_PATTERN.sub(replace_var, text)
|
||||
return substituted, undefined_vars
|
||||
|
||||
@staticmethod
|
||||
def substitute_request_excluding_sensitive(
|
||||
request: HttpRequest,
|
||||
environment: Environment,
|
||||
sensitive_variables: List[str]
|
||||
) -> Tuple[HttpRequest, Set[str], bool]:
|
||||
"""
|
||||
Substitute variables in request, but exclude sensitive variables (leave as {{var}}).
|
||||
|
||||
This is useful for saving requests to history without exposing sensitive values.
|
||||
Sensitive variables remain in their {{variable_name}} placeholder form.
|
||||
|
||||
Args:
|
||||
request: The HTTP request with variable placeholders
|
||||
environment: The environment containing variable values
|
||||
sensitive_variables: List of variable names to exclude from substitution
|
||||
|
||||
Returns:
|
||||
Tuple of (redacted_request, set_of_undefined_variables, has_sensitive_vars)
|
||||
- redacted_request: Request with only non-sensitive variables substituted
|
||||
- set_of_undefined_variables: Variables that were referenced but not defined
|
||||
- has_sensitive_vars: True if any sensitive variables were found and redacted
|
||||
"""
|
||||
all_undefined = set()
|
||||
|
||||
# Substitute URL (excluding sensitive variables)
|
||||
new_url, url_undefined = VariableSubstitution.substitute_excluding_variables(
|
||||
request.url, environment.variables, sensitive_variables
|
||||
)
|
||||
all_undefined.update(url_undefined)
|
||||
|
||||
# Substitute headers (both keys and values, excluding sensitive variables)
|
||||
new_headers = {}
|
||||
for key, value in request.headers.items():
|
||||
new_key, key_undefined = VariableSubstitution.substitute_excluding_variables(
|
||||
key, environment.variables, sensitive_variables
|
||||
)
|
||||
new_value, value_undefined = VariableSubstitution.substitute_excluding_variables(
|
||||
value, environment.variables, sensitive_variables
|
||||
)
|
||||
all_undefined.update(key_undefined)
|
||||
all_undefined.update(value_undefined)
|
||||
new_headers[new_key] = new_value
|
||||
|
||||
# Substitute body (excluding sensitive variables)
|
||||
new_body, body_undefined = VariableSubstitution.substitute_excluding_variables(
|
||||
request.body, environment.variables, sensitive_variables
|
||||
)
|
||||
all_undefined.update(body_undefined)
|
||||
|
||||
# Create new HttpRequest with redacted values
|
||||
redacted_request = HttpRequest(
|
||||
method=request.method,
|
||||
url=new_url,
|
||||
headers=new_headers,
|
||||
body=new_body,
|
||||
syntax=request.syntax
|
||||
)
|
||||
|
||||
# Check if any sensitive variables were actually used in the request
|
||||
all_vars_in_request = set()
|
||||
all_vars_in_request.update(VariableSubstitution.find_variables(request.url))
|
||||
all_vars_in_request.update(VariableSubstitution.find_variables(request.body))
|
||||
for key, value in request.headers.items():
|
||||
all_vars_in_request.update(VariableSubstitution.find_variables(key))
|
||||
all_vars_in_request.update(VariableSubstitution.find_variables(value))
|
||||
|
||||
# Determine if any sensitive variables were actually present
|
||||
has_sensitive_vars = bool(
|
||||
set(sensitive_variables) & all_vars_in_request
|
||||
)
|
||||
|
||||
return redacted_request, all_undefined, has_sensitive_vars
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (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
|
||||
@ -24,18 +23,5 @@ from .project_item import ProjectItem
|
||||
from .request_item import RequestItem
|
||||
from .variable_row import VariableRow
|
||||
from .environment_row import EnvironmentRow
|
||||
from .environment_column_header import EnvironmentColumnHeader
|
||||
from .environment_header_row import EnvironmentHeaderRow
|
||||
from .variable_data_row import VariableDataRow
|
||||
|
||||
__all__ = [
|
||||
'HeaderRow',
|
||||
'HistoryItem',
|
||||
'ProjectItem',
|
||||
'RequestItem',
|
||||
'VariableRow',
|
||||
'EnvironmentRow',
|
||||
'EnvironmentColumnHeader',
|
||||
'EnvironmentHeaderRow',
|
||||
'VariableDataRow',
|
||||
]
|
||||
__all__ = ['HeaderRow', 'HistoryItem', 'ProjectItem', 'RequestItem', 'VariableRow', 'EnvironmentRow']
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<template class="EnvironmentColumnHeader" parent="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">4</property>
|
||||
<property name="width-request">200</property>
|
||||
|
||||
<child>
|
||||
<object class="GtkLabel" id="name_label">
|
||||
<property name="xalign">0.5</property>
|
||||
<property name="ellipsize">end</property>
|
||||
<property name="max-width-chars">20</property>
|
||||
<style>
|
||||
<class name="heading"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="spacing">4</property>
|
||||
|
||||
<child>
|
||||
<object class="GtkButton" id="edit_button">
|
||||
<property name="icon-name">document-properties-symbolic</property>
|
||||
<property name="tooltip-text">Edit environment</property>
|
||||
<signal name="clicked" handler="on_edit_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
<class name="circular"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkButton" id="delete_button">
|
||||
<property name="icon-name">edit-delete-symbolic</property>
|
||||
<property name="tooltip-text">Delete environment</property>
|
||||
<signal name="clicked" handler="on_delete_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
<class name="circular"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</template>
|
||||
</interface>
|
||||
@ -1,39 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<template class="EnvironmentHeaderRow" parent="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">12</property>
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<property name="margin-top">12</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
|
||||
<child>
|
||||
<object class="GtkBox" id="variable_cell">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="width-request">150</property>
|
||||
|
||||
<child>
|
||||
<object class="GtkLabel" id="variable_label">
|
||||
<property name="label">Variable</property>
|
||||
<property name="xalign">0</property>
|
||||
<property name="hexpand">True</property>
|
||||
<style>
|
||||
<class name="heading"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkBox" id="columns_box">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="hexpand">True</property>
|
||||
</object>
|
||||
</child>
|
||||
</template>
|
||||
</interface>
|
||||
@ -1,56 +0,0 @@
|
||||
# environment_column_header.py
|
||||
#
|
||||
# Copyright 2025 Pavel Baksy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from gi.repository import Gtk, GObject
|
||||
|
||||
|
||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/environment-column-header.ui')
|
||||
class EnvironmentColumnHeader(Gtk.Box):
|
||||
"""Widget for displaying an environment column header with edit/delete buttons."""
|
||||
|
||||
__gtype_name__ = 'EnvironmentColumnHeader'
|
||||
|
||||
name_label = Gtk.Template.Child()
|
||||
edit_button = Gtk.Template.Child()
|
||||
delete_button = Gtk.Template.Child()
|
||||
|
||||
__gsignals__ = {
|
||||
'edit-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
}
|
||||
|
||||
def __init__(self, environment):
|
||||
super().__init__()
|
||||
self.environment = environment
|
||||
self.name_label.set_text(environment.name)
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_edit_clicked(self, button):
|
||||
"""Handle edit button click."""
|
||||
self.emit('edit-requested')
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_delete_clicked(self, button):
|
||||
"""Handle delete button click."""
|
||||
self.emit('delete-requested')
|
||||
|
||||
def update_name(self, name):
|
||||
"""Update the environment name display."""
|
||||
self.name_label.set_text(name)
|
||||
@ -1,84 +0,0 @@
|
||||
# environment_header_row.py
|
||||
#
|
||||
# Copyright 2025 Pavel Baksy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from gi.repository import Gtk, GObject
|
||||
from .environment_column_header import EnvironmentColumnHeader
|
||||
|
||||
|
||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/environment-header-row.ui')
|
||||
class EnvironmentHeaderRow(Gtk.Box):
|
||||
"""Widget for the header row containing all environment column headers."""
|
||||
|
||||
__gtype_name__ = 'EnvironmentHeaderRow'
|
||||
|
||||
variable_cell = Gtk.Template.Child()
|
||||
variable_label = Gtk.Template.Child()
|
||||
columns_box = Gtk.Template.Child()
|
||||
|
||||
__gsignals__ = {
|
||||
'environment-edit-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||
'environment-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||
}
|
||||
|
||||
def __init__(self, environments, size_group):
|
||||
super().__init__()
|
||||
self.environments = environments
|
||||
self.size_group = size_group
|
||||
self.column_headers = {}
|
||||
|
||||
# Add variable cell to size group for alignment
|
||||
if size_group:
|
||||
size_group.add_widget(self.variable_cell)
|
||||
|
||||
self._populate()
|
||||
|
||||
def _populate(self):
|
||||
"""Populate with environment column headers."""
|
||||
# Clear existing
|
||||
while child := self.columns_box.get_first_child():
|
||||
self.columns_box.remove(child)
|
||||
|
||||
self.column_headers = {}
|
||||
|
||||
# Create column header for each environment
|
||||
for env in self.environments:
|
||||
header = EnvironmentColumnHeader(env)
|
||||
header.connect('edit-requested', self._on_edit_requested, env)
|
||||
header.connect('delete-requested', self._on_delete_requested, env)
|
||||
|
||||
# Add to size group for alignment with data rows
|
||||
if self.size_group:
|
||||
self.size_group.add_widget(header)
|
||||
|
||||
self.columns_box.append(header)
|
||||
self.column_headers[env.id] = header
|
||||
|
||||
def _on_edit_requested(self, widget, environment):
|
||||
"""Handle edit request from column header."""
|
||||
self.emit('environment-edit-requested', environment)
|
||||
|
||||
def _on_delete_requested(self, widget, environment):
|
||||
"""Handle delete request from column header."""
|
||||
self.emit('environment-delete-requested', environment)
|
||||
|
||||
def refresh(self, environments):
|
||||
"""Refresh with updated environments list."""
|
||||
self.environments = environments
|
||||
self._populate()
|
||||
@ -7,7 +7,6 @@
|
||||
# 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
|
||||
@ -21,7 +20,7 @@
|
||||
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):
|
||||
"""Widget for displaying and editing an environment with its variables."""
|
||||
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
# 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
|
||||
@ -21,7 +20,7 @@
|
||||
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):
|
||||
"""Widget for editing a single HTTP header key-value pair."""
|
||||
|
||||
|
||||
@ -62,19 +62,6 @@
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Redaction Indicator (shown when sensitive variables were redacted) -->
|
||||
<child>
|
||||
<object class="GtkImage" id="redaction_indicator">
|
||||
<property name="icon-name">dialog-information-symbolic</property>
|
||||
<property name="tooltip-text">Sensitive variables were redacted from this history entry</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="visible">False</property>
|
||||
<style>
|
||||
<class name="warning"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Delete Button -->
|
||||
<child>
|
||||
<object class="GtkButton" id="delete_button">
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
# 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
|
||||
@ -22,7 +21,7 @@ from gi.repository import Gtk, GObject, Gdk
|
||||
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):
|
||||
"""Widget for displaying a history entry."""
|
||||
|
||||
@ -34,7 +33,6 @@ class HistoryItem(Gtk.Box):
|
||||
url_label = Gtk.Template.Child()
|
||||
timestamp_label = Gtk.Template.Child()
|
||||
status_label = Gtk.Template.Child()
|
||||
redaction_indicator = Gtk.Template.Child()
|
||||
delete_button = Gtk.Template.Child()
|
||||
request_headers_label = Gtk.Template.Child()
|
||||
request_body_scroll = Gtk.Template.Child()
|
||||
@ -78,10 +76,6 @@ class HistoryItem(Gtk.Box):
|
||||
|
||||
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
|
||||
if self.entry.response:
|
||||
status_text = f"{self.entry.response.status_code} {self.entry.response.status_text}"
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
<property name="spacing">0</property>
|
||||
|
||||
<child>
|
||||
<object class="GtkBox" id="header_box">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="margin-start">6</property>
|
||||
@ -72,7 +72,7 @@
|
||||
|
||||
<child>
|
||||
<object class="GtkListBox" id="requests_listbox">
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-start">24</property>
|
||||
<style>
|
||||
<class name="navigation-sidebar"/>
|
||||
</style>
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
# 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
|
||||
@ -22,13 +21,12 @@ from gi.repository import Gtk, GObject, Gio
|
||||
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):
|
||||
"""Widget for displaying a project with its requests."""
|
||||
|
||||
__gtype_name__ = 'ProjectItem'
|
||||
|
||||
header_box = Gtk.Template.Child()
|
||||
project_icon = Gtk.Template.Child()
|
||||
name_label = Gtk.Template.Child()
|
||||
requests_revealer = Gtk.Template.Child()
|
||||
@ -53,7 +51,7 @@ class ProjectItem(Gtk.Box):
|
||||
# Click gesture for header
|
||||
gesture = Gtk.GestureClick.new()
|
||||
gesture.connect('released', self._on_header_clicked)
|
||||
self.header_box.add_controller(gesture)
|
||||
self.add_controller(gesture)
|
||||
|
||||
def _setup_actions(self):
|
||||
"""Setup action group for menu."""
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
# 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
|
||||
@ -21,7 +20,7 @@
|
||||
from gi.repository import Gtk, GObject
|
||||
|
||||
|
||||
@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):
|
||||
"""Widget for displaying a saved request."""
|
||||
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk" version="4.0"/>
|
||||
<template class="VariableDataRow" parent="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">12</property>
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<property name="margin-top">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
|
||||
<child>
|
||||
<object class="GtkBox" id="variable_cell">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="width-request">150</property>
|
||||
|
||||
<child>
|
||||
<object class="GtkEntry" id="name_entry">
|
||||
<property name="placeholder-text">Variable name</property>
|
||||
<property name="hexpand">True</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Inline edit buttons (shown only when editing) -->
|
||||
<child>
|
||||
<object class="GtkRevealer" id="edit_buttons_revealer">
|
||||
<property name="transition-type">slide-left</property>
|
||||
<property name="reveal-child">False</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="spacing">3</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="cancel_button">
|
||||
<property name="icon-name">process-stop-symbolic</property>
|
||||
<property name="tooltip-text">Cancel editing</property>
|
||||
<signal name="clicked" handler="on_cancel_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="save_button">
|
||||
<property name="icon-name">object-select-symbolic</property>
|
||||
<property name="tooltip-text">Save changes</property>
|
||||
<signal name="clicked" handler="on_save_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
<class name="suggested-action"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkToggleButton" id="sensitive_toggle">
|
||||
<property name="icon-name">changes-allow-symbolic</property>
|
||||
<property name="tooltip-text">Mark as sensitive (store in keyring)</property>
|
||||
<signal name="toggled" handler="on_sensitive_toggled"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkButton" id="delete_button">
|
||||
<property name="icon-name">edit-delete-symbolic</property>
|
||||
<property name="tooltip-text">Delete variable</property>
|
||||
<signal name="clicked" handler="on_delete_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkBox" id="values_box">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="hexpand">True</property>
|
||||
</object>
|
||||
</child>
|
||||
</template>
|
||||
</interface>
|
||||
@ -1,290 +0,0 @@
|
||||
# variable_data_row.py
|
||||
#
|
||||
# Copyright 2025 Pavel Baksy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from gi.repository import Gtk, GObject, GLib
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/variable-data-row.ui')
|
||||
class VariableDataRow(Gtk.Box):
|
||||
"""Widget for a data row with variable name and values for each environment."""
|
||||
|
||||
__gtype_name__ = 'VariableDataRow'
|
||||
|
||||
variable_cell = Gtk.Template.Child()
|
||||
name_entry = Gtk.Template.Child()
|
||||
edit_buttons_revealer = Gtk.Template.Child()
|
||||
cancel_button = Gtk.Template.Child()
|
||||
save_button = Gtk.Template.Child()
|
||||
sensitive_toggle = Gtk.Template.Child()
|
||||
delete_button = Gtk.Template.Child()
|
||||
values_box = Gtk.Template.Child()
|
||||
|
||||
__gsignals__ = {
|
||||
'variable-changed': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
'variable-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
'value-changed': (GObject.SIGNAL_RUN_FIRST, None, (str, str)), # env_id, value
|
||||
'sensitivity-changed': (GObject.SIGNAL_RUN_FIRST, None, (bool,)), # is_sensitive
|
||||
}
|
||||
|
||||
def __init__(self, variable_name, environments, size_group, project, project_manager, update_timestamp=None):
|
||||
super().__init__()
|
||||
self.variable_name = variable_name
|
||||
self.original_name = variable_name # Store original name for cancel
|
||||
self.environments = environments
|
||||
self.size_group = size_group
|
||||
self.project = project
|
||||
self.project_manager = project_manager
|
||||
self.value_entries = {}
|
||||
self.update_timeout_id = None
|
||||
self._updating_toggle = False # Flag to prevent recursion
|
||||
self._is_editing = False # Track editing state
|
||||
|
||||
# Set variable name
|
||||
self.name_entry.set_text(variable_name)
|
||||
|
||||
# Connect focus events for inline editing
|
||||
focus_controller = Gtk.EventControllerFocus()
|
||||
focus_controller.connect('enter', self._on_name_entry_focus_in)
|
||||
focus_controller.connect('leave', self._on_name_entry_focus_out)
|
||||
self.name_entry.add_controller(focus_controller)
|
||||
|
||||
# Connect activate signal (Enter key)
|
||||
self.name_entry.connect('activate', self._on_name_entry_activate)
|
||||
|
||||
# Set initial sensitivity state
|
||||
is_sensitive = variable_name in self.project.sensitive_variables
|
||||
self._updating_toggle = True
|
||||
self.sensitive_toggle.set_active(is_sensitive)
|
||||
self._update_sensitive_icon(is_sensitive)
|
||||
self._updating_toggle = False
|
||||
|
||||
# Add variable cell to size group for alignment
|
||||
if size_group:
|
||||
size_group.add_widget(self.variable_cell)
|
||||
|
||||
self._populate_values()
|
||||
|
||||
# Apply visual marking if recently updated
|
||||
if update_timestamp:
|
||||
self._apply_update_marking(update_timestamp)
|
||||
|
||||
def _populate_values(self):
|
||||
"""Populate value entries for each environment."""
|
||||
# Clear existing
|
||||
while child := self.values_box.get_first_child():
|
||||
self.values_box.remove(child)
|
||||
|
||||
self.value_entries = {}
|
||||
|
||||
# Check if this variable is sensitive
|
||||
is_sensitive = self.variable_name in self.project.sensitive_variables
|
||||
|
||||
# Create entry for each environment
|
||||
for env in self.environments:
|
||||
# Create a box to hold the entry (for size group alignment)
|
||||
entry_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
entry_box.set_size_request(200, -1)
|
||||
|
||||
entry = Gtk.Entry()
|
||||
entry.set_placeholder_text(env.name)
|
||||
entry.set_hexpand(True)
|
||||
|
||||
# Configure entry for sensitive variables
|
||||
if is_sensitive:
|
||||
entry.set_visibility(False) # Show bullets instead of text
|
||||
entry.set_input_purpose(Gtk.InputPurpose.PASSWORD)
|
||||
entry.add_css_class("sensitive-variable")
|
||||
|
||||
entry_box.append(entry)
|
||||
|
||||
# Add entry box to size group for alignment
|
||||
if self.size_group:
|
||||
self.size_group.add_widget(entry_box)
|
||||
|
||||
self.values_box.append(entry_box)
|
||||
self.value_entries[env.id] = entry
|
||||
|
||||
# Get value from appropriate storage (keyring or JSON) asynchronously
|
||||
def on_value_retrieved(value, entry=entry, env_id=env.id):
|
||||
entry.set_text(value)
|
||||
# Connect changed handler after setting initial value to avoid spurious signals
|
||||
entry.connect('changed', self._on_value_changed, env_id)
|
||||
|
||||
self.project_manager.get_variable_value(
|
||||
self.project.id,
|
||||
env.id,
|
||||
self.variable_name,
|
||||
on_value_retrieved
|
||||
)
|
||||
|
||||
def _on_value_changed(self, entry, env_id):
|
||||
"""Handle value entry changes."""
|
||||
value = entry.get_text()
|
||||
|
||||
# Emit signal first (value is being changed)
|
||||
self.emit('value-changed', env_id, value)
|
||||
|
||||
# Use project_manager to store value in appropriate location (async)
|
||||
self.project_manager.set_variable_value(
|
||||
self.project.id,
|
||||
env_id,
|
||||
self.variable_name,
|
||||
value,
|
||||
callback=None # Fire and forget - errors are logged
|
||||
)
|
||||
|
||||
def _on_name_entry_focus_in(self, controller):
|
||||
"""Show edit buttons when entry gains focus."""
|
||||
self._is_editing = True
|
||||
self.original_name = self.name_entry.get_text().strip()
|
||||
self.edit_buttons_revealer.set_reveal_child(True)
|
||||
|
||||
def _on_name_entry_focus_out(self, controller):
|
||||
"""Handle focus leaving the entry without explicit save/cancel."""
|
||||
# Only auto-hide if user didn't click cancel/save
|
||||
# The buttons will handle hiding themselves
|
||||
pass
|
||||
|
||||
def _on_name_entry_activate(self, entry):
|
||||
"""Handle Enter key press - same as clicking Save."""
|
||||
self._save_changes()
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_cancel_clicked(self, button):
|
||||
"""Cancel editing and restore original name."""
|
||||
self.name_entry.set_text(self.original_name)
|
||||
self._is_editing = False
|
||||
self.edit_buttons_revealer.set_reveal_child(False)
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_save_clicked(self, button):
|
||||
"""Save variable name changes."""
|
||||
self._save_changes()
|
||||
|
||||
def _save_changes(self):
|
||||
"""Save the variable name change."""
|
||||
new_name = self.name_entry.get_text().strip()
|
||||
|
||||
# Only emit if name actually changed
|
||||
if new_name != self.original_name:
|
||||
self.emit('variable-changed')
|
||||
|
||||
self._is_editing = False
|
||||
self.edit_buttons_revealer.set_reveal_child(False)
|
||||
self.original_name = new_name
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_delete_clicked(self, button):
|
||||
"""Handle delete button click."""
|
||||
self.emit('variable-delete-requested')
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_sensitive_toggled(self, toggle_button):
|
||||
"""Handle sensitivity toggle."""
|
||||
if self._updating_toggle:
|
||||
return
|
||||
|
||||
is_sensitive = toggle_button.get_active()
|
||||
self._update_sensitive_icon(is_sensitive)
|
||||
|
||||
# Emit signal so parent can update the project
|
||||
self.emit('sensitivity-changed', is_sensitive)
|
||||
|
||||
def _update_sensitive_icon(self, is_sensitive):
|
||||
"""Update the lock icon based on sensitivity state."""
|
||||
if is_sensitive:
|
||||
self.sensitive_toggle.set_icon_name("channel-secure-symbolic")
|
||||
self.sensitive_toggle.set_tooltip_text("Sensitive (stored in keyring)\nClick to make non-sensitive")
|
||||
self.name_entry.add_css_class("sensitive-variable-name")
|
||||
else:
|
||||
self.sensitive_toggle.set_icon_name("changes-allow-symbolic")
|
||||
self.sensitive_toggle.set_tooltip_text("Not sensitive (stored in JSON)\nClick to mark as sensitive")
|
||||
self.name_entry.remove_css_class("sensitive-variable-name")
|
||||
|
||||
def get_variable_name(self):
|
||||
"""Return current variable name."""
|
||||
return self.name_entry.get_text().strip()
|
||||
|
||||
def refresh(self, environments, project):
|
||||
"""Refresh with updated environments and project."""
|
||||
self.environments = environments
|
||||
self.project = project
|
||||
|
||||
# Update original_name in case variable was renamed
|
||||
self.original_name = self.variable_name
|
||||
|
||||
# Update sensitivity toggle state
|
||||
is_sensitive = self.variable_name in self.project.sensitive_variables
|
||||
self._updating_toggle = True
|
||||
self.sensitive_toggle.set_active(is_sensitive)
|
||||
self._update_sensitive_icon(is_sensitive)
|
||||
self._updating_toggle = False
|
||||
|
||||
self._populate_values()
|
||||
|
||||
def _apply_update_marking(self, timestamp_str):
|
||||
"""
|
||||
Apply visual marking to show variable was recently updated.
|
||||
|
||||
Args:
|
||||
timestamp_str: ISO format timestamp of when variable was updated
|
||||
"""
|
||||
try:
|
||||
# Parse timestamp
|
||||
update_time = datetime.fromisoformat(timestamp_str)
|
||||
time_diff = datetime.now() - update_time
|
||||
|
||||
# Only apply marking if updated within last 3 minutes
|
||||
if time_diff < timedelta(minutes=3):
|
||||
# Add CSS class for visual styling
|
||||
self.add_css_class('recently-updated')
|
||||
|
||||
# Set tooltip with update time
|
||||
seconds_ago = int(time_diff.total_seconds())
|
||||
if seconds_ago < 10:
|
||||
tooltip_text = "✨ Updated just now by script"
|
||||
elif seconds_ago < 60:
|
||||
tooltip_text = f"✨ Updated {seconds_ago} seconds ago by script"
|
||||
else:
|
||||
minutes_ago = seconds_ago // 60
|
||||
if minutes_ago == 1:
|
||||
tooltip_text = "✨ Updated 1 minute ago by script"
|
||||
else:
|
||||
tooltip_text = f"✨ Updated {minutes_ago} minutes ago by script"
|
||||
|
||||
self.set_tooltip_text(tooltip_text)
|
||||
|
||||
# Keep highlighting visible for 60 seconds (plenty of time to see it)
|
||||
if self.update_timeout_id:
|
||||
GLib.source_remove(self.update_timeout_id)
|
||||
|
||||
self.update_timeout_id = GLib.timeout_add_seconds(60, self._remove_update_marking)
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
# Invalid timestamp format, skip marking
|
||||
pass
|
||||
|
||||
def _remove_update_marking(self):
|
||||
"""Remove visual marking after timeout."""
|
||||
self.remove_css_class('recently-updated')
|
||||
self.set_tooltip_text("")
|
||||
self.update_timeout_id = None
|
||||
return False # Don't repeat timeout
|
||||
@ -7,7 +7,6 @@
|
||||
# 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
|
||||
@ -21,7 +20,7 @@
|
||||
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):
|
||||
"""Widget for editing a variable name."""
|
||||
|
||||
|
||||
672
src/window.py
@ -20,16 +20,7 @@
|
||||
import gi
|
||||
gi.require_version('GtkSource', '5')
|
||||
from gi.repository import Adw, Gtk, GLib, Gio, GtkSource
|
||||
from typing import Dict, Optional
|
||||
import logging
|
||||
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 .history_manager import HistoryManager
|
||||
from .project_manager import ProjectManager
|
||||
@ -43,19 +34,12 @@ from datetime import datetime
|
||||
import json
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@Gtk.Template(resource_path='/cz/bugsy/roster/main-window.ui')
|
||||
@Gtk.Template(resource_path='/cz/vesp/roster/main-window.ui')
|
||||
class RosterWindow(Adw.ApplicationWindow):
|
||||
__gtype_name__ = 'RosterWindow'
|
||||
|
||||
# Toast overlay
|
||||
toast_overlay = Gtk.Template.Child()
|
||||
|
||||
# Top bar widgets
|
||||
save_request_button = Gtk.Template.Child()
|
||||
export_request_button = Gtk.Template.Child()
|
||||
new_request_button = Gtk.Template.Child()
|
||||
tab_view = Gtk.Template.Child()
|
||||
tab_bar = Gtk.Template.Child()
|
||||
@ -66,6 +50,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# Sidebar widgets
|
||||
projects_listbox = Gtk.Template.Child()
|
||||
add_project_button = Gtk.Template.Child()
|
||||
save_request_button = Gtk.Template.Child()
|
||||
|
||||
# History (hidden but kept for compatibility)
|
||||
history_listbox = Gtk.Template.Child()
|
||||
@ -79,13 +64,9 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
self.tab_manager = TabManager()
|
||||
|
||||
# Map AdwTabPage to tab data
|
||||
self.page_to_widget: Dict[Adw.TabPage, RequestTabWidget] = {}
|
||||
self.page_to_tab: Dict[Adw.TabPage, RequestTab] = {}
|
||||
self.current_tab_id: Optional[str] = None
|
||||
|
||||
# Track recently updated variables for visual marking
|
||||
# Format: {project_id: {var_name: timestamp}}
|
||||
self.recently_updated_variables: Dict[str, Dict[str, str]] = {}
|
||||
self.page_to_widget = {} # AdwTabPage -> RequestTabWidget
|
||||
self.page_to_tab = {} # AdwTabPage -> RequestTab
|
||||
self.current_tab_id = None
|
||||
|
||||
# Connect to tab view signals
|
||||
self.tab_view.connect('close-page', self._on_tab_close_page)
|
||||
@ -108,7 +89,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# Create first tab
|
||||
self._create_new_tab()
|
||||
|
||||
def _on_close_request(self, window) -> bool:
|
||||
def _on_close_request(self, window):
|
||||
"""Handle window close request - warn if there are unsaved changes."""
|
||||
# Check if any tabs have unsaved changes
|
||||
modified_tabs = []
|
||||
@ -143,13 +124,13 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# No unsaved changes, allow closing
|
||||
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."""
|
||||
if response == "close":
|
||||
# User confirmed - close the window
|
||||
self.destroy()
|
||||
|
||||
def _setup_custom_css(self) -> None:
|
||||
def _setup_custom_css(self):
|
||||
"""Setup custom CSS for UI styling."""
|
||||
css_provider = Gtk.CssProvider()
|
||||
css_provider.load_from_data(b"""
|
||||
@ -165,39 +146,15 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
}
|
||||
|
||||
stackswitcher button:checked {
|
||||
font-weight: 900;
|
||||
background: @accent_bg_color;
|
||||
color: @accent_fg_color;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Warning styling for undefined variables */
|
||||
entry.warning {
|
||||
background-color: mix(@warning_bg_color, @view_bg_color, 0.3);
|
||||
}
|
||||
|
||||
/* Visual marking for recently updated variables */
|
||||
.recently-updated {
|
||||
background: linear-gradient(90deg,
|
||||
mix(@success_bg_color, @view_bg_color, 0.25),
|
||||
mix(@success_bg_color, @view_bg_color, 0.05));
|
||||
border-left: 4px solid @success_color;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.recently-updated:hover {
|
||||
background: linear-gradient(90deg,
|
||||
mix(@success_bg_color, @view_bg_color, 0.35),
|
||||
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;
|
||||
}
|
||||
""")
|
||||
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
@ -206,12 +163,12 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
)
|
||||
|
||||
def _setup_tab_system(self) -> None:
|
||||
def _setup_tab_system(self):
|
||||
"""Set up the tab system."""
|
||||
# Connect new request button
|
||||
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."""
|
||||
page = tab_view.get_selected_page()
|
||||
if not page:
|
||||
@ -222,7 +179,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
if tab:
|
||||
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."""
|
||||
# Get the RequestTab and widget for this page
|
||||
tab = self.page_to_tab.get(page)
|
||||
@ -255,7 +212,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
remaining_tabs = len(self.page_to_tab)
|
||||
if remaining_tabs == 0:
|
||||
# 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
|
||||
tab_view.close_page_finish(page, True)
|
||||
@ -281,7 +238,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
|
||||
return False # Allow close
|
||||
|
||||
def _create_new_tab_once(self) -> bool:
|
||||
def _create_new_tab_once(self):
|
||||
"""Create a new tab (one-time callback)."""
|
||||
# Only create if there really are no tabs
|
||||
if self.tab_view.get_n_pages() == 0:
|
||||
@ -289,7 +246,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
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."""
|
||||
if not self.current_tab_id:
|
||||
return False
|
||||
@ -311,25 +268,22 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
|
||||
# Check if it's empty
|
||||
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 = (
|
||||
not request.url.strip() and
|
||||
not request.body.strip() and
|
||||
(not request.headers or has_only_default_headers)
|
||||
not request.headers
|
||||
)
|
||||
|
||||
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."""
|
||||
for tab in self.tab_manager.tabs:
|
||||
if tab.saved_request_id == saved_request_id:
|
||||
return tab
|
||||
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."""
|
||||
# Check if "Name (copy)" exists
|
||||
existing_names = {tab.name for tab in self.tab_manager.tabs}
|
||||
@ -346,29 +300,21 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
return f"{base_name} (copy {counter})"
|
||||
|
||||
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):
|
||||
"""Create a new tab with RequestTabWidget."""
|
||||
# Create tab in tab manager
|
||||
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,
|
||||
project_id, selected_environment_id, scripts)
|
||||
project_id, selected_environment_id)
|
||||
self.current_tab_id = tab.id
|
||||
|
||||
# Create RequestTabWidget for this tab
|
||||
widget = RequestTabWidget(tab.id, request, response, project_id, selected_environment_id, scripts)
|
||||
widget = RequestTabWidget(tab.id, request, response, project_id, selected_environment_id)
|
||||
widget.original_request = tab.original_request
|
||||
widget.project_manager = self.project_manager # Inject project manager
|
||||
|
||||
# Set original scripts for change tracking (if this is a saved request)
|
||||
if saved_request_id and scripts:
|
||||
from .models import Scripts
|
||||
widget.original_scripts = Scripts(
|
||||
preprocessing=scripts.preprocessing,
|
||||
postprocessing=scripts.postprocessing
|
||||
)
|
||||
|
||||
# Populate environment dropdown if project_id is set
|
||||
if project_id and hasattr(widget, '_populate_environment_dropdown'):
|
||||
widget._populate_environment_dropdown()
|
||||
@ -420,7 +366,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# Remove star from title
|
||||
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."""
|
||||
# Find the page for this tab
|
||||
for page, tab in self.page_to_tab.items():
|
||||
@ -428,34 +374,17 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
self.tab_view.set_selected_page(page)
|
||||
return
|
||||
|
||||
def _close_current_tab(self) -> None:
|
||||
def _close_current_tab(self):
|
||||
"""Close the currently active tab."""
|
||||
page = self.tab_view.get_selected_page()
|
||||
if 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):
|
||||
"""Handle New Request button click."""
|
||||
self._create_new_tab()
|
||||
|
||||
def _create_actions(self) -> None:
|
||||
def _create_actions(self):
|
||||
"""Create window-level actions."""
|
||||
# New tab shortcut (Ctrl+T)
|
||||
action = Gio.SimpleAction.new("new-tab", None)
|
||||
@ -475,178 +404,65 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
self.add_action(action)
|
||||
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"])
|
||||
|
||||
def _on_send_clicked(self, 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
|
||||
request = widget.get_request()
|
||||
if not request.url.strip():
|
||||
self._show_toast("Please enter a URL")
|
||||
return
|
||||
|
||||
# Execute preprocessing script (if exists)
|
||||
scripts = widget.get_scripts()
|
||||
modified_request = request
|
||||
|
||||
if scripts and scripts.preprocessing.strip():
|
||||
from .script_executor import ScriptContext
|
||||
|
||||
preprocessing_result = self._execute_preprocessing(
|
||||
widget, scripts.preprocessing, request
|
||||
)
|
||||
|
||||
# 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
|
||||
# Apply variable substitution if environment is selected
|
||||
substituted_request = request
|
||||
if widget.selected_environment_id:
|
||||
env = widget.get_selected_environment()
|
||||
if env:
|
||||
from .variable_substitution import VariableSubstitution
|
||||
substituted_request, undefined = VariableSubstitution.substitute_request(request, env)
|
||||
# Log undefined variables for debugging
|
||||
if undefined:
|
||||
print(f"Warning: Undefined variables in request: {', '.join(undefined)}")
|
||||
|
||||
# Disable send button during request
|
||||
widget.send_button.set_sensitive(False)
|
||||
widget.send_button.set_label("Sending...")
|
||||
|
||||
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
|
||||
def callback(response, error, user_data):
|
||||
"""Callback runs on main thread."""
|
||||
# Re-enable send button
|
||||
widget.send_button.set_sensitive(True)
|
||||
widget.send_button.set_label("Send")
|
||||
|
||||
# Execute async
|
||||
def callback(response, error, user_data):
|
||||
"""Callback runs on main thread."""
|
||||
# Re-enable send button
|
||||
widget.send_button.set_sensitive(True)
|
||||
# Update tab with response
|
||||
if response:
|
||||
widget.display_response(response)
|
||||
else:
|
||||
widget.display_error(error or "Unknown error")
|
||||
|
||||
# Update tab with response
|
||||
if response:
|
||||
widget.display_response(response)
|
||||
# Save response to tab
|
||||
page = self.tab_view.get_selected_page()
|
||||
if page:
|
||||
tab = self.page_to_tab.get(page)
|
||||
if tab:
|
||||
tab.response = response
|
||||
|
||||
# Execute postprocessing script if exists
|
||||
scripts = widget.get_scripts()
|
||||
if scripts and scripts.postprocessing.strip():
|
||||
from .script_executor import ScriptExecutor, ScriptContext
|
||||
# Create history entry (save the substituted request with actual values)
|
||||
entry = HistoryEntry(
|
||||
timestamp=datetime.now().isoformat(),
|
||||
request=substituted_request,
|
||||
response=response,
|
||||
error=error
|
||||
)
|
||||
self.history_manager.add_entry(entry)
|
||||
|
||||
# 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
|
||||
)
|
||||
# Refresh history panel to show new entry
|
||||
self._load_history()
|
||||
|
||||
# Execute script with context
|
||||
script_result = ScriptExecutor.execute_postprocessing_script(
|
||||
scripts.postprocessing,
|
||||
response,
|
||||
context
|
||||
)
|
||||
|
||||
# Display script results
|
||||
widget.display_script_results(script_result)
|
||||
|
||||
# Process variable updates
|
||||
self._process_variable_updates(script_result, widget, context)
|
||||
else:
|
||||
widget._clear_script_results()
|
||||
else:
|
||||
widget.display_error(error or "Unknown error")
|
||||
widget._clear_script_results()
|
||||
|
||||
# Save response to tab
|
||||
page = self.tab_view.get_selected_page()
|
||||
if page:
|
||||
tab = self.page_to_tab.get(page)
|
||||
if tab:
|
||||
tab.response = response
|
||||
|
||||
# Create history entry with redacted sensitive variables
|
||||
# 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(
|
||||
timestamp=datetime.now().isoformat(),
|
||||
request=request_for_history,
|
||||
response=response,
|
||||
error=error,
|
||||
has_redacted_variables=has_redacted
|
||||
)
|
||||
self.history_manager.add_entry(entry)
|
||||
|
||||
# Refresh history panel to show new entry
|
||||
self._load_history()
|
||||
|
||||
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)
|
||||
self.http_client.execute_request_async(substituted_request, callback, None)
|
||||
|
||||
# History and Project Management
|
||||
def _load_history(self) -> None:
|
||||
def _load_history(self):
|
||||
"""Load history from file and populate list."""
|
||||
# Clear existing history items
|
||||
while True:
|
||||
@ -682,7 +498,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# Generate name from method and URL
|
||||
url_parts = request.url.split('/')
|
||||
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"
|
||||
if self._is_empty_new_request_tab():
|
||||
@ -724,181 +540,19 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
|
||||
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):
|
||||
"""
|
||||
Process variable updates from postprocessing script.
|
||||
|
||||
Args:
|
||||
script_result: ScriptResult containing variable updates
|
||||
widget: RequestTabWidget instance
|
||||
context: ScriptContext or None
|
||||
"""
|
||||
# Check if there are any variable updates
|
||||
if not script_result.variable_updates:
|
||||
return
|
||||
|
||||
# Show warnings if context is missing
|
||||
if not context or not context.project_id or not context.environment_id:
|
||||
if widget.project_id:
|
||||
self._show_toast("Cannot set variables: no environment selected")
|
||||
else:
|
||||
self._show_toast("Cannot set variables: tab not associated with project")
|
||||
return
|
||||
|
||||
# Get project and environment
|
||||
projects = self.project_manager.load_projects()
|
||||
project = None
|
||||
environment = None
|
||||
|
||||
for p in projects:
|
||||
if p.id == context.project_id:
|
||||
project = p
|
||||
for env in p.environments:
|
||||
if env.id == context.environment_id:
|
||||
environment = env
|
||||
break
|
||||
break
|
||||
|
||||
if not project or not environment:
|
||||
self._show_toast("Cannot set variables: project or environment not found")
|
||||
return
|
||||
|
||||
# Process each variable update
|
||||
created_vars = []
|
||||
updated_vars = []
|
||||
|
||||
# First pass: identify and create missing variables
|
||||
for var_name in script_result.variable_updates.keys():
|
||||
if var_name not in project.variable_names:
|
||||
# Auto-create variable
|
||||
self.project_manager.add_variable(context.project_id, var_name)
|
||||
created_vars.append(var_name)
|
||||
else:
|
||||
updated_vars.append(var_name)
|
||||
|
||||
# Second pass: batch update all variable values
|
||||
self.project_manager.batch_update_environment_variables(
|
||||
context.project_id,
|
||||
context.environment_id,
|
||||
script_result.variable_updates
|
||||
)
|
||||
|
||||
# Track updated variables for visual marking
|
||||
if context.project_id not in self.recently_updated_variables:
|
||||
self.recently_updated_variables[context.project_id] = {}
|
||||
|
||||
current_time = datetime.now().isoformat()
|
||||
for var_name in script_result.variable_updates.keys():
|
||||
self.recently_updated_variables[context.project_id][var_name] = current_time
|
||||
|
||||
# Show toast notification
|
||||
env_name = environment.name
|
||||
if created_vars and updated_vars:
|
||||
vars_list = ', '.join(created_vars + updated_vars)
|
||||
self._show_toast(f"Created {len(created_vars)} and updated {len(updated_vars)} variables in {env_name}")
|
||||
elif created_vars:
|
||||
vars_list = ', '.join(created_vars)
|
||||
self._show_toast(f"Created {len(created_vars)} variable(s) in {env_name}: {vars_list}")
|
||||
elif updated_vars:
|
||||
vars_list = ', '.join(updated_vars)
|
||||
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."""
|
||||
toast = Adw.Toast()
|
||||
toast.set_title(message)
|
||||
toast.set_timeout(UI_TOAST_TIMEOUT_SECONDS)
|
||||
self.toast_overlay.add_toast(toast)
|
||||
toast.set_timeout(3)
|
||||
|
||||
# Get the toast overlay (we need to add one)
|
||||
# For now, just print to console
|
||||
print(f"Toast: {message}")
|
||||
|
||||
# Project Management Methods
|
||||
|
||||
def _validate_project_name(self, name: str) -> tuple[bool, str]:
|
||||
"""
|
||||
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:
|
||||
def _load_projects(self):
|
||||
"""Load and display projects."""
|
||||
# Clear existing
|
||||
while child := self.projects_listbox.get_first_child():
|
||||
@ -936,13 +590,9 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
def on_response(dlg, response):
|
||||
if response == "create":
|
||||
name = entry.get_text().strip()
|
||||
is_valid, error_msg = self._validate_project_name(name)
|
||||
if is_valid:
|
||||
if name:
|
||||
self.project_manager.add_project(name)
|
||||
self._load_projects()
|
||||
self._show_toast(f"Project '{name}' created")
|
||||
else:
|
||||
self._show_toast(error_msg)
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
dialog.present(self)
|
||||
@ -992,13 +642,9 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
if response == "save":
|
||||
new_name = entry.get_text().strip()
|
||||
new_icon = icon_button.selected_icon
|
||||
is_valid, error_msg = self._validate_project_name(new_name)
|
||||
if is_valid:
|
||||
if new_name:
|
||||
self.project_manager.update_project(project.id, new_name, new_icon)
|
||||
self._load_projects()
|
||||
self._show_toast(f"Project updated")
|
||||
else:
|
||||
self._show_toast(error_msg)
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
dialog.present(self)
|
||||
@ -1024,22 +670,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
|
||||
def _on_manage_environments(self, widget, project):
|
||||
"""Show environments management dialog."""
|
||||
# Get latest project data (includes 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()
|
||||
fresh_project = None
|
||||
for p in projects:
|
||||
if p.id == project.id:
|
||||
fresh_project = p
|
||||
break
|
||||
|
||||
if not fresh_project:
|
||||
fresh_project = project # Fallback to original if not found
|
||||
|
||||
# Get recently updated variables for this project
|
||||
recently_updated = self.recently_updated_variables.get(project.id, {})
|
||||
|
||||
dialog = EnvironmentsDialog(fresh_project, self.project_manager, recently_updated)
|
||||
dialog = EnvironmentsDialog(project, self.project_manager)
|
||||
|
||||
def on_environments_updated(dlg):
|
||||
# Reload projects to reflect changes
|
||||
@ -1063,7 +694,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
return
|
||||
|
||||
request = widget.get_request()
|
||||
scripts = widget.get_scripts()
|
||||
|
||||
if not request.url.strip():
|
||||
self._show_toast("Cannot save: URL is empty")
|
||||
@ -1086,7 +716,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
|
||||
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
|
||||
preselect_project_index = 0
|
||||
prefill_name = ""
|
||||
@ -1101,13 +731,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
preselect_project_index = i
|
||||
prefill_name = current_tab.name
|
||||
break
|
||||
elif current_tab.project_id:
|
||||
# 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":
|
||||
elif current_tab.name and current_tab.name != "New Request":
|
||||
# Copy tab or named unsaved tab - prefill with tab name
|
||||
prefill_name = current_tab.name
|
||||
|
||||
@ -1137,71 +761,27 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
def on_response(dlg, response):
|
||||
if response == "save":
|
||||
name = entry.get_text().strip()
|
||||
is_valid, error_msg = self._validate_request_name(name)
|
||||
if is_valid:
|
||||
if name:
|
||||
selected = dropdown.get_selected()
|
||||
project = projects[selected]
|
||||
# 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, scripts)
|
||||
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, scripts)
|
||||
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, scripts)
|
||||
else:
|
||||
self._show_toast(error_msg)
|
||||
self._mark_tab_as_saved(saved_request.id, name, request)
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
dialog.present(self)
|
||||
|
||||
@Gtk.Template.Callback()
|
||||
def on_export_request_clicked(self, button):
|
||||
"""Handle Export button click - export request as cURL command."""
|
||||
# Get current tab
|
||||
page = self.tab_view.get_selected_page()
|
||||
if not page:
|
||||
self._show_toast("No active request")
|
||||
return
|
||||
|
||||
widget = self.page_to_widget.get(page)
|
||||
if not widget:
|
||||
self._show_toast("No active request")
|
||||
return
|
||||
|
||||
# Get request from widget (raw with {{variables}})
|
||||
request = widget.get_request()
|
||||
|
||||
# Validate URL
|
||||
if not request.url.strip():
|
||||
self._show_toast("Cannot export: URL is empty")
|
||||
return
|
||||
|
||||
# Apply variable substitution if environment is selected
|
||||
substituted_request = request
|
||||
if widget.selected_environment_id:
|
||||
env = widget.get_selected_environment()
|
||||
if env:
|
||||
from .variable_substitution import VariableSubstitution
|
||||
substituted_request, undefined = VariableSubstitution.substitute_request(request, env)
|
||||
|
||||
# Warn about undefined variables
|
||||
if undefined:
|
||||
logger.warning(f"Undefined variables in export: {', '.join(undefined)}")
|
||||
# Show warning toast but continue with export
|
||||
self._show_toast(f"Warning: {len(undefined)} undefined variable(s)")
|
||||
|
||||
# Show export dialog with substituted request
|
||||
from .export_dialog import ExportDialog
|
||||
dialog = ExportDialog(substituted_request)
|
||||
dialog.present(self)
|
||||
|
||||
def _mark_tab_as_saved(self, saved_request_id, name, request, scripts=None):
|
||||
def _mark_tab_as_saved(self, saved_request_id, name, request):
|
||||
"""Mark the current tab as saved (clear modified flag)."""
|
||||
page = self.tab_view.get_selected_page()
|
||||
if not page:
|
||||
@ -1215,7 +795,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
tab.saved_request_id = saved_request_id
|
||||
tab.name = name
|
||||
tab.modified = False
|
||||
tab.scripts = scripts
|
||||
|
||||
# Update original_request to match saved state
|
||||
original = HttpRequest(
|
||||
@ -1227,17 +806,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
)
|
||||
tab.original_request = original
|
||||
|
||||
# Update original_scripts to match saved state
|
||||
if scripts:
|
||||
from .models import Scripts
|
||||
original_scripts = Scripts(
|
||||
preprocessing=scripts.preprocessing,
|
||||
postprocessing=scripts.postprocessing
|
||||
)
|
||||
widget.original_scripts = original_scripts
|
||||
else:
|
||||
widget.original_scripts = None
|
||||
|
||||
# Update widget state
|
||||
widget.original_request = original
|
||||
widget.modified = False
|
||||
@ -1245,7 +813,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# Update tab page title (widget.modified is now False, so no star)
|
||||
page.set_title(name)
|
||||
|
||||
def _show_overwrite_dialog(self, project, name, existing_request_id, request, scripts=None):
|
||||
def _show_overwrite_dialog(self, project, name, existing_request_id, request):
|
||||
"""Show dialog asking if user wants to overwrite existing request."""
|
||||
dialog = Adw.AlertDialog()
|
||||
dialog.set_heading("Request Already Exists")
|
||||
@ -1260,34 +828,60 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
def on_overwrite_response(dlg, response):
|
||||
if response == "overwrite":
|
||||
# Update the existing request
|
||||
self.project_manager.update_request(project.id, existing_request_id, name, request, scripts)
|
||||
self.project_manager.update_request(project.id, existing_request_id, name, request)
|
||||
self._load_projects()
|
||||
self._show_toast(f"Updated '{name}'")
|
||||
|
||||
# Clear modified flag on current tab
|
||||
self._mark_tab_as_saved(existing_request_id, name, request, scripts)
|
||||
self._mark_tab_as_saved(existing_request_id, name, request)
|
||||
|
||||
dialog.connect("response", on_overwrite_response)
|
||||
dialog.present(self)
|
||||
|
||||
def _on_add_to_project(self, widget, project):
|
||||
"""Create a new empty request tab associated with the project."""
|
||||
# Get default environment for the project
|
||||
default_env_id = project.environments[0].id if project.environments else None
|
||||
"""Save current request to specific project."""
|
||||
request = self._build_request_from_ui()
|
||||
|
||||
# Create a new tab with project_id set so Save will pre-select this project
|
||||
self._create_new_tab(
|
||||
name="New Request",
|
||||
project_id=project.id,
|
||||
selected_environment_id=default_env_id
|
||||
)
|
||||
if not request.url.strip():
|
||||
self._show_toast("Cannot save: URL is empty")
|
||||
return
|
||||
|
||||
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):
|
||||
"""Load saved request - smart loading based on current tab state."""
|
||||
req = saved_request.request
|
||||
scripts = saved_request.scripts
|
||||
|
||||
# First, check if this request is already open in an unmodified tab
|
||||
existing_tab = self._find_tab_by_saved_request_id(saved_request.id)
|
||||
@ -1327,7 +921,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
current_tab.saved_request_id = link_to_saved
|
||||
current_tab.project_id = project_id
|
||||
current_tab.selected_environment_id = default_env_id
|
||||
current_tab.scripts = scripts
|
||||
|
||||
# Update widget with project context
|
||||
widget.project_id = project_id
|
||||
@ -1341,18 +934,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# Load request into widget
|
||||
widget._load_request(req)
|
||||
|
||||
# Load scripts into widget
|
||||
if scripts:
|
||||
widget.load_scripts(scripts)
|
||||
# Set original scripts for change tracking
|
||||
from .models import Scripts
|
||||
widget.original_scripts = Scripts(
|
||||
preprocessing=scripts.preprocessing,
|
||||
postprocessing=scripts.postprocessing
|
||||
)
|
||||
else:
|
||||
widget.original_scripts = None
|
||||
|
||||
if is_copy:
|
||||
# This is a copy - mark as unsaved
|
||||
current_tab.original_request = None
|
||||
@ -1379,8 +960,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
request=req,
|
||||
saved_request_id=link_to_saved,
|
||||
project_id=project_id,
|
||||
selected_environment_id=default_env_id,
|
||||
scripts=scripts
|
||||
selected_environment_id=default_env_id
|
||||
)
|
||||
|
||||
# If it's a copy, clear the original_request to mark as unsaved
|
||||
|
||||