Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff55c91dc2 | |||
| bcc53a0dc1 | |||
| 0eb7d24411 | |||
| 811a407195 | |||
| f96597a194 |
346
README.md
Normal file
346
README.md
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# Pixstrip
|
||||||
|
|
||||||
|
**Your images. Your machine. Your rules.**
|
||||||
|
|
||||||
|
A native Linux batch image processor that keeps everything local, respects your privacy, and gets out of your way.
|
||||||
|
|
||||||
|
Resize. Convert. Compress. Strip metadata. Watermark. Rename. Adjust.
|
||||||
|
All in one wizard - or one command.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[Download AppImage](https://git.lashman.live/lashman/pixstrip/releases) · [Report a Bug](https://git.lashman.live/lashman/pixstrip/issues) · [Source Code](https://git.lashman.live/lashman/pixstrip)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 What Is Pixstrip
|
||||||
|
|
||||||
|
Pixstrip is a batch image processor for Linux built with GTK4 and libadwaita. It exists because preparing images for the web, for clients, for print, or for sharing shouldn't require proprietary software, cloud uploads, or a subscription.
|
||||||
|
|
||||||
|
Everything runs locally on your machine. No accounts. No telemetry. No cloud. No surprises. You own the tool and you own the output.
|
||||||
|
|
||||||
|
The GUI walks you through a step-by-step wizard where you enable only the operations you need. The CLI gives you the same power in a single command for scripting and automation. Both share the same processing engine, the same presets, and the same results.
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
Grab the AppImage from the [releases page](https://git.lashman.live/lashman/pixstrip/releases) and run it. That's it.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x Pixstrip-x86_64.AppImage
|
||||||
|
./Pixstrip-x86_64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything is bundled inside - GTK4, libadwaita, all shared libraries. No package manager, no dependency chain, no root access needed. Download, run, own it.
|
||||||
|
|
||||||
|
## 🔧 Operations
|
||||||
|
|
||||||
|
Pixstrip combines seven image operations into a single workflow. Enable what you need, skip what you don't. Each operation is a step in the wizard with its own controls, previews, and help text.
|
||||||
|
|
||||||
|
### ✂️ Resize
|
||||||
|
|
||||||
|
Scale images to exact dimensions, or let Pixstrip figure out the math. Five resize modes cover everything from rigid pixel targets to flexible fit-in-box scaling that preserves aspect ratio.
|
||||||
|
|
||||||
|
- **Width** - set a target width, height scales proportionally
|
||||||
|
- **Height** - set a target height, width scales proportionally
|
||||||
|
- **Exact** - force both width and height (may distort aspect ratio)
|
||||||
|
- **Fit in box** - scale to fit within a bounding box without cropping or stretching
|
||||||
|
- **Social media presets** - Mastodon Post, Pixelfed Square, Pixelfed Portrait, Lemmy Thumbnail, and more built in
|
||||||
|
|
||||||
|
Resize uses Lanczos3 interpolation by default. Catmull-Rom, bilinear, and nearest-neighbor are available for specific needs. Upscaling is disabled by default to prevent quality loss - enable it explicitly when you need it.
|
||||||
|
|
||||||
|
### 🎨 Adjustments
|
||||||
|
|
||||||
|
Tune the look of your images before they leave your hands. All adjustments apply non-destructively to the output - your originals are never touched.
|
||||||
|
|
||||||
|
- **Brightness** - lighten or darken (-100 to +100)
|
||||||
|
- **Contrast** - increase or reduce tonal range (-100 to +100)
|
||||||
|
- **Saturation** - boost or mute color intensity (-100 to +100)
|
||||||
|
- **Rotation** - 90, 180, 270 degrees, or flip horizontal/vertical
|
||||||
|
- **Crop to aspect ratio** - freeform or preset ratios (1:1, 4:3, 16:9, 3:2)
|
||||||
|
- **Trim whitespace** - auto-detect and remove white or near-white borders
|
||||||
|
- **Canvas padding** - add uniform padding around images
|
||||||
|
|
||||||
|
Four one-click effects are available: **Grayscale**, **Sepia**, **Sharpen**, and a combined **Grayscale + Sharpen** for crisp black-and-white output.
|
||||||
|
|
||||||
|
### 🔄 Convert
|
||||||
|
|
||||||
|
Change image formats across your entire batch. Pixstrip supports eight formats and knows which conversions make sense.
|
||||||
|
|
||||||
|
| Format | Type | Best for |
|
||||||
|
|---|---|---|
|
||||||
|
| JPEG | Lossy | Photos, web, email - universal support |
|
||||||
|
| PNG | Lossless | Graphics, screenshots, transparency |
|
||||||
|
| WebP | Lossy/Lossless | Modern web - smaller than JPEG at same quality |
|
||||||
|
| AVIF | Lossy/Lossless | Next-gen web - smallest files, growing support |
|
||||||
|
| GIF | Lossless (256 colors) | Simple graphics, legacy compatibility |
|
||||||
|
| TIFF | Lossless | Archival, print, professional workflows |
|
||||||
|
| BMP | Uncompressed | Legacy systems, raw pixel data |
|
||||||
|
|
||||||
|
**Per-format mapping** handles mixed batches intelligently. Instead of converting everything to one format, you can set rules: PNGs become WebP, TIFFs become JPEG, everything else stays as-is. One pass, multiple conversions.
|
||||||
|
|
||||||
|
### 📐 Compress
|
||||||
|
|
||||||
|
Squeeze file sizes without visible quality loss. Pixstrip uses the best open-source encoders available - the same ones used by the tools that big platforms keep behind paywalls.
|
||||||
|
|
||||||
|
- **mozjpeg** for JPEG - typically 10-15% smaller than standard JPEG at identical visual quality
|
||||||
|
- **oxipng** for PNG - lossless recompression that shaves bytes without touching pixels
|
||||||
|
- **libwebp** for WebP - Google's encoder with fine-grained quality control
|
||||||
|
- **ravif** for AVIF - the rav1e encoder with configurable speed/quality tradeoffs
|
||||||
|
|
||||||
|
Five quality presets (Maximum, High, Medium, Low, Web) give you quick access to sensible defaults. Per-format quality overrides let you set exact values: JPEG quality 92, WebP quality 85, AVIF quality 70 - all in the same batch.
|
||||||
|
|
||||||
|
The compression step includes a live **before/after preview** with a draggable split view. See exactly what you're trading before you commit. The preview calculates real file sizes from a sample image so you know the actual savings.
|
||||||
|
|
||||||
|
### 🛡️ Metadata
|
||||||
|
|
||||||
|
Your photos carry more information than you think. GPS coordinates, camera serial numbers, editing software history, timestamps - all embedded in EXIF data that travels with every file you share.
|
||||||
|
|
||||||
|
Pixstrip gives you five metadata modes:
|
||||||
|
|
||||||
|
- **Strip All** - remove everything for the smallest files and maximum privacy
|
||||||
|
- **Privacy Mode** - strip GPS, camera serial, and device info while keeping copyright and basic camera settings
|
||||||
|
- **Photographer Mode** - keep copyright, camera model, and lens data while stripping GPS and software info
|
||||||
|
- **Keep All** - preserve every byte of metadata exactly as it is
|
||||||
|
- **Custom** - toggle individual categories: GPS/Location, Camera Info, Software, Timestamps, Copyright/Author
|
||||||
|
|
||||||
|
Custom mode puts you in full control of what leaves your machine and what doesn't. Your data, your choice about who sees it.
|
||||||
|
|
||||||
|
### 💧 Watermark
|
||||||
|
|
||||||
|
Protect your work or brand your output with text or image watermarks. The watermark step provides a live preview that updates as you change settings.
|
||||||
|
|
||||||
|
**Text watermarks** support:
|
||||||
|
- Custom text with any system font, adjustable size, and bold/italic styling
|
||||||
|
- Nine-position placement grid (corners, edges, center)
|
||||||
|
- Rotation from -180 to +180 degrees
|
||||||
|
- Opacity from fully transparent to fully opaque
|
||||||
|
- Custom color with hex input
|
||||||
|
- **Tiling mode** - repeat the watermark across the entire image in a diagonal pattern
|
||||||
|
|
||||||
|
**Image watermarks** overlay a PNG or other image file (great for logos):
|
||||||
|
- Same nine-position grid and opacity controls
|
||||||
|
- Scale relative to the target image (5% to 100%)
|
||||||
|
- Transparency in the watermark image is preserved
|
||||||
|
|
||||||
|
### ✏️ Rename
|
||||||
|
|
||||||
|
Batch rename files with a flexible system that handles everything from simple prefix/suffix additions to complex template-driven naming with EXIF variables.
|
||||||
|
|
||||||
|
**Simple rename** operations:
|
||||||
|
- Add prefix or suffix to existing filenames
|
||||||
|
- Replace spaces with hyphens, underscores, or remove them
|
||||||
|
- Strip special characters
|
||||||
|
- Convert case: lowercase, UPPERCASE, Title Case
|
||||||
|
- Add sequential counters with configurable start number and zero-padding
|
||||||
|
|
||||||
|
**Template engine** for advanced renaming:
|
||||||
|
|
||||||
|
| Variable | Expands to |
|
||||||
|
|---|---|
|
||||||
|
| `{name}` | Original filename without extension |
|
||||||
|
| `{ext}` | File extension |
|
||||||
|
| `{counter}` | Sequential number |
|
||||||
|
| `{counter:N}` | Sequential number with N-digit padding |
|
||||||
|
| `{width}` | Image width in pixels |
|
||||||
|
| `{height}` | Image height in pixels |
|
||||||
|
| `{date}` | File modification date (YYYY-MM-DD) |
|
||||||
|
|
||||||
|
**Find and replace** with full regex support for surgical renaming. Remove prefixes, rewrite patterns, extract parts of filenames - anything regex can express.
|
||||||
|
|
||||||
|
The rename step shows a **live preview** of the first five files so you see exactly what the output filenames will be before processing. A stats bar at the bottom shows total files, naming conflicts, file extensions, and the longest resulting filename.
|
||||||
|
|
||||||
|
## ⚡ Presets
|
||||||
|
|
||||||
|
Presets save a complete workflow configuration and apply it in one click. Pixstrip ships with eight built-in presets that cover the most common batch processing tasks.
|
||||||
|
|
||||||
|
| Preset | What it does |
|
||||||
|
|---|---|
|
||||||
|
| **Blog Photos** | Resize 1200px wide, JPEG high quality, strip all metadata |
|
||||||
|
| **Social Media** | Fit 1080x1080, compress medium, strip metadata, sequential rename |
|
||||||
|
| **Web Optimization** | Convert to WebP, compress high, strip metadata, sequential rename |
|
||||||
|
| **Email Friendly** | Resize 800px wide, JPEG medium quality |
|
||||||
|
| **Privacy Clean** | Strip all metadata, no other changes |
|
||||||
|
| **Photographer Export** | Resize 2048px, compress high, privacy metadata mode, rename by date |
|
||||||
|
| **Archive Compress** | Lossless compression, preserve all metadata |
|
||||||
|
| **Print Ready** | Maximum quality, convert to PNG, keep all metadata |
|
||||||
|
|
||||||
|
**Create your own presets** from any workflow you build. After processing, hit Save as Preset to name it, pick an icon and color, and have it appear on the workflow page for next time. Export presets to share them with others - import presets others have shared with you.
|
||||||
|
|
||||||
|
Presets belong to you. They live as plain JSON files in `~/.config/pixstrip/presets/` - readable, editable, shareable, version-controllable. No proprietary format, no lock-in.
|
||||||
|
|
||||||
|
## 🖥️ Using the GUI
|
||||||
|
|
||||||
|
**Start with a preset** by clicking any card on the workflow page. Pixstrip loads the preset's settings and jumps straight to the image selection step. Add your images, review the output summary, and hit Process. Done.
|
||||||
|
|
||||||
|
**Build a custom workflow** by selecting the Custom card. Toggle the operations you want in the checklist at the bottom of the page, then click Next to step through each one. Only the operations you enabled appear in the wizard - everything else is skipped automatically.
|
||||||
|
|
||||||
|
The **step indicator** across the top tracks your progress. Each step shows as a dot with a label. Click any completed step to jump back and change settings. The Back and Next buttons (or Alt+Left / Alt+Right) move between steps.
|
||||||
|
|
||||||
|
Every step has a **help button** in the header bar that opens a detailed explanation of what the step does, what the options mean, and which keyboard shortcuts are available. A guided **tour** runs on first launch to walk you through the interface.
|
||||||
|
|
||||||
|
When processing finishes, the results page shows how many images were processed, original and output sizes, percentage saved, and processing time. From there you can open the output folder, process another batch, queue more images, or save the workflow as a preset.
|
||||||
|
|
||||||
|
## ⌨️ Using the CLI
|
||||||
|
|
||||||
|
The CLI provides the same operations with the same engine. Every flag maps to a GUI option.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Resize all images in a folder to 1200px wide
|
||||||
|
pixstrip process photos/ --resize 1200
|
||||||
|
|
||||||
|
# Convert PNGs to WebP at high quality
|
||||||
|
pixstrip process *.png --format webp --quality high
|
||||||
|
|
||||||
|
# Fit within 1920x1080, strip metadata, rename to lowercase
|
||||||
|
pixstrip process photos/ --resize fit:1920x1080 --strip-metadata --rename-case lower
|
||||||
|
|
||||||
|
# Use a saved preset
|
||||||
|
pixstrip process photos/ --preset "Blog Photos"
|
||||||
|
|
||||||
|
# Override a preset option
|
||||||
|
pixstrip process photos/ --preset "Blog Photos" --resize 800
|
||||||
|
|
||||||
|
# Per-format compression tuning
|
||||||
|
pixstrip process photos/ --jpeg-quality 90 --webp-quality 85 --avif-quality 70
|
||||||
|
|
||||||
|
# Tiled watermark with custom color and angle
|
||||||
|
pixstrip process photos/ --watermark "(c) 2026" --watermark-tiled \
|
||||||
|
--watermark-rotation 45 --watermark-color ff0000 --watermark-opacity 0.3
|
||||||
|
|
||||||
|
# Image watermark (logo)
|
||||||
|
pixstrip process photos/ --watermark-image logo.png --watermark-scale 0.15
|
||||||
|
|
||||||
|
# Regex rename - remove "IMG_" prefix
|
||||||
|
pixstrip process photos/ --rename-regex-find "^IMG_" --rename-regex-replace ""
|
||||||
|
|
||||||
|
# Sequential numbering with 4-digit padding
|
||||||
|
pixstrip process photos/ --rename-counter-position replace-name \
|
||||||
|
--rename-counter-start 1 --rename-counter-padding 4
|
||||||
|
|
||||||
|
# Template rename with dimensions
|
||||||
|
pixstrip process photos/ --rename-template "{name}_{width}x{height}.{ext}"
|
||||||
|
|
||||||
|
# Everything at once
|
||||||
|
pixstrip process photos/ -r --resize fit:1920x1080 --format webp \
|
||||||
|
--quality high --strip-metadata --rename-case lower \
|
||||||
|
--watermark "(c) 2026" --overwrite skip -o output/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preset and history management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List available presets
|
||||||
|
pixstrip preset list
|
||||||
|
|
||||||
|
# Show preset details
|
||||||
|
pixstrip preset show "Blog Photos"
|
||||||
|
|
||||||
|
# View processing history
|
||||||
|
pixstrip history
|
||||||
|
|
||||||
|
# Undo the last batch (restores from trash)
|
||||||
|
pixstrip undo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Watch folders
|
||||||
|
|
||||||
|
Monitor directories and auto-process new images as they appear.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add a watched directory with a preset
|
||||||
|
pixstrip watch add ~/incoming --preset "Web Optimization"
|
||||||
|
|
||||||
|
# List active watches
|
||||||
|
pixstrip watch list
|
||||||
|
|
||||||
|
# Start the watcher (runs until stopped)
|
||||||
|
pixstrip watch start
|
||||||
|
|
||||||
|
# Remove a watch
|
||||||
|
pixstrip watch remove ~/incoming
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 File Manager Integration
|
||||||
|
|
||||||
|
Right-click any image (or selection of images) in your file manager to process them directly with Pixstrip. A submenu lists all your presets for one-click processing, plus an "Open in Pixstrip" option for the full wizard.
|
||||||
|
|
||||||
|
| File Manager | How it works |
|
||||||
|
|---|---|
|
||||||
|
| **Nautilus** (GNOME Files) | Python extension - dynamically reads your presets |
|
||||||
|
| **Nemo** (Cinnamon) | Python extension - dynamically reads your presets |
|
||||||
|
| **Thunar** (Xfce) | Custom action - regenerated when presets change |
|
||||||
|
| **Dolphin** (KDE) | Service menu - regenerated when presets change |
|
||||||
|
|
||||||
|
Toggle each integration on or off in Settings. The extensions are installed into the standard locations for each file manager.
|
||||||
|
|
||||||
|
## 🏗️ Building from Source
|
||||||
|
|
||||||
|
Pixstrip is written in Rust and builds with standard Cargo tooling.
|
||||||
|
|
||||||
|
**Dependencies:** Rust 1.85+, GTK4 4.16+ dev libraries, libadwaita 1.6+ dev libraries.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install build dependencies (Ubuntu/Debian)
|
||||||
|
sudo apt install libgtk-4-dev libadwaita-1-dev
|
||||||
|
|
||||||
|
# Clone, build, run
|
||||||
|
git clone https://git.lashman.live/lashman/pixstrip.git
|
||||||
|
cd pixstrip
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# The two binaries
|
||||||
|
./target/release/pixstrip-gtk # GUI
|
||||||
|
./target/release/pixstrip-cli # CLI
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building an AppImage
|
||||||
|
|
||||||
|
The included script downloads `linuxdeploy` with its GTK plugin, bundles all shared libraries, and produces a self-contained AppImage.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-appimage.sh
|
||||||
|
# Output: Pixstrip-x86_64.AppImage (~39 MB)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🗂️ Project Structure
|
||||||
|
|
||||||
|
Pixstrip is a Cargo workspace with three crates that share a common core.
|
||||||
|
|
||||||
|
```
|
||||||
|
pixstrip/
|
||||||
|
pixstrip-core/ Processing engine, operations, presets, pipeline, watcher
|
||||||
|
pixstrip-gtk/ GTK4/libadwaita graphical interface
|
||||||
|
pixstrip-cli/ Command-line interface with clap
|
||||||
|
data/ Desktop file, AppStream metainfo, icons
|
||||||
|
```
|
||||||
|
|
||||||
|
The architecture is deliberately simple. `pixstrip-core` contains all the image processing logic and has no GUI dependencies. Both the GTK app and the CLI are thin frontends that build jobs and hand them to the core's pipeline executor. If you wanted to build a different frontend - a web UI, a TUI, a bot - you'd just depend on `pixstrip-core` and wire up the interface.
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Pixstrip is built in the open and contributions are welcome. The project uses [CC0](https://creativecommons.org/publicdomain/zero/1.0/) - there are no copyright assignments, no CLAs, no legal barriers. Your contributions enter the public domain just like the rest of the code.
|
||||||
|
|
||||||
|
File bugs, suggest features, or submit patches through the [issue tracker](https://git.lashman.live/lashman/pixstrip/issues). The codebase is straightforward Rust with GTK4 bindings - if you can read Rust, you can contribute.
|
||||||
|
|
||||||
|
## 🔒 Privacy and Trust
|
||||||
|
|
||||||
|
Pixstrip processes everything on your machine. It never phones home, never uploads your images, never tracks what you do with it. There is no analytics, no crash reporting, no update checker, no account system. The binary does exactly what you tell it to and nothing else.
|
||||||
|
|
||||||
|
Your images are yours. Your metadata is yours. Your workflow is yours. The tool is yours too - CC0 means nobody owns it, including us. Fork it, modify it, redistribute it, sell it, give it away. No permission needed.
|
||||||
|
|
||||||
|
## ⚖️ License
|
||||||
|
|
||||||
|
**CC0 1.0 - Public Domain.** No rights reserved. This work is dedicated to the public domain worldwide. You can copy, modify, distribute, and use it for any purpose, commercial or otherwise, without asking permission and without owing anyone anything.
|
||||||
|
|
||||||
|
Good tools should be freely available to everyone who needs them.
|
||||||
|
This project is developed with the help of a locally-run LLM via opencode for scaffolding, planning, and routine code tasks. Architecture and design decisions are my own.
|
||||||
@@ -84,15 +84,14 @@ fn shell_safe(s: &str) -> String {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pixstrip_bin() -> String {
|
/// Path to the GTK binary for "Open in Pixstrip" actions.
|
||||||
// Try to find the pixstrip binary path
|
fn pixstrip_gtk_bin() -> String {
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
// When running from AppImage, use the AppImage path directly
|
||||||
// If running from the GTK app, find the CLI sibling
|
if let Ok(appimage) = std::env::var("APPIMAGE") {
|
||||||
let dir = exe.parent().unwrap_or(Path::new("/usr/bin"));
|
return appimage;
|
||||||
let cli_path = dir.join("pixstrip");
|
|
||||||
if cli_path.exists() {
|
|
||||||
return cli_path.display().to_string();
|
|
||||||
}
|
}
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
let dir = exe.parent().unwrap_or(Path::new("/usr/bin"));
|
||||||
let gtk_path = dir.join("pixstrip-gtk");
|
let gtk_path = dir.join("pixstrip-gtk");
|
||||||
if gtk_path.exists() {
|
if gtk_path.exists() {
|
||||||
return gtk_path.display().to_string();
|
return gtk_path.display().to_string();
|
||||||
@@ -102,6 +101,22 @@ fn pixstrip_bin() -> String {
|
|||||||
"pixstrip-gtk".into()
|
"pixstrip-gtk".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Path to the CLI binary for preset processing actions.
|
||||||
|
fn pixstrip_cli_bin() -> String {
|
||||||
|
// When running from AppImage, use the AppImage path directly
|
||||||
|
if let Ok(appimage) = std::env::var("APPIMAGE") {
|
||||||
|
return appimage;
|
||||||
|
}
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
let dir = exe.parent().unwrap_or(Path::new("/usr/bin"));
|
||||||
|
let cli_path = dir.join("pixstrip");
|
||||||
|
if cli_path.exists() {
|
||||||
|
return cli_path.display().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"pixstrip".into()
|
||||||
|
}
|
||||||
|
|
||||||
fn get_preset_names() -> Vec<String> {
|
fn get_preset_names() -> Vec<String> {
|
||||||
let mut names: Vec<String> = Preset::all_builtins()
|
let mut names: Vec<String> = Preset::all_builtins()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -138,7 +153,8 @@ fn install_nautilus() -> Result<()> {
|
|||||||
let dir = nautilus_extension_dir();
|
let dir = nautilus_extension_dir();
|
||||||
std::fs::create_dir_all(&dir)?;
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
let bin = pixstrip_bin();
|
let gtk_bin = pixstrip_gtk_bin();
|
||||||
|
let cli_bin = pixstrip_cli_bin();
|
||||||
let presets = get_preset_names();
|
let presets = get_preset_names();
|
||||||
|
|
||||||
let mut preset_items = String::new();
|
let mut preset_items = String::new();
|
||||||
@@ -156,7 +172,8 @@ fn install_nautilus() -> Result<()> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let escaped_bin = bin.replace('\\', "\\\\").replace('\'', "\\'");
|
let escaped_gtk_bin = gtk_bin.replace('\\', "\\\\").replace('\'', "\\'");
|
||||||
|
let escaped_cli_bin = cli_bin.replace('\\', "\\\\").replace('\'', "\\'");
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"import subprocess
|
r#"import subprocess
|
||||||
from gi.repository import Nautilus, GObject
|
from gi.repository import Nautilus, GObject
|
||||||
@@ -204,14 +221,15 @@ class PixstripExtension(GObject.GObject, Nautilus.MenuProvider):
|
|||||||
|
|
||||||
def _on_open(self, menu, files):
|
def _on_open(self, menu, files):
|
||||||
paths = [f.get_location().get_path() for f in files if f.get_location()]
|
paths = [f.get_location().get_path() for f in files if f.get_location()]
|
||||||
subprocess.Popen(['{bin}', '--files'] + paths)
|
subprocess.Popen(['{gtk_bin}'] + paths)
|
||||||
|
|
||||||
def _on_preset(self, menu, preset_name, files):
|
def _on_preset(self, menu, preset_name, files):
|
||||||
paths = [f.get_location().get_path() for f in files if f.get_location()]
|
paths = [f.get_location().get_path() for f in files if f.get_location()]
|
||||||
subprocess.Popen(['{bin}', '--preset', preset_name, '--files'] + paths)
|
subprocess.Popen(['{cli_bin}', 'process', '--preset', preset_name] + paths)
|
||||||
"#,
|
"#,
|
||||||
preset_items = preset_items,
|
preset_items = preset_items,
|
||||||
bin = escaped_bin,
|
gtk_bin = escaped_gtk_bin,
|
||||||
|
cli_bin = escaped_cli_bin,
|
||||||
);
|
);
|
||||||
|
|
||||||
atomic_write(&nautilus_extension_path(), &script)?;
|
atomic_write(&nautilus_extension_path(), &script)?;
|
||||||
@@ -245,19 +263,20 @@ fn install_nemo() -> Result<()> {
|
|||||||
let dir = nemo_action_dir();
|
let dir = nemo_action_dir();
|
||||||
std::fs::create_dir_all(&dir)?;
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
let bin = pixstrip_bin();
|
let gtk_bin = pixstrip_gtk_bin();
|
||||||
|
let cli_bin = pixstrip_cli_bin();
|
||||||
|
|
||||||
// Main "Open in Pixstrip" action
|
// Main "Open in Pixstrip" action
|
||||||
let open_action = format!(
|
let open_action = format!(
|
||||||
"[Nemo Action]\n\
|
"[Nemo Action]\n\
|
||||||
Name=Open in Pixstrip...\n\
|
Name=Open in Pixstrip...\n\
|
||||||
Comment=Process images with Pixstrip\n\
|
Comment=Process images with Pixstrip\n\
|
||||||
Exec={bin} --files %F\n\
|
Exec={gtk_bin} %F\n\
|
||||||
Icon-Name=applications-graphics-symbolic\n\
|
Icon-Name=applications-graphics-symbolic\n\
|
||||||
Selection=Any\n\
|
Selection=Any\n\
|
||||||
Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\
|
Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\
|
||||||
Mimetypes=image/*;\n",
|
Mimetypes=image/*;\n",
|
||||||
bin = bin,
|
gtk_bin = gtk_bin,
|
||||||
);
|
);
|
||||||
atomic_write(&nemo_action_path(), &open_action)?;
|
atomic_write(&nemo_action_path(), &open_action)?;
|
||||||
|
|
||||||
@@ -270,14 +289,14 @@ fn install_nemo() -> Result<()> {
|
|||||||
"[Nemo Action]\n\
|
"[Nemo Action]\n\
|
||||||
Name=Pixstrip: {name}\n\
|
Name=Pixstrip: {name}\n\
|
||||||
Comment=Process with {name} preset\n\
|
Comment=Process with {name} preset\n\
|
||||||
Exec={bin} --preset \"{safe_label}\" --files %F\n\
|
Exec={cli_bin} process --preset \"{safe_label}\" %F\n\
|
||||||
Icon-Name=applications-graphics-symbolic\n\
|
Icon-Name=applications-graphics-symbolic\n\
|
||||||
Selection=Any\n\
|
Selection=Any\n\
|
||||||
Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\
|
Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\
|
||||||
Mimetypes=image/*;\n",
|
Mimetypes=image/*;\n",
|
||||||
name = name,
|
name = name,
|
||||||
safe_label = shell_safe(name),
|
safe_label = shell_safe(name),
|
||||||
bin = bin,
|
cli_bin = cli_bin,
|
||||||
);
|
);
|
||||||
atomic_write(&action_path, &action)?;
|
atomic_write(&action_path, &action)?;
|
||||||
}
|
}
|
||||||
@@ -324,7 +343,8 @@ fn install_thunar() -> Result<()> {
|
|||||||
let dir = thunar_action_dir();
|
let dir = thunar_action_dir();
|
||||||
std::fs::create_dir_all(&dir)?;
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
let bin = pixstrip_bin();
|
let gtk_bin = pixstrip_gtk_bin();
|
||||||
|
let cli_bin = pixstrip_cli_bin();
|
||||||
let presets = get_preset_names();
|
let presets = get_preset_names();
|
||||||
|
|
||||||
let mut actions = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<actions>\n");
|
let mut actions = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<actions>\n");
|
||||||
@@ -334,13 +354,13 @@ fn install_thunar() -> Result<()> {
|
|||||||
" <action>\n\
|
" <action>\n\
|
||||||
\x20 <icon>applications-graphics-symbolic</icon>\n\
|
\x20 <icon>applications-graphics-symbolic</icon>\n\
|
||||||
\x20 <name>Open in Pixstrip...</name>\n\
|
\x20 <name>Open in Pixstrip...</name>\n\
|
||||||
\x20 <command>{bin} --files %F</command>\n\
|
\x20 <command>{gtk_bin} %F</command>\n\
|
||||||
\x20 <description>Process images with Pixstrip</description>\n\
|
\x20 <description>Process images with Pixstrip</description>\n\
|
||||||
\x20 <patterns>*.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp</patterns>\n\
|
\x20 <patterns>*.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp</patterns>\n\
|
||||||
\x20 <image-files/>\n\
|
\x20 <image-files/>\n\
|
||||||
\x20 <directories/>\n\
|
\x20 <directories/>\n\
|
||||||
</action>\n",
|
</action>\n",
|
||||||
bin = bin,
|
gtk_bin = gtk_bin,
|
||||||
));
|
));
|
||||||
|
|
||||||
for name in &presets {
|
for name in &presets {
|
||||||
@@ -348,7 +368,7 @@ fn install_thunar() -> Result<()> {
|
|||||||
" <action>\n\
|
" <action>\n\
|
||||||
\x20 <icon>applications-graphics-symbolic</icon>\n\
|
\x20 <icon>applications-graphics-symbolic</icon>\n\
|
||||||
\x20 <name>Pixstrip: {xml_name}</name>\n\
|
\x20 <name>Pixstrip: {xml_name}</name>\n\
|
||||||
\x20 <command>{bin} --preset \"{safe_label}\" --files %F</command>\n\
|
\x20 <command>{cli_bin} process --preset \"{safe_label}\" %F</command>\n\
|
||||||
\x20 <description>Process with {xml_name} preset</description>\n\
|
\x20 <description>Process with {xml_name} preset</description>\n\
|
||||||
\x20 <patterns>*.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp</patterns>\n\
|
\x20 <patterns>*.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp</patterns>\n\
|
||||||
\x20 <image-files/>\n\
|
\x20 <image-files/>\n\
|
||||||
@@ -356,7 +376,7 @@ fn install_thunar() -> Result<()> {
|
|||||||
</action>\n",
|
</action>\n",
|
||||||
xml_name = name.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """),
|
xml_name = name.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """),
|
||||||
safe_label = shell_safe(name),
|
safe_label = shell_safe(name),
|
||||||
bin = bin,
|
cli_bin = cli_bin,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +412,8 @@ fn install_dolphin() -> Result<()> {
|
|||||||
let dir = dolphin_service_dir();
|
let dir = dolphin_service_dir();
|
||||||
std::fs::create_dir_all(&dir)?;
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
let bin = pixstrip_bin();
|
let gtk_bin = pixstrip_gtk_bin();
|
||||||
|
let cli_bin = pixstrip_cli_bin();
|
||||||
let presets = get_preset_names();
|
let presets = get_preset_names();
|
||||||
|
|
||||||
let mut desktop = format!(
|
let mut desktop = format!(
|
||||||
@@ -406,8 +427,8 @@ fn install_dolphin() -> Result<()> {
|
|||||||
[Desktop Action Open]\n\
|
[Desktop Action Open]\n\
|
||||||
Name=Open in Pixstrip...\n\
|
Name=Open in Pixstrip...\n\
|
||||||
Icon=applications-graphics-symbolic\n\
|
Icon=applications-graphics-symbolic\n\
|
||||||
Exec={bin} --files %F\n\n",
|
Exec={gtk_bin} %F\n\n",
|
||||||
bin = bin,
|
gtk_bin = gtk_bin,
|
||||||
preset_actions = presets
|
preset_actions = presets
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -421,11 +442,11 @@ fn install_dolphin() -> Result<()> {
|
|||||||
"[Desktop Action Preset{i}]\n\
|
"[Desktop Action Preset{i}]\n\
|
||||||
Name={name}\n\
|
Name={name}\n\
|
||||||
Icon=applications-graphics-symbolic\n\
|
Icon=applications-graphics-symbolic\n\
|
||||||
Exec={bin} --preset \"{safe_label}\" --files %F\n\n",
|
Exec={cli_bin} process --preset \"{safe_label}\" %F\n\n",
|
||||||
i = i,
|
i = i,
|
||||||
name = name,
|
name = name,
|
||||||
safe_label = shell_safe(name),
|
safe_label = shell_safe(name),
|
||||||
bin = bin,
|
cli_bin = cli_bin,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
let subfolder_choice: Rc<RefCell<Option<bool>>> = Rc::new(RefCell::new(None));
|
let subfolder_choice: Rc<RefCell<Option<bool>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
// Set up drag-and-drop on the entire page
|
// Set up drag-and-drop on the entire page
|
||||||
let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY);
|
// Accept both FileList (from file managers) and single File
|
||||||
drop_target.set_types(&[gtk::gio::File::static_type()]);
|
let drop_target = gtk::DropTarget::new(gtk::gdk::FileList::static_type(), gtk::gdk::DragAction::COPY | gtk::gdk::DragAction::MOVE);
|
||||||
|
drop_target.set_types(&[gtk::gdk::FileList::static_type(), gtk::gio::File::static_type(), glib::GString::static_type()]);
|
||||||
|
drop_target.set_preload(true);
|
||||||
|
|
||||||
{
|
{
|
||||||
let loaded_files = state.loaded_files.clone();
|
let loaded_files = state.loaded_files.clone();
|
||||||
@@ -40,40 +42,97 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
let stack_ref = stack.clone();
|
let stack_ref = stack.clone();
|
||||||
let subfolder_choice = subfolder_choice.clone();
|
let subfolder_choice = subfolder_choice.clone();
|
||||||
drop_target.connect_drop(move |target, value, _x, _y| {
|
drop_target.connect_drop(move |target, value, _x, _y| {
|
||||||
if let Ok(file) = value.get::<gtk::gio::File>()
|
// Collect paths from FileList, single File, or URI text
|
||||||
&& let Some(path) = file.path()
|
let mut paths: Vec<PathBuf> = Vec::new();
|
||||||
{
|
if let Ok(file_list) = value.get::<gtk::gdk::FileList>() {
|
||||||
|
for file in file_list.files() {
|
||||||
|
if let Some(path) = file.path() {
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Ok(file) = value.get::<gtk::gio::File>() {
|
||||||
|
if let Some(path) = file.path() {
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
} else if let Ok(text) = value.get::<glib::GString>() {
|
||||||
|
// Handle URI text drops (from web browsers)
|
||||||
|
let text = text.trim().to_string();
|
||||||
|
let lower = text.to_lowercase();
|
||||||
|
let is_image_url = (lower.starts_with("http://") || lower.starts_with("https://"))
|
||||||
|
&& (lower.ends_with(".jpg")
|
||||||
|
|| lower.ends_with(".jpeg")
|
||||||
|
|| lower.ends_with(".png")
|
||||||
|
|| lower.ends_with(".webp")
|
||||||
|
|| lower.ends_with(".gif")
|
||||||
|
|| lower.ends_with(".avif")
|
||||||
|
|| lower.ends_with(".tiff")
|
||||||
|
|| lower.ends_with(".bmp")
|
||||||
|
|| lower.contains(".jpg?")
|
||||||
|
|| lower.contains(".jpeg?")
|
||||||
|
|| lower.contains(".png?")
|
||||||
|
|| lower.contains(".webp?"));
|
||||||
|
|
||||||
|
if is_image_url {
|
||||||
|
let loaded = loaded_files.clone();
|
||||||
|
let excl = excluded.clone();
|
||||||
|
let sz = sizes.clone();
|
||||||
|
let sr = stack_ref.clone();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<Option<std::path::PathBuf>>();
|
||||||
|
let url = text.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = download_image_url(&url);
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(Some(path)) => {
|
||||||
|
let mut files = loaded.borrow_mut();
|
||||||
|
if !files.contains(&path) {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
let count = files.len();
|
||||||
|
drop(files);
|
||||||
|
refresh_grid(&sr, &loaded, &excl, &sz, count);
|
||||||
|
glib::ControlFlow::Break
|
||||||
|
}
|
||||||
|
Ok(None) => glib::ControlFlow::Break,
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||||
|
Err(_) => glib::ControlFlow::Break,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if paths.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in paths {
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
let has_subdirs = has_subfolders(&path);
|
let has_subdirs = has_subfolders(&path);
|
||||||
if !has_subdirs {
|
if !has_subdirs {
|
||||||
let mut files = loaded_files.borrow_mut();
|
let mut files = loaded_files.borrow_mut();
|
||||||
add_images_flat(&path, &mut files);
|
add_images_flat(&path, &mut files);
|
||||||
let count = files.len();
|
|
||||||
drop(files);
|
drop(files);
|
||||||
refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count);
|
|
||||||
} else {
|
} else {
|
||||||
let choice = *subfolder_choice.borrow();
|
let choice = *subfolder_choice.borrow();
|
||||||
match choice {
|
match choice {
|
||||||
Some(true) => {
|
Some(true) => {
|
||||||
let mut files = loaded_files.borrow_mut();
|
let mut files = loaded_files.borrow_mut();
|
||||||
add_images_from_dir(&path, &mut files);
|
add_images_from_dir(&path, &mut files);
|
||||||
let count = files.len();
|
|
||||||
drop(files);
|
drop(files);
|
||||||
refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count);
|
|
||||||
}
|
}
|
||||||
Some(false) => {
|
Some(false) => {
|
||||||
let mut files = loaded_files.borrow_mut();
|
let mut files = loaded_files.borrow_mut();
|
||||||
add_images_flat(&path, &mut files);
|
add_images_flat(&path, &mut files);
|
||||||
let count = files.len();
|
|
||||||
drop(files);
|
drop(files);
|
||||||
refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count);
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let mut files = loaded_files.borrow_mut();
|
let mut files = loaded_files.borrow_mut();
|
||||||
add_images_flat(&path, &mut files);
|
add_images_flat(&path, &mut files);
|
||||||
let count = files.len();
|
|
||||||
drop(files);
|
drop(files);
|
||||||
refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count);
|
|
||||||
|
|
||||||
let loaded_files = loaded_files.clone();
|
let loaded_files = loaded_files.clone();
|
||||||
let excluded = excluded.clone();
|
let excluded = excluded.clone();
|
||||||
@@ -98,88 +157,23 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
} else if is_image_file(&path) {
|
} else if is_image_file(&path) {
|
||||||
let mut files = loaded_files.borrow_mut();
|
let mut files = loaded_files.borrow_mut();
|
||||||
if !files.contains(&path) {
|
if !files.contains(&path) {
|
||||||
files.push(path);
|
files.push(path);
|
||||||
}
|
}
|
||||||
let count = files.len();
|
|
||||||
drop(files);
|
drop(files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = loaded_files.borrow().len();
|
||||||
refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count);
|
refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count);
|
||||||
return true;
|
true
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
stack.add_controller(drop_target);
|
stack.add_controller(drop_target);
|
||||||
|
|
||||||
// Also accept URI text drops (from web browsers)
|
|
||||||
let uri_drop = gtk::DropTarget::new(glib::GString::static_type(), gtk::gdk::DragAction::COPY);
|
|
||||||
{
|
|
||||||
let loaded_files = state.loaded_files.clone();
|
|
||||||
let excluded = state.excluded_files.clone();
|
|
||||||
let sizes = state.file_sizes.clone();
|
|
||||||
let stack_ref = stack.clone();
|
|
||||||
uri_drop.connect_drop(move |_target, value, _x, _y| {
|
|
||||||
if let Ok(text) = value.get::<glib::GString>() {
|
|
||||||
let text = text.trim().to_string();
|
|
||||||
// Check if it looks like an image URL
|
|
||||||
let lower = text.to_lowercase();
|
|
||||||
let is_image_url = (lower.starts_with("http://") || lower.starts_with("https://"))
|
|
||||||
&& (lower.ends_with(".jpg")
|
|
||||||
|| lower.ends_with(".jpeg")
|
|
||||||
|| lower.ends_with(".png")
|
|
||||||
|| lower.ends_with(".webp")
|
|
||||||
|| lower.ends_with(".gif")
|
|
||||||
|| lower.ends_with(".avif")
|
|
||||||
|| lower.ends_with(".tiff")
|
|
||||||
|| lower.ends_with(".bmp")
|
|
||||||
|| lower.contains(".jpg?")
|
|
||||||
|| lower.contains(".jpeg?")
|
|
||||||
|| lower.contains(".png?")
|
|
||||||
|| lower.contains(".webp?"));
|
|
||||||
|
|
||||||
if is_image_url {
|
|
||||||
let loaded = loaded_files.clone();
|
|
||||||
let excl = excluded.clone();
|
|
||||||
let sz = sizes.clone();
|
|
||||||
let sr = stack_ref.clone();
|
|
||||||
// Download in background thread
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel::<Option<std::path::PathBuf>>();
|
|
||||||
let url = text.clone();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let result = download_image_url(&url);
|
|
||||||
let _ = tx.send(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
|
||||||
match rx.try_recv() {
|
|
||||||
Ok(Some(path)) => {
|
|
||||||
let mut files = loaded.borrow_mut();
|
|
||||||
if !files.contains(&path) {
|
|
||||||
files.push(path);
|
|
||||||
}
|
|
||||||
let count = files.len();
|
|
||||||
drop(files);
|
|
||||||
refresh_grid(&sr, &loaded, &excl, &sz, count);
|
|
||||||
glib::ControlFlow::Break
|
|
||||||
}
|
|
||||||
Ok(None) => glib::ControlFlow::Break,
|
|
||||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
|
||||||
Err(_) => glib::ControlFlow::Break,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
stack.add_controller(uri_drop);
|
|
||||||
|
|
||||||
adw::NavigationPage::builder()
|
adw::NavigationPage::builder()
|
||||||
.title("Add Images")
|
.title("Add Images")
|
||||||
.tag("step-images")
|
.tag("step-images")
|
||||||
|
|||||||
Reference in New Issue
Block a user