laussyMenu is my own service menu for KDE: a submenu that appears when right-clicking files in Dolphin, holding the handful of operations I actually perform every day. It is the spiritual successor of KIM (KDE Image Menu), the classic right-click image toolbox that has not survived the move to Plasma 6.
The point is not the operations themselves—any of them is a one-line shell command—it is having them under the ring finger, on the files already selected, without opening a terminal, an editor or GIMP.
Built with Claude Opus 5 on 1 August (2026) for Plasma 6.3.2 / Dolphin 24.12.2. Version 1.3.0.
The catalogue of everything currently in the menu, entry by entry, with the tunables and the recipe for adding one, is on LaussyMenu services.
I had a compress_image.desktop lying around since the KDE 4 days. It had stopped working and nothing said why. It turned out to be wrong in three separate ways at once, and that is the whole story of why service menus have a reputation for being nightmarish to maintain:
[DesktopEntry] instead of [Desktop Entry]. One missing space. The file is then simply not a desktop file, and no error is reported anywhere.~/.local/share/kservices5/ServiceMenus/. That was correct for Plasma 5; Plasma 6 does not read that directory at all. The new home is ~/.local/share/kio/servicemenus/..desktop file must have the executable bit set or Dolphin ignores it silently.Three failure modes, zero diagnostics, and each one of them makes the entry vanish completely rather than misbehave visibly. Multiply by every Plasma upgrade and you see why people give up. So the actual deliverable here is not "an image compressor", it is a generator: I never write a .desktop file again.
There are three pieces, and only the middle one is ever edited.
| Piece | Where | What it is |
|---|---|---|
| the driver | ~/bin/laussymenu |
one bash script: installer, generator, dispatcher and doctor |
| the actions | ~/.local/share/laussymenu/actions/ |
one executable script per menu entry, carrying its own metadata |
| the generated menu | ~/.local/share/kio/servicemenus/laussymenu-*.desktop |
written by laussymenu install; never edited by hand
|
An action declares itself in comment lines at the top of its own file:
#!/usr/bin/env bash
# LM-Name: Shrink for upload (2000 px, q85)
# LM-Icon: image-x-generic
# LM-Mime: image/jpeg;image/png;image/tiff;image/bmp;image/webp
# LM-Order: 10
laussymenu install reads those headers, groups the actions by MIME set (KDE attaches MimeType to the file, not to the individual action, so one file per distinct set), writes the .desktop, sets the executable bit, and refreshes the KDE service cache. Adding an entry to the menu is therefore: drop a script in actions/, chmod +x, run laussymenu install. Nothing else, ever.
The generated Exec line is laussymenu run <action> %F rather than the action's own path, so the menu keeps working if I move the action files, and so every entry goes through one place that I can instrument.
The X-KDE-Priority=TopLevel line is what puts laussyMenu directly in the right-click menu instead of burying it two levels down under Actions—that is the ring-finger requirement.
The recurring job: a photo comes off the camera at 6960×4640 and 25 MB, and it needs to become something a wiki, a mail or a form will accept. The target is 2000 px on the longest side at quality 85.
Right-click → laussyMenu → Shrink for upload (2000 px, q85) and a copy called photo-web.jpg appears next to photo.jpg, followed by a notification saying how much was saved.
The naive version is convert in.jpg -resize 2000x2000 -quality 85 out.jpg. Every refinement below came from a specific way that naive version misbehaves.
| Ingredient | Why |
|---|---|
-resize 2000x2000> |
the > means shrink only. Without it a 800×600 thumbnail gets upscaled to 2000 px: bigger file, blurrier picture, no gain whatsoever.
|
-auto-orient before stripping |
phone photos record their rotation in the EXIF Orientation tag. Strip the EXIF first and the picture comes out on its side. Baking the rotation into the pixels first is the fix.
|
-strip |
removes EXIF, ICC junk and thumbnails. Typically 30–60 kB, but the real reason is privacy: camera serial number and GPS coordinates of my house otherwise ride along with every uploaded holiday photo. LM_KEEP_EXIF=1 turns it off when the metadata is the point.
|
-colorspace sRGB |
an AdobeRGB or ProPhoto original looks washed-out in browsers, which assume sRGB and ignore the profile that -strip just removed.
|
-filter Lanczos |
sharper downscaling than the default for large reductions, at no cost worth measuring. |
-interlace Plane |
progressive JPEG. Usually a few percent smaller, and it renders coarse-to-fine while loading instead of top-to-bottom. |
-sampling-factor 4:2:0 |
standard chroma subsampling; the eye's colour resolution is lower than its luminance resolution, so this is nearly free. (Not for screenshots of coloured text, where it smears—use the custom entry with a higher quality there.) |
-quality 85, capped by the source |
85 is the sweet spot where artefacts stop being visible at 100% zoom. But re-encoding a quality-55 image at 85 makes a bigger file that faithfully preserves the original's artefacts. So the script reads identify -format %Q and never encodes above the source's own quality.
|
| PNG stays PNG if transparency is used | flattening an alpha channel onto JPEG gives black or white fringes. The script checks whether the alpha channel is genuinely non-opaque (-alpha extract, minimum < 1); if it is, it stays PNG, otherwise the "PNG" is really a photo and becomes a JPEG.
|
| output alongside, never in place | the original is never touched. The copy is name-web.jpg; a second run gives name-web-2.jpg rather than overwriting; and a file already ending in -web does not become -web-web.
|
Numbers from the acceptance run:
| Input | Output | Note | ||
|---|---|---|---|---|
| Canon EOS 90D photo | 6960×4640, 25.2 MB | 2000×1333, 1.2 MB | 95% smaller; EXIF (make, model, GPS) gone | |
| opaque PNG | 2600×1500, 20.6 MB | 2000×1154, 323 kB JPEG | re-containered, since nothing was transparent | |
| PNG with alpha | 3000×1800, 26 kB | 2000×1200, 12 kB PNG | stayed PNG, transparency intact | |
| quality-55 JPEG | 4000×2000, 398 kB | 2000×1000, 113 kB at q55 | not re-encoded up to 85 | |
| 800×600 thumbnail | 800×600 | not enlarged |
Defaults live in ~/.config/laussymenu.conf and are read at every run, so changing them needs no reinstall:
LM_MAX=2000 # longest side in pixels (never enlarges)
LM_QUALITY=85 # JPEG quality ceiling
LM_SUFFIX=-web # copy is named foo-web.jpg next to foo.jpg
LM_KEEP_EXIF=0 # 1 keeps camera metadata / GPS in the copy
The second menu entry, Shrink for upload (choose size / quality…), pops a kdialog box asking for the two numbers and then calls the same script with them—so there is exactly one implementation of the recipe.
I read papers on the iPad, in Notability, and annotate them there. Getting the annotated copy back out is already automatic—Notability keeps its own backup—but getting a paper in was still done by hand, and the obvious ways of doing it are all wrong for me: Dropbox, Google Drive, mailing it to myself, all of them send the document on a trip through somebody else's computer. Papers, drafts and referee reports have no business transiting through the web or any other public place. So this entry hands the file to the iPad over the local network, straight off this machine, and nothing ever leaves the house.
The first thing to establish was the least welcome one: Notability has no watch folder and no import inbox. Nothing on the Linux side can put a file into its library. Every route—cloud, mail, AirDrop, KDE Connect—ends in the same place, a share-sheet tap on the iPad. The Notability folder that was already sitting in my Dropbox turned out to be the backup end of the pipe, annotated PDFs flowing out of the iPad, and not a way in.
That is worth finding out before writing anything, because it reframes the job. The last tap is irreducible, so everything before it has to cost nothing: no address to type, no hunting through the Files app, no cloud round-trip to wait for.
Right-click a PDF, Send to iPad, and two things happen. The file is hard-linked into a spool directory—a link and not a copy, so a 56 MB thesis costs no extra disk—and a small server is started, if one is not already listening. On the iPad I open a bookmark, the paper is already on the screen, and Share → Notability finishes it.
The server is a single Python file using nothing outside the standard library. It listens on port 8642, serves only what is in the spool, and refuses any client that is not on a private address, so anything reaching the port from outside gets a flat 403.
Because avahi-daemon is running, this machine answers to azag.local over Bonjour, which iOS speaks natively. That is what makes the bookmark durable, and what removed the need for a QR code: the address is short enough to type once, and it survives the router handing out a different lease. Added to the Home Screen, it is one tap.
Both need root, and both were true here:
ufw was active, so the port was dropped before anything reached the server. The cure is ufw allow from 192.168.1.0/24 to any port 8642 proto tcp.azag.local resolved to 192.168.122.1, the libvirt bridge, instead of the Wi-Fi address. Avahi was advertising the virtual interface and the iPad was chasing a dead address. The cure is deny-interfaces=virbr0 in /etc/avahi/avahi-daemon.conf. This was a general mDNS fault on the machine, not something this entry introduced—anything at all asking for azag.local was being misdirected.laussymenu doctor now checks both. The server itself never asks the system for its own address, because gethostbyname() would have answered virbr0 just as happily; it opens a UDP socket towards the gateway and reads back which interface the kernel picked.
200 makes it re-fetch the whole file, repeatedly, on anything large. This one answers 206 with a proper Content-Range, and 416 when the range is nonsense.Content-Disposition: inline. With attachment the iPad downloads the file and I have to go and find it again; with inline Safari renders it and the share sheet is right there.| Command | Effect |
|---|---|
ipad-server --status |
whether it is running, on which address, and how many files are waiting |
ipad-server --ensure |
start it in the background unless it is already listening—what the entry itself calls |
ipad-server --stop |
stop the background instance |
| Command | Effect |
|---|---|
laussymenu install |
regenerate the .desktop files from actions/ and refresh KDE
|
laussymenu list |
show the installed entries with their order and MIME types |
laussymenu doctor |
diagnose a menu that is not showing up |
laussymenu run <action> <files…> |
run an entry from the terminal, exactly as Dolphin calls it |
laussymenu uninstall |
remove the generated files |
doctor exists precisely because of the three silent failures above: it checks that the generated file is where Plasma 6 looks, that it is executable, that the external tools are present, and it warns if stale KF5 menus are still sitting in kservices5/ServiceMenus/ pretending to be installed.
desktop-file-validate reports two errors on the generated file, complaining that MimeType and Actions are only valid for Type=Application. These are expected and must be ignored: Type=Service is a KDE extension that the freedesktop validator does not know about, and this is exactly the shape KDE's own service menus have.convert, not magick. On ImageMagick 7 the geometry argument 2000x2000> needs quoting against the shell in interactive use—inside the script it already is.pngquant is worth it: the script uses it when present, and for screenshots and flat-colour PNGs it beats optipng by a wide margin (lossy palette quantisation, typically 60–70% off, visually indistinguishable). sudo apt install pngquant.notify-send under the application name laussyMenu, so they can be muted or styled separately in System Settings → Notifications.- rather than _, which desktop-file-validate wants, and a Name key in the main groupLM_MENU_ICON, laussymenu from the hicolor theme by default) instead of borrowing one from whichever entry matched; doctor checks itLM-QimgvKey: an action carrying it also runs from inside qimgv, on the picture being viewed, so what is a right-click in Dolphin is one keypress in the viewer. install registers the keys through qimgv-register, and doctor reports them~/bin/laussymenu. This is the only piece that knows anything about the .desktop format, and the whole point is that it is the only piece that ever will.
#!/usr/bin/env bash
# laussymenu — a KIM-like service-menu framework for KDE Plasma 6 (Dolphin)
# Version: 1.3.0
#
# Usage:
# laussymenu install regenerate the .desktop files from actions/ and refresh KDE
# laussymenu uninstall remove the generated .desktop files
# laussymenu list show the installed actions
# laussymenu doctor diagnose why the menu is not showing up
# laussymenu run <action> [files...] run an action directly (what Dolphin calls)
#
# Adding a new entry = drop an executable script in ~/.local/share/laussymenu/actions/
# with an LM- metadata header, then run `laussymenu install`. That is the whole
# maintenance story.
#
# Metadata header keys (read from '# LM-Key: value' comment lines):
# LM-Name menu label (required)
# LM-Mime ';'-separated MIME types (required)
# LM-Icon freedesktop icon name (default: application-x-executable)
# LM-Order sort key inside the submenu (default: 50)
# LM-QimgvKey a key that also runs the action from inside qimgv, on the
# picture being viewed (registered by `install` as a qimgv script;
# restart qimgv to pick it up)
#
# The submenu's own icon is 'laussymenu' from the hicolor theme; override it with
# LM_MENU_ICON (an icon name, or an absolute path to an image file).
set -uo pipefail
VERSION=1.3.0
LM_HOME="${LM_HOME:-$HOME/.local/share/laussymenu}"
ACTION_DIR="$LM_HOME/actions"
MENU_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/kio/servicemenus"
SUBMENU="laussyMenu"
PREFIX="laussymenu"
# The submenu's own icon. Without an Icon= in the [Desktop Entry] group, KDE has
# nothing to draw next to "laussyMenu" and picks one out of the entries inside —
# which is how the whole menu turned into the edit-rename pencil the moment an
# all/allfiles action joined. Installed at 16–256 px in hicolor/*/apps/.
MENU_ICON="${LM_MENU_ICON:-laussymenu}"
die() { printf 'laussymenu: %s\n' "$*" >&2; exit 1; }
meta() { # meta <file> <key> [default]
local v
v=$(sed -n "s/^#[[:space:]]*$2:[[:space:]]*//p" "$1" | head -1)
printf '%s' "${v:-${3-}}"
}
actions() { find "$ACTION_DIR" -maxdepth 1 -type f -perm -u+x -printf '%f\n' 2>/dev/null | sort; }
cmd_install() {
[ -d "$ACTION_DIR" ] || die "no action directory at $ACTION_DIR"
mkdir -p "$MENU_DIR"
rm -f "$MENU_DIR/$PREFIX"-*.desktop
# Group actions by their MIME set: KDE applies MimeType per file, not per action.
local -A group_actions=() group_names=()
local a f mime key
while read -r a; do
[ -n "$a" ] || continue
f="$ACTION_DIR/$a"
mime=$(meta "$f" LM-Mime)
[ -n "$mime" ] || { printf 'skipping %s: no LM-Mime header\n' "$a" >&2; continue; }
key=$(printf '%s' "$mime" | md5sum | cut -c1-8)
group_actions[$key]+="$a "
group_names[$key]="$mime"
done < <(actions)
[ ${#group_actions[@]} -gt 0 ] || die "no executable actions found in $ACTION_DIR"
local n=0 out ids id
for key in "${!group_actions[@]}"; do
n=$((n + 1))
out="$MENU_DIR/$PREFIX-$key.desktop"
ids=""
# order actions by LM-Order then name
while read -r _ a; do
ids+="lm-${a//[^a-zA-Z0-9]/-};"
done < <(for a in ${group_actions[$key]}; do
printf '%s %s\n' "$(meta "$ACTION_DIR/$a" LM-Order 50)" "$a"
done | sort -k1,1n -k2,2)
{
printf '# Generated by laussymenu %s — do not edit; edit the action scripts instead.\n' "$VERSION"
printf '[Desktop Entry]\n'
printf 'Name=laussyMenu\n'; printf 'Type=Service\n'
printf 'Icon=%s\n' "$MENU_ICON"
printf 'MimeType=%s\n' "${group_names[$key]}"
printf 'Actions=%s\n' "$ids"
printf 'X-KDE-Submenu=%s\n' "$SUBMENU"
printf 'X-KDE-Priority=TopLevel\n'
printf 'X-KDE-StartupNotify=false\n'
for a in ${group_actions[$key]}; do
id="lm-${a//[^a-zA-Z0-9]/-}"
printf '\n[Desktop Action %s]\n' "$id"
printf 'Name=%s\n' "$(meta "$ACTION_DIR/$a" LM-Name "$a")"
printf 'Icon=%s\n' "$(meta "$ACTION_DIR/$a" LM-Icon application-x-executable)"
printf 'Exec=%s run %s %%F\n' "$(command -v laussymenu || printf '%s/bin/laussymenu' "$HOME")" "$a"
done
} >"$out"
chmod +x "$out" # KF6 requires this, or Dolphin silently ignores the file
printf 'wrote %s\n' "$out"
done
command -v kbuildsycoca6 >/dev/null && kbuildsycoca6 --noincremental >/dev/null 2>&1
printf 'laussyMenu %s installed (%d file(s), %d action(s)). Restart Dolphin to see it.\n' \
"$VERSION" "$n" "$(actions | wc -l)"
qimgv_register
}
# Actions with an LM-QimgvKey header become qimgv scripts bound to that key,
# so the same thing you right-click in Dolphin is one keypress in the viewer.
# qimgv keeps scripts as a serialised QVariant and drops its default keys the
# moment a custom list exists, so the writing is left to qimgv-register.
qimgv_keys() { # prints "action=KEY" lines
local a k
while read -r a; do
[ -n "$a" ] || continue
k=$(meta "$ACTION_DIR/$a" LM-QimgvKey)
[ -n "$k" ] && printf '%s=%s\n' "$a" "$k"
done < <(actions)
}
qimgv_register() {
local keys
mapfile -t keys < <(qimgv_keys)
[ ${#keys[@]} -gt 0 ] || return 0
command -v qimgv >/dev/null || { printf 'qimgv not installed; %d viewer key(s) not registered.\n' ${#keys[@]}; return 0; }
[ -x "$LM_HOME/qimgv-register" ] || { printf '!! %s/qimgv-register is missing.\n' "$LM_HOME"; return 1; }
"$LM_HOME/qimgv-register" --laussymenu "$(command -v laussymenu || printf '%s/bin/laussymenu' "$HOME")" "${keys[@]}"
pgrep -x qimgv >/dev/null && printf 'qimgv is running: restart it to get the new key(s).\n'
}
cmd_uninstall() {
rm -fv "$MENU_DIR/$PREFIX"-*.desktop
command -v kbuildsycoca6 >/dev/null && kbuildsycoca6 --noincremental >/dev/null 2>&1
echo "laussyMenu removed."
}
cmd_list() {
printf '%-10s %-34s %s\n' ORDER NAME MIME
while read -r a; do
[ -n "$a" ] || continue
printf '%-10s %-34s %s\n' "$(meta "$ACTION_DIR/$a" LM-Order 50)" \
"$(meta "$ACTION_DIR/$a" LM-Name "$a")" "$(meta "$ACTION_DIR/$a" LM-Mime)"
done < <(actions)
}
cmd_doctor() {
printf 'laussymenu %s\n' "$VERSION"
printf 'Plasma: %s\n' "$(plasmashell --version 2>/dev/null || echo '?')"
printf 'Dolphin: %s\n' "$(dolphin --version 2>/dev/null || echo '?')"
printf 'action dir: %s (%s executable action(s))\n' "$ACTION_DIR" "$(actions | wc -l)"
printf 'menu dir: %s\n' "$MENU_DIR"
local f ok=1
for f in "$MENU_DIR/$PREFIX"-*.desktop; do
[ -e "$f" ] || { echo ' !! no generated .desktop — run: laussymenu install'; ok=0; break; }
[ -x "$f" ] && printf ' ok %s (executable)\n' "${f##*/}" \
|| { printf ' !! %s is NOT executable — KF6 will ignore it\n' "${f##*/}"; ok=0; }
done
# A submenu with no resolvable Icon= silently borrows one from the entries
# inside it, so the whole menu takes on the look of whichever action matched.
printf 'submenu icon: %s\n' "$MENU_ICON"
case $MENU_ICON in
/*) [ -r "$MENU_ICON" ] && printf ' ok file exists\n' \
|| { printf ' !! no such file — KDE will fall back to an entry icon\n'; ok=0; } ;;
*)
local found
found=$(find "$HOME/.local/share/icons" /usr/share/icons -name "$MENU_ICON.*" \
-type f 2>/dev/null | wc -l)
if [ "$found" -gt 0 ]; then
printf ' ok %s file(s) in the icon theme\n' "$found"
else
printf ' !! "%s" is in no icon theme — KDE will fall back to an entry icon\n' "$MENU_ICON"
ok=0
fi ;;
esac
grep -qs '^Icon=' "$MENU_DIR/$PREFIX"-*.desktop \
|| printf ' !! generated files carry no Icon= in [Desktop Entry] — reinstall\n'
if [ -e "$HOME/.local/share/kservices5/ServiceMenus" ] && \
[ -n "$(ls -A "$HOME/.local/share/kservices5/ServiceMenus" 2>/dev/null)" ]; then
printf ' note: stale KF5 menus in ~/.local/share/kservices5/ServiceMenus/ — Plasma 6 ignores them.\n'
fi
for f in convert identify notify-send python3; do
command -v "$f" >/dev/null || { printf ' !! missing tool: %s\n' "$f"; ok=0; }
done
# Viewer keys: an LM-QimgvKey is only real once qimgv-register has put it
# in qimgv.conf, and a running qimgv only reads that file at start-up.
local keys
mapfile -t keys < <(qimgv_keys)
if [ ${#keys[@]} -gt 0 ] && command -v qimgv >/dev/null; then
printf 'qimgv keys:\n'
"$LM_HOME/qimgv-register" --status "${keys[@]}" 2>/dev/null | sed 's/^/ /' || ok=0
fi
# Send to iPad: three things silently break the hand-off.
if [ -x "$LM_HOME/ipad-server" ]; then
printf 'iPad server: %s\n' "$("$LM_HOME/ipad-server" --status 2>/dev/null | head -1)"
local port=8642 lanip mdns
port=$(sed -n 's/^LM_IPAD_PORT=\([0-9]*\).*/\1/p' "$HOME/.config/laussymenu.conf" \
2>/dev/null | head -1); port=${port:-8642}
if command -v ufw >/dev/null && ! ufw status 2>/dev/null | grep -q "$port"; then
printf ' note: cannot confirm ufw allows port %s — check with:\n' "$port"
printf ' sudo ufw status | grep %s\n' "$port"
fi
lanip=$(ip -4 route get 192.168.1.1 2>/dev/null | grep -oP 'src \K[\d.]+' | head -1)
mdns=$(avahi-resolve -4 -n "$(hostname).local" 2>/dev/null | awk '{print $2}' | head -1)
if [ -n "$mdns" ] && [ -n "$lanip" ] && [ "$mdns" != "$lanip" ]; then
printf ' !! %s.local resolves to %s but the LAN address is %s\n' \
"$(hostname)" "$mdns" "$lanip"
printf ' (avahi is advertising a virtual interface; the iPad will not reach it)\n'
ok=0
fi
fi
[ "$ok" = 1 ] && echo 'All good.' || echo 'Problems found (see !! above).'
}
cmd_run() {
local a=${1-}; shift || true
[ -n "$a" ] || die "run: no action given"
[ -x "$ACTION_DIR/$a" ] || die "run: no such action: $a"
exec "$ACTION_DIR/$a" "$@"
}
case "${1-}" in
install) shift; cmd_install "$@" ;;
uninstall) shift; cmd_uninstall "$@" ;;
list) shift; cmd_list "$@" ;;
doctor) shift; cmd_doctor "$@" ;;
run) shift; cmd_run "$@" ;;
-V|--version) printf 'laussymenu %s\n' "$VERSION" ;;
""|-h|--help) sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//' ;;
*) die "unknown command: $1 (try --help)" ;;
esac
~/.local/share/laussymenu/actions/image-shrink. Note the LM- header: that is the entire interface between an action and the menu.
#!/usr/bin/env bash
# LM-Name: Shrink for upload (2000 px, q85)
# LM-Icon: image-x-generic
# LM-Mime: image/jpeg;image/png;image/tiff;image/bmp;image/webp;image/heif;image/x-portable-pixmap
# LM-Order: 10
#
# laussymenu action — version 1.1.0
# Makes an upload-sized copy NEXT TO the original. The original is never touched.
#
# Tunables (env, or ~/.config/laussymenu.conf):
# LM_MAX=2000 longest-side cap in pixels; images already smaller are not enlarged
# LM_QUALITY=85 JPEG quality; never raised above the source's own quality
# LM_SUFFIX=-web appended to the basename of the copy
# LM_KEEP_EXIF=0 1 to keep camera metadata (GPS, serial numbers…) in the copy
# LM_IMG_PREFIX=lw- a camera's IMG_4523.jpg becomes lw-4523-web.jpg; empty
# keeps the IMG name as it is
set -uo pipefail
# The environment beats the file. Sourcing the config plainly would overwrite an
# LM_* value the caller had already exported -- which is exactly what a dialog
# that execs a worker does -- so snapshot those, source, and put them back.
_lm_env=$(export -p | grep '^declare -x LM_' || true)
# shellcheck source=/dev/null
[ -r "$HOME/.config/laussymenu.conf" ] && . "$HOME/.config/laussymenu.conf"
[ -n "$_lm_env" ] && eval "$_lm_env"
MAX=${LM_MAX:-2000}
Q=${LM_QUALITY:-85}
SUFFIX=${LM_SUFFIX:--web}
KEEP_EXIF=${LM_KEEP_EXIF:-0}
IMG_PREFIX=${LM_IMG_PREFIX-lw-} # set it empty to keep the camera's own name
notify() { # notify <urgency> <title> <body>
notify-send -a laussyMenu -u "$1" -i image-x-generic "$2" "$3" 2>/dev/null
}
hsize() { numfmt --to=iec --suffix=B --format='%.1f' "$1" 2>/dev/null || printf '%sB' "$1"; }
[ $# -gt 0 ] || { notify critical "laussyMenu" "No file given."; exit 1; }
total_in=0 total_out=0 done_n=0 fail_n=0 last_out="" report=""
for f in "$@"; do
[ -f "$f" ] || { fail_n=$((fail_n + 1)); continue; }
geom=$(identify -quiet -format '%w %h %Q %A' "$f[0]" 2>/dev/null) || {
report+=$'\n'"✗ ${f##*/}: not a readable image"; fail_n=$((fail_n + 1)); continue; }
read -r w h srcq alpha <<<"$geom"
dir=${f%/*}; [ "$dir" = "$f" ] && dir=.
base=${f##*/}; base=${base%.*}
base=${base%"$SUFFIX"} # don't build foo-web-web.jpg chains
# A camera's IMG_4523 says nothing on the wiki: IMG_4523 → lw-4523. Only the
# leading IMG goes; a name that is nothing but IMG keeps it, having no rest.
if [ -n "$IMG_PREFIX" ] && [[ $base =~ ^[Ii][Mm][Gg][-_]?(.+)$ ]]; then
base="$IMG_PREFIX${BASH_REMATCH[1]}"
fi
# Transparency actually in use? Then stay PNG, else go JPEG.
ext=jpg
case "$alpha" in
True|Blend|Associated)
[ "$(convert "$f[0]" -alpha extract -format '%[fx:minima]' info: 2>/dev/null)" = "1" ] || ext=png ;;
esac
out="$dir/$base$SUFFIX.$ext"
i=2; while [ -e "$out" ]; do out="$dir/$base$SUFFIX-$i.$ext"; i=$((i + 1)); done
# Never re-encode *up*: a q60 source stays q60.
q=$Q
case "$srcq" in ''|*[!0-9]*) : ;; *) [ "$srcq" -gt 0 ] && [ "$srcq" -lt "$q" ] && q=$srcq ;; esac
strip=(-strip)
[ "$KEEP_EXIF" = 1 ] && strip=()
if [ "$ext" = jpg ]; then
convert "$f[0]" -auto-orient -colorspace sRGB \
-filter Lanczos -resize "${MAX}x${MAX}>" \
"${strip[@]}" -interlace Plane -sampling-factor 4:2:0 \
-define jpeg:dct-method=float -quality "$q" "$out" 2>/dev/null
else
convert "$f[0]" -auto-orient \
-filter Lanczos -resize "${MAX}x${MAX}>" \
"${strip[@]}" -define png:compression-level=9 "$out" 2>/dev/null
if [ -s "$out" ] && command -v pngquant >/dev/null; then
pngquant --force --skip-if-larger --quality=65-90 \
--output "$out" -- "$out" 2>/dev/null
fi
if [ -s "$out" ] && command -v optipng >/dev/null; then
optipng -quiet -o2 "$out" 2>/dev/null
fi
fi
if [ ! -s "$out" ]; then
rm -f "$out"
report+=$'\n'"✗ ${f##*/}: conversion failed"; fail_n=$((fail_n + 1)); continue
fi
in_b=$(stat -c%s "$f"); out_b=$(stat -c%s "$out")
read -r nw nh <<<"$(identify -quiet -format '%w %h' "$out[0]" 2>/dev/null)"
total_in=$((total_in + in_b)); total_out=$((total_out + out_b))
done_n=$((done_n + 1)); last_out=$out
report+=$'\n'"${out##*/} ${w}×${h} → ${nw}×${nh}, $(hsize "$in_b") → $(hsize "$out_b")"
done
if [ "$done_n" = 0 ]; then
notify critical "laussyMenu — nothing written" "${report:-No image could be processed.}"
exit 1
fi
pct=$(( total_in > 0 ? 100 - 100 * total_out / total_in : 0 ))
if [ "$done_n" = 1 ]; then
title="Shrunk ${last_out##*/} — ${pct}% smaller"
else
title="Shrunk $done_n images — ${pct}% smaller ($(hsize "$total_in") → $(hsize "$total_out"))"
fi
[ "$fail_n" -gt 0 ] && title+=" ($fail_n failed)"
notify normal "$title" "${report#$'\n'}"
~/.local/share/laussymenu/actions/image-shrink-custom, which is how cheap a second entry is once the first one exists: ask for the two numbers, then hand over to the real implementation.
#!/usr/bin/env bash
# LM-Name: Shrink for upload (choose size / quality…)
# LM-Icon: image-resize-symbolic
# LM-Mime: image/jpeg;image/png;image/tiff;image/bmp;image/webp;image/heif;image/x-portable-pixmap
# LM-Order: 20
#
# laussymenu action — version 1.0.4
# Same as image-shrink, but asks for the two numbers first.
set -uo pipefail
# The environment beats the file. Sourcing the config plainly would overwrite an
# LM_* value the caller had already exported -- which is exactly what a dialog
# that execs a worker does -- so snapshot those, source, and put them back.
_lm_env=$(export -p | grep '^declare -x LM_' || true)
# shellcheck source=/dev/null
[ -r "$HOME/.config/laussymenu.conf" ] && . "$HOME/.config/laussymenu.conf"
[ -n "$_lm_env" ] && eval "$_lm_env"
ans=$(kdialog --title "laussyMenu" \
--inputbox "Longest side (px) and JPEG quality (%):" \
"${LM_MAX:-2000} ${LM_QUALITY:-85}") || exit 0
read -r max q _ <<<"$ans"
case "$max$q" in
''|*[!0-9]*)
kdialog --title "laussyMenu" --error "Expected two numbers, e.g. “1600 80”."
exit 1 ;;
esac
if [ "$q" -lt 1 ] || [ "$q" -gt 100 ]; then
kdialog --title "laussyMenu" --error "Quality must be 1–100."
exit 1
fi
export LM_MAX="$max" LM_QUALITY="$q"
exec "${0%/*}/image-shrink" "$@"
~/.local/share/laussymenu/actions/pdf-to-ipad. It spools and starts the server; it deliberately knows nothing about HTTP.
#!/usr/bin/env bash
# LM-Name: Send to iPad (Notability)
# LM-Icon: document-send
# LM-Mime: application/pdf;application/epub+zip
# LM-Order: 30
#
# laussymenu action — version 1.0.1
# Puts the file in the local hand-off spool and makes sure the LAN server is up.
# Nothing is uploaded anywhere: the iPad fetches it straight off this machine.
#
# On the iPad: open the bookmark, the file is there, Share -> Notability.
#
# Tunables (env, or ~/.config/laussymenu.conf):
# LM_IPAD_PORT=8642 port the hand-off server listens on
# LM_IPAD_SPOOL=... where files wait to be collected
set -uo pipefail
# The environment beats the file. Sourcing the config plainly would overwrite an
# LM_* value the caller had already exported -- which is exactly what a dialog
# that execs a worker does -- so snapshot those, source, and put them back.
_lm_env=$(export -p | grep '^declare -x LM_' || true)
# shellcheck source=/dev/null
[ -r "$HOME/.config/laussymenu.conf" ] && . "$HOME/.config/laussymenu.conf"
[ -n "$_lm_env" ] && eval "$_lm_env"
LM_HOME="${LM_HOME:-$HOME/.local/share/laussymenu}"
SPOOL="${LM_IPAD_SPOOL:-$LM_HOME/ipad}"
PORT="${LM_IPAD_PORT:-8642}"
SERVER="$LM_HOME/ipad-server"
notify() { # notify <urgency> <title> <body>
notify-send -a laussyMenu -u "$1" -i document-send "$2" "$3" 2>/dev/null
}
[ $# -gt 0 ] || { notify critical "laussyMenu" "No file given."; exit 1; }
[ -x "$SERVER" ] || { notify critical "laussyMenu" "Missing $SERVER"; exit 1; }
mkdir -p "$SPOOL" || { notify critical "laussyMenu" "Cannot create $SPOOL"; exit 1; }
sent=0 fail=0 report=""
for f in "$@"; do
if [ ! -f "$f" ]; then
report+=$'\n'"✗ ${f##*/}: not a file"; fail=$((fail + 1)); continue
fi
dest="$SPOOL/${f##*/}"
# Hard link when we can — a 56 MB thesis need not be copied to be served.
if ! ln -f "$f" "$dest" 2>/dev/null && ! cp -f "$f" "$dest" 2>/dev/null; then
report+=$'\n'"✗ ${f##*/}: could not spool"; fail=$((fail + 1)); continue
fi
sent=$((sent + 1))
report+=$'\n'"${f##*/}"
done
if [ "$sent" = 0 ]; then
notify critical "laussyMenu — nothing sent" "${report:-No file could be spooled.}"
exit 1
fi
"$SERVER" --ensure 2>/dev/null
# The address the iPad should actually use: the LAN interface, not virbr0.
ip=$(ip -4 route get 192.168.1.1 2>/dev/null | grep -oP 'src \K[\d.]+' | head -1)
[ -n "$ip" ] || ip=$(hostname -I 2>/dev/null | awk '{print $1}')
waiting=$(find "$SPOOL" -maxdepth 1 -type f ! -name '.*' 2>/dev/null | wc -l)
if [ "$sent" = 1 ]; then
title="Sent to iPad — ${report#$'\n'}"
else
title="Sent $sent files to iPad"
fi
[ "$fail" -gt 0 ] && title+=" ($fail failed)"
notify normal "$title" "Open http://$ip:$PORT/ on the iPad — $waiting waiting.$(
[ "$fail" -gt 0 ] && printf '%s' "$report")"
~/.local/share/laussymenu/ipad-server, standard library only.
#!/usr/bin/env python3
"""laussyMenu — hand-off server for the iPad.
Serves whatever the 'Send to iPad' action has dropped in the spool directory,
over the local network only. Nothing ever leaves the house.
The iPad end is a Home Screen bookmark pointing at this server. Open it, the
pending PDF appears, Share -> Notability. Notability has no watch folder, so
that last tap is unavoidable; everything before it is not.
ipad-server run in the foreground (Ctrl-C to stop)
ipad-server --ensure start in the background unless already listening
ipad-server --status say whether it is running, and on which URL
ipad-server --stop stop the background instance
Tunables (env, or ~/.config/laussymenu.conf):
LM_IPAD_PORT=8642 TCP port to listen on
LM_IPAD_SPOOL=... directory of files waiting to be picked up
LM_IPAD_AUTOOPEN=1 with exactly one file pending, go straight to it
"""
import html
import os
import re
import socket
import subprocess
import sys
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
VERSION = "1.1.0"
HOME = os.path.expanduser("~")
LM_HOME = os.environ.get("LM_HOME", os.path.join(HOME, ".local/share/laussymenu"))
def conf(key, default):
"""Read a tunable from the environment, else ~/.config/laussymenu.conf."""
if key in os.environ:
return os.environ[key]
path = os.path.join(HOME, ".config/laussymenu.conf")
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.split("#", 1)[0].strip()
if line.startswith(key + "="):
return line.split("=", 1)[1].strip().strip("\"'")
except OSError:
pass
return default
SPOOL = os.path.expanduser(conf("LM_IPAD_SPOOL", os.path.join(LM_HOME, "ipad")))
PORT = int(conf("LM_IPAD_PORT", "8642"))
AUTOOPEN = conf("LM_IPAD_AUTOOPEN", "1") == "1"
PIDFILE = os.path.join(LM_HOME, "ipad-server.pid")
# Files the iPad has already fetched this session. Not persisted: a restart
# simply means the next visit auto-opens again, which is harmless.
SERVED = set()
# Content types we are willing to serve. Anything else is a download.
TYPES = {
".pdf": "application/pdf",
".epub": "application/epub+zip",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".txt": "text/plain; charset=utf-8",
}
def lan_ip():
"""The address of the interface that actually reaches the LAN.
Deliberately not gethostbyname(): on a machine running libvirt that
answers with the virbr0 address, which the iPad cannot reach.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("192.168.1.1", 9)) # no packet is sent for UDP connect
return s.getsockname()[0]
except OSError:
return "127.0.0.1"
finally:
s.close()
def pending():
"""Spooled files, newest first."""
try:
names = [n for n in os.listdir(SPOOL) if not n.startswith(".")]
except OSError:
return []
files = []
for n in names:
p = os.path.join(SPOOL, n)
if os.path.isfile(p):
files.append((n, os.path.getsize(p), os.path.getmtime(p)))
files.sort(key=lambda t: t[2], reverse=True)
return files
def hsize(n):
for unit in ("B", "kB", "MB", "GB"):
if n < 1024 or unit == "GB":
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
n /= 1024.0
def safe(name):
"""Reject anything that is not a plain file name in the spool."""
return name and "/" not in name and not name.startswith(".")
PAGE = """<!doctype html>
<html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<title>Send to iPad</title>
<style>
:root {{ color-scheme: light dark; }}
body {{ font: 17px/1.5 -apple-system,system-ui,sans-serif; margin: 0;
padding: 1.2rem; background: #fff1c5; color: #241f14; }}
@media (prefers-color-scheme: dark) {{
body {{ background: #1c1a15; color: #ece5d2; }}
.card {{ background: #2a2720 !important; border-color: #4a4436 !important; }}
.rm {{ border-left-color: #4a4436 !important; }} }}
h1 {{ font-size: 1.15rem; font-weight: 600; margin: 0 0 1rem; opacity: .75; }}
.card {{ display: flex; align-items: center; gap: .9rem;
background: #fff; border: 1px solid #e0d6b4;
border-radius: 12px; padding: .95rem 1.05rem; margin-bottom: .6rem; }}
.card a {{ text-decoration: none; color: inherit; }}
.open {{ flex: 1; min-width: 0; display: flex; align-items: center; gap: .9rem; }}
.open:active {{ opacity: .6; }}
.nm {{ flex: 1; min-width: 0; word-break: break-word; font-weight: 500; }}
.sz {{ opacity: .55; font-size: .82rem; white-space: nowrap; }}
.rm {{ color: #b3261e !important; font-size: 1.4rem; line-height: 1;
padding: .2rem .1rem .2rem .7rem; opacity: .45;
border-left: 1px solid #e0d6b4; }}
.clear {{ display: inline-block; margin-top: .8rem; font-size: .85rem;
color: #b3261e; text-decoration: none; opacity: .8; }}
.empty {{ opacity: .55; padding: 2.5rem 0; text-align: center; }}
footer {{ margin-top: 1.6rem; font-size: .78rem; opacity: .45; }}
</style></head><body>
<h1>{title}</h1>
{body}
<footer>laussyMenu {version} · {host}</footer>
</body></html>"""
class Handler(BaseHTTPRequestHandler):
server_version = f"laussyMenu-iPad/{VERSION}"
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args): # keep the console quiet
pass
def _private(self):
"""Serve the LAN only, never a routed address that wandered in."""
ip = self.client_address[0]
return (
ip.startswith("192.168.")
or ip.startswith("10.")
or ip.startswith("127.")
or re.match(r"^172\.(1[6-9]|2\d|3[01])\.", ip)
or ip in ("::1",)
or ip.startswith("fe80:")
)
def _send(self, code, body=b"", ctype="text/html; charset=utf-8", extra=None):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _redirect(self, to):
self.send_response(303)
self.send_header("Location", to)
self.send_header("Content-Length", "0")
self.end_headers()
def do_HEAD(self):
self.do_GET()
def do_GET(self):
if not self._private():
self._send(403, b"Local network only.", "text/plain; charset=utf-8")
return
parsed = urllib.parse.urlparse(self.path)
path = urllib.parse.unquote(parsed.path)
query = urllib.parse.parse_qs(parsed.query)
if path == "/":
self.index(force_list="list" in query)
elif path == "/rm-all":
self.remove_all()
elif path.startswith("/f/"):
self.serve(path[3:])
elif path.startswith("/rm/"):
self.remove(path[4:])
else:
self._send(404, b"No.", "text/plain; charset=utf-8")
def index(self, force_list=False):
files = pending()
# Sending one paper then picking up the iPad is the overwhelmingly
# common case, so go straight to it. But only while it is still
# unread: once fetched, the bookmark must fall back to the list, or
# there would be no way to reach the remove buttons.
fresh = [f for f in files if f[0] not in SERVED]
if AUTOOPEN and len(fresh) == 1 and not force_list:
self._redirect("/f/" + urllib.parse.quote(fresh[0][0]))
return
if not files:
body = '<p class="empty">Nothing waiting.</p>'
title = "Send to iPad"
else:
rows = []
for name, size, _ in files:
q = urllib.parse.quote(name)
rows.append(
f'<div class="card">'
f'<a class="open" href="/f/{q}">'
f'<span class="nm">{html.escape(name)}</span>'
f'<span class="sz">{hsize(size)}</span></a>'
f'<a class="rm" href="/rm/{q}" title="remove">×</a>'
f"</div>"
)
rows.append('<a class="clear" href="/rm-all">clear all</a>')
body = "\n".join(rows)
title = f"{len(files)} waiting"
page = PAGE.format(
title=title, body=body, version=VERSION, host=socket.gethostname()
)
self._send(200, page.encode("utf-8"))
def remove(self, name):
if safe(name):
try:
os.unlink(os.path.join(SPOOL, name))
except OSError:
pass
SERVED.discard(name)
self._redirect("/?list=1")
def remove_all(self):
for name, _, _ in pending():
try:
os.unlink(os.path.join(SPOOL, name))
except OSError:
pass
SERVED.clear()
self._redirect("/?list=1")
def serve(self, name):
if not safe(name):
self._send(404, b"No.", "text/plain; charset=utf-8")
return
path = os.path.join(SPOOL, name)
if not os.path.isfile(path):
self._send(404, b"Gone.", "text/plain; charset=utf-8")
return
SERVED.add(name) # the iPad has seen it; stop auto-opening it
ctype = TYPES.get(os.path.splitext(name)[1].lower(), "application/octet-stream")
size = os.path.getsize(path)
start, end = 0, size - 1
# Safari asks for ranges when rendering a PDF; a 200-only server makes
# it re-fetch the whole file repeatedly on anything large.
rng = self.headers.get("Range")
partial = False
if rng:
m = re.match(r"bytes=(\d*)-(\d*)$", rng.strip())
if m:
g1, g2 = m.group(1), m.group(2)
if g1:
start = int(g1)
if g2:
end = min(int(g2), size - 1)
elif g2: # suffix range: last N bytes
start = max(0, size - int(g2))
if start > end or start >= size:
self.send_response(416)
self.send_header("Content-Range", f"bytes */{size}")
self.send_header("Content-Length", "0")
self.end_headers()
return
partial = True
length = end - start + 1
self.send_response(206 if partial else 200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(length))
self.send_header("Accept-Ranges", "bytes")
# inline: Safari renders the PDF, so Share -> Notability is right there
self.send_header(
"Content-Disposition",
"inline; filename*=UTF-8''" + urllib.parse.quote(name),
)
if partial:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.end_headers()
if self.command == "HEAD":
return
with open(path, "rb") as fh:
fh.seek(start)
left = length
while left > 0:
chunk = fh.read(min(65536, left))
if not chunk:
break
try:
self.wfile.write(chunk)
except (BrokenPipeError, ConnectionResetError):
return # iPad closed the connection; perfectly normal
left -= len(chunk)
def listening():
"""Is something already on our port?"""
s = socket.socket()
s.settimeout(0.4)
try:
s.connect(("127.0.0.1", PORT))
return True
except OSError:
return False
finally:
s.close()
def url():
return f"http://{lan_ip()}:{PORT}/"
def main():
os.makedirs(SPOOL, exist_ok=True)
arg = sys.argv[1] if len(sys.argv) > 1 else ""
if arg == "--status":
if listening():
print(f"running on {url()} (also http://{socket.gethostname()}.local:{PORT}/)")
print(f"{len(pending())} file(s) waiting in {SPOOL}")
else:
print("not running")
return 0
if arg == "--stop":
try:
with open(PIDFILE) as fh:
os.kill(int(fh.read().strip()), 15)
os.unlink(PIDFILE)
print("stopped")
except (OSError, ValueError):
print("not running (no pidfile)")
return 0
if arg == "--ensure":
if listening():
return 0
# Re-exec ourselves, detached, so the caller (Dolphin) is not held.
with open(os.devnull, "wb") as null:
p = subprocess.Popen(
[sys.executable, os.path.abspath(__file__)],
stdout=null, stderr=null, stdin=null, start_new_session=True,
)
with open(PIDFILE, "w") as fh:
fh.write(str(p.pid))
return 0
if arg in ("-V", "--version"):
print(f"ipad-server {VERSION}")
return 0
srv = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
srv.daemon_threads = True
print(f"laussyMenu iPad server {VERSION} on {url()}")
print(f"spool: {SPOOL}")
try:
srv.serve_forever()
except KeyboardInterrupt:
print("\nbye")
return 0
if __name__ == "__main__":
sys.exit(main())
~/.local/share/laussymenu/actions/browse-in-qimgv.
#!/usr/bin/env bash
# LM-Name: Browse this folder in qimgv
# LM-Icon: qimgv
# LM-Mime: inode/directory;image/jpeg;image/png;image/tiff;image/bmp;image/webp;image/gif;image/heif;image/x-portable-pixmap
# LM-Order: 5
#
# laussymenu action — version 1.2.0
# Opens qimgv on a whole folder of images. Right-click an image and it starts on
# that one, with the rest of the folder under the mouse wheel; right-click the
# folder itself and it opens on all of them at once, in qimgv's thumbnail grid.
# qimgv takes either a file or a directory as its argument, so in both cases
# this is one path handed over — there is no listing to build here.
#
# If the folder holds a "browse" subfolder made by browse-copy, that is opened
# instead. Downscaled JPEGs decode about 4x faster than full-size PNGs — 48 ms
# against 186 ms on a timescreenlapse frame — which is the whole difference
# between scrolling and stuttering, and no preloader can close it: qimgv only
# hides the cost of the NEXT frame.
#
# Tunables (env, or ~/.config/laussymenu.conf):
# LM_VIEWER=qimgv image viewer to hand the path to (feh, gwenview…)
# LM_QIMGV_FOLDERVIEW=1 what a click on a FOLDER opens: 1 the thumbnail
# grid, 0 the folder's first image in single view,
# which is the quicker way in when you mean to
# scroll straight through
# LM_BROWSE_PREFER=1 0 to ignore an existing browse/ and look at the
# originals anyway
# LM_BROWSE_DIR=browse its name, shared with browse-copy
set -uo pipefail
# The environment beats the file. Sourcing the config plainly would overwrite an
# LM_* value the caller had already exported -- which is exactly what a dialog
# that execs a worker does -- so snapshot those, source, and put them back.
_lm_env=$(export -p | grep '^declare -x LM_' || true)
# shellcheck source=/dev/null
[ -r "$HOME/.config/laussymenu.conf" ] && . "$HOME/.config/laussymenu.conf"
[ -n "$_lm_env" ] && eval "$_lm_env"
VIEWER=${LM_VIEWER:-qimgv}
FOLDERVIEW=${LM_QIMGV_FOLDERVIEW:-1}
PREFER=${LM_BROWSE_PREFER:-1}
SUBDIR=${LM_BROWSE_DIR:-browse}
notify() { # notify <urgency> <title> <body>
notify-send -a laussyMenu -u "$1" -i qimgv "$2" "$3" 2>/dev/null
}
[ $# -gt 0 ] || { notify critical "laussyMenu" "Nothing given."; exit 1; }
command -v "$VIEWER" >/dev/null 2>&1 || {
notify critical "laussyMenu" "$VIEWER is not installed."
exit 1
}
# The viewer builds its own playlist from the folder, so it wants exactly one
# path. A selection of several is not an error — the first one simply says
# where to start.
target=$1
[ -e "$target" ] || { notify critical "laussyMenu" "${target##*/} is gone."; exit 1; }
# A folder with a browse/ copy in it: scroll the copies, they decode 4x faster.
# Only for a folder click — clicking one image means you want that image, and
# silently showing a different file would be a lie.
if [ "$PREFER" = 1 ] && [ -d "$target" ] && [ -d "$target/$SUBDIR" ] \
&& [ -n "$(find "$target/$SUBDIR" -maxdepth 1 -type f -iname '*.jpg' -print -quit)" ]; then
target="$target/$SUBDIR"
fi
if [ -d "$target" ] && [ "$FOLDERVIEW" != 1 ]; then
# Start on the folder's first image instead of the grid. Same playlist
# either way; this just lands in single-image view a click sooner.
first=$(find "$target" -maxdepth 1 -type f \
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' \
-o -iname '*.webp' -o -iname '*.tif' -o -iname '*.tiff' -o -iname '*.bmp' \
-o -iname '*.avif' -o -iname '*.heif' -o -iname '*.heic' \) \
-printf '%f\n' 2>/dev/null | sort | head -1)
if [ -n "$first" ]; then
target="$target/$first"
else
notify normal "laussyMenu" "${target##*/} holds no images."
exit 1
fi
fi
exec "$VIEWER" -- "$target"
~/.local/share/laussymenu/actions/browse-copy.
#!/usr/bin/env bash
# LM-Name: Make a fast browse copy
# LM-Icon: view-preview
# LM-Mime: inode/directory;image/jpeg;image/png;image/tiff;image/bmp;image/webp;image/heif;image/x-portable-pixmap
# LM-Order: 6
#
# laussymenu action — version 1.0.0
# Builds a downscaled JPEG copy of every image in a folder, into a "browse"
# subfolder, and opens the viewer on that instead of the originals.
#
# The point is scrolling speed, and the numbers are stark. A timescreenlapse
# frame is 3440x1440 PNG and costs ~186 ms to decode; the same frame at 1720 px
# as JPEG costs ~48 ms. Roughly 4x, and that is the whole difference between a
# folder that scrolls and one that stutters. No preloader can help: qimgv only
# hides the cost of the NEXT frame, so as soon as you scroll faster than one
# decode you are waiting on pixels again. The fix is cheaper pixels.
#
# Copies keep the original basenames — frame-000042.png becomes
# frame-000042.jpg — so you always know which frame you are looking at, and a
# frame deleted from the original folder simply has no copy.
#
# Re-running is incremental: a copy that already exists and is newer than its
# source is left alone, so pointing this at a session that has grown since last
# time only converts what is new.
#
# Tunables (env, or ~/.config/laussymenu.conf):
# LM_BROWSE_WIDTH=1720 width of the copies; height follows the aspect
# LM_BROWSE_QUALITY=85 JPEG quality of the copies
# LM_BROWSE_DIR=browse name of the subfolder they go into
# LM_BROWSE_OPEN=1 0 to build them without opening the viewer
# LM_BROWSE_JOBS= parallel conversions; empty means one per core
# LM_VIEWER=qimgv viewer opened on the copies when done
set -uo pipefail
# The environment beats the file. Sourcing the config plainly would overwrite an
# LM_* value the caller had already exported -- which is exactly what a dialog
# that execs a worker does -- so snapshot those, source, and put them back.
_lm_env=$(export -p | grep '^declare -x LM_' || true)
# shellcheck source=/dev/null
[ -r "$HOME/.config/laussymenu.conf" ] && . "$HOME/.config/laussymenu.conf"
[ -n "$_lm_env" ] && eval "$_lm_env"
WIDTH=${LM_BROWSE_WIDTH:-1720}
QUALITY=${LM_BROWSE_QUALITY:-85}
SUBDIR=${LM_BROWSE_DIR:-browse}
OPEN=${LM_BROWSE_OPEN:-1}
JOBS=${LM_BROWSE_JOBS:-$(nproc 2>/dev/null || echo 4)}
VIEWER=${LM_VIEWER:-qimgv}
notify() { # notify <urgency> <title> <body>
notify-send -a laussyMenu -u "$1" -i view-preview "$2" "$3" 2>/dev/null
}
hsize() { numfmt --to=iec --suffix=B --format='%.1f' "$1" 2>/dev/null || printf '%sB' "$1"; }
[ $# -gt 0 ] || { notify critical "laussyMenu" "Nothing given."; exit 1; }
command -v convert >/dev/null || { notify critical "laussyMenu" "ImageMagick is not installed."; exit 1; }
# Clicking a frame means "this folder", the same as clicking the folder itself.
target=$1
[ -e "$target" ] || { notify critical "laussyMenu" "${target##*/} is gone."; exit 1; }
[ -d "$target" ] || target=${target%/*}
[ -d "$target" ] || { notify critical "laussyMenu" "No folder to work on."; exit 1; }
case "${target##*/}" in
"$SUBDIR")
notify normal "laussyMenu" "That is already the $SUBDIR folder."
exit 0 ;;
esac
dest="$target/$SUBDIR"
mkdir -p "$dest" || { notify critical "laussyMenu" "Cannot create $dest."; exit 1; }
# Sources: images sitting directly in the folder, never the copies themselves.
mapfile -t sources < <(find "$target" -maxdepth 1 -type f \
\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.tif' \
-o -iname '*.tiff' -o -iname '*.bmp' -o -iname '*.webp' -o -iname '*.ppm' \
-o -iname '*.heif' -o -iname '*.heic' \) | sort)
if [ "${#sources[@]}" = 0 ]; then
notify normal "laussyMenu" "${target##*/} holds no images."
rmdir "$dest" 2>/dev/null
exit 1
fi
# Incremental: only what is missing or older than its source.
todo=()
for f in "${sources[@]}"; do
base=${f##*/}; out="$dest/${base%.*}.jpg"
[ -f "$out" ] && [ "$out" -nt "$f" ] && continue
todo+=("$f")
done
if [ "${#todo[@]}" = 0 ]; then
notify low "laussyMenu" "The $SUBDIR copy is already up to date (${#sources[@]} images)."
else
notify low "laussyMenu" "Building ${#todo[@]} browse copies at ${WIDTH} px…"
printf '%s\0' "${todo[@]}" | xargs -0 -P "$JOBS" -I{} \
sh -c 'b=${1##*/}; convert "$1" -auto-orient -filter Lanczos -resize "$2x" \
-strip -quality "$3" "$4/${b%.*}.jpg" 2>/dev/null' \
_ {} "$WIDTH" "$QUALITY" "$dest"
fi
made=$(find "$dest" -maxdepth 1 -type f -iname '*.jpg' | wc -l)
if [ "$made" = 0 ]; then
notify critical "laussyMenu" "Nothing could be converted."
exit 1
fi
src_b=0; for f in "${sources[@]}"; do src_b=$((src_b + $(stat -c%s "$f"))); done
dst_b=$(du -sb "$dest" 2>/dev/null | cut -f1)
notify normal "Browse copy ready — $made images" \
"$SUBDIR/ · $(hsize "$src_b") → $(hsize "$dst_b") · ${WIDTH} px"
if [ "$OPEN" = 1 ] && command -v "$VIEWER" >/dev/null 2>&1; then
exec "$VIEWER" -- "$dest"
fi
~/.local/share/laussymenu/actions/tag-original.
#!/usr/bin/env bash
# LM-Name: Tag: keep the original
# LM-Icon: tag
# LM-Mime: image/jpeg;image/png;image/tiff;image/bmp;image/webp;image/gif;image/heif;image/x-portable-pixmap
# LM-Order: 7
# LM-QimgvKey: T
#
# laussymenu action — version 1.0.0
# Marks a picture while browsing and collects its ORIGINAL, full-size file into
# a "tagged" folder, to be picked up later. Press T in qimgv on a frame you want
# to keep; press T again on the same frame to take it back out.
#
# The point is the browse/ folders made by browse-copy: what scrolls fast is a
# 1720 px JPEG, but what you want to keep is the 3440x1440 PNG it was made from.
# So a file inside browse/ is traced back to its source next door — same
# basename, whatever extension the original has — and THAT is what gets kept.
# Tagging an original directly (no browse/ involved) keeps the file itself.
#
# The original is never touched: what lands in tagged/ is a copy.
#
# Reaches qimgv through `laussymenu install`, which registers every action
# carrying an LM-QimgvKey header as a qimgv script bound to that key. From
# Dolphin it is the usual right-click entry, and takes a selection.
#
# Tunables (env, or ~/.config/laussymenu.conf):
# LM_TAG_DIR=tagged where the originals are collected. A bare name is a
# subfolder of the session folder (next to browse/);
# an absolute path is one collection for everything,
# with copies renamed <session>-<file> so that
# frame-000042.png from two sessions cannot collide
# LM_BROWSE_DIR=browse the browse folder's name, shared with browse-copy
set -uo pipefail
# The environment beats the file. Sourcing the config plainly would overwrite an
# LM_* value the caller had already exported -- which is exactly what a dialog
# that execs a worker does -- so snapshot those, source, and put them back.
_lm_env=$(export -p | grep '^declare -x LM_' || true)
# shellcheck source=/dev/null
[ -r "$HOME/.config/laussymenu.conf" ] && . "$HOME/.config/laussymenu.conf"
[ -n "$_lm_env" ] && eval "$_lm_env"
TAGDIR=${LM_TAG_DIR:-tagged}
SUBDIR=${LM_BROWSE_DIR:-browse}
TAGDIR=${TAGDIR/#\~/$HOME}
notify() { # notify <urgency> <title> <body>
notify-send -a laussyMenu -u "$1" -i tag "$2" "$3" 2>/dev/null
}
[ $# -gt 0 ] || { notify critical "laussyMenu" "Nothing given."; exit 1; }
# original <file> -> prints the full-size source of a browse copy, or the file
# itself when it is not a browse copy. Empty (and false) when the source is gone.
original() {
local f=$1 dir base stem cand
dir=${f%/*}; base=${f##*/}; stem=${base%.*}
if [ "${dir##*/}" = "$SUBDIR" ]; then
dir=${dir%/*}
for cand in "$dir/$stem".{png,PNG,jpg,JPG,jpeg,JPEG,tif,tiff,TIF,TIFF,bmp,BMP,webp,WEBP,ppm,heif,heic,HEIC,gif,GIF}; do
[ -f "$cand" ] && { printf '%s\n' "$cand"; return 0; }
done
# Something with that stem, whatever it is called (never the copy).
cand=$(find "$dir" -maxdepth 1 -type f -name "$stem.*" -print -quit 2>/dev/null)
[ -n "$cand" ] && { printf '%s\n' "$cand"; return 0; }
return 1
fi
printf '%s\n' "$f"
}
tagged=0; untagged=0; lost=0; last=""; lastdest=""
for f in "$@"; do
[ -e "$f" ] || { lost=$((lost + 1)); continue; }
f=$(realpath -- "$f")
src=$(original "$f") || { lost=$((lost + 1)); last=${f##*/}; continue; }
session=${src%/*}
case $TAGDIR in
/*) dest=$TAGDIR; name="${session##*/}-${src##*/}" ;;
*) dest="$session/$TAGDIR"; name=${src##*/} ;;
esac
mkdir -p "$dest" || { notify critical "laussyMenu" "Cannot create $dest."; exit 1; }
if [ -e "$dest/$name" ]; then
# Second press on the same frame: take it back out. Only if it really
# is the same picture, so a stale copy of another file is never lost.
if cmp -s "$dest/$name" "$src"; then
rm -f "$dest/$name"; untagged=$((untagged + 1))
else
cp -p -- "$src" "$dest/$name"; tagged=$((tagged + 1))
fi
else
cp -p -- "$src" "$dest/$name" && tagged=$((tagged + 1))
fi
last=$name; lastdest=$dest
done
count=$(find "${lastdest:-/nonexistent}" -maxdepth 1 -type f 2>/dev/null | wc -l)
where=$lastdest; case $TAGDIR in /*) ;; *) where="${lastdest%/*}"; where="${where##*/}/$TAGDIR" ;; esac
if [ "$tagged" = 1 ] && [ "$untagged" = 0 ] && [ "$lost" = 0 ]; then
notify low "Tagged $last" "$count in $where"
elif [ "$untagged" = 1 ] && [ "$tagged" = 0 ] && [ "$lost" = 0 ]; then
notify low "Untagged $last" "$count left in $where"
else
body=""
[ "$tagged" -gt 0 ] && body+="$tagged tagged"
[ "$untagged" -gt 0 ] && body+="${body:+, }$untagged untagged"
[ "$lost" -gt 0 ] && body+="${body:+, }$lost without an original"
[ -n "$lastdest" ] && body+=" · $count in $where"
if [ "$lost" -gt 0 ] && [ "$tagged" = 0 ] && [ "$untagged" = 0 ]; then
notify critical "No original for ${last:-that}" "$body"; exit 1
fi
notify low "laussyMenu" "$body"
fi
~/.local/share/laussymenu/actions/rename-timestamp.
#!/usr/bin/env bash
# LM-Name: Rename with timestamp…
# LM-Icon: edit-rename
# LM-Mime: all/allfiles
# LM-Order: 20
#
# laussymenu action — version 1.0.1
# What Dolphin's F2 batch rename does, plus the timestamp it refuses to give you.
# KIO's rename dialog only ever expands '#' into a sequence number; there is no
# date token and no way to add one short of patching KIO. So this is a stand-in
# dialog that speaks the same '#' language and adds %s / %m / %n / %e.
#
# Tokens:
# #, ##, ### sequence number, zero-padded to the width of the run
# %s batch stamp: ONE timestamp, taken once, shared by every file
# %m that file's own modification time
# %n original name without extension
# %e original extension (without the dot)
# %% a literal %
#
# The extension is kept automatically unless the pattern names one itself
# (i.e. unless it contains a '.' or a %e).
set -uo pipefail
VERSION=1.0.1
FMT_DEFAULT='%Y%m%d-%H%M%S'
PREVIEW_LINES=25
# Source the conf like every other action does — the file carries trailing
# comments on the value lines, which only the shell strips correctly.
# The environment beats the file. Sourcing the config plainly would overwrite an
# LM_* value the caller had already exported -- which is exactly what a dialog
# that execs a worker does -- so snapshot those, source, and put them back.
_lm_env=$(export -p | grep '^declare -x LM_' || true)
# shellcheck source=/dev/null
[ -r "$HOME/.config/laussymenu.conf" ] && . "$HOME/.config/laussymenu.conf"
[ -n "$_lm_env" ] && eval "$_lm_env"
FMT=${LM_RENAME_TSFMT:-$FMT_DEFAULT}
fail() { kdialog --title 'Rename with timestamp' --error "$1" 2>/dev/null || printf '%s\n' "$1" >&2; exit 1; }
(( $# )) || fail "No files given.\n\nThis is a Dolphin service menu action: select files, right-click → laussyMenu."
BATCH=$(date +"$FMT")
# ---------------------------------------------------------------- name pieces
# Split a basename into stem + extension, keeping .tar.gz-style pairs together
# and treating a leading dot as part of the stem (.bashrc has no extension).
split_name() { # split_name <basename> -> sets STEM, EXT
local b=$1 lead=''
[[ $b == .* ]] && { lead='.'; b=${b#.}; }
case $b in
*.tar.gz|*.tar.bz2|*.tar.xz|*.tar.zst) EXT=${b#*.}; EXT=${EXT#*.}; EXT="tar.${EXT}"; STEM=${b%.tar.*} ;;
*.*) EXT=${b##*.}; STEM=${b%.*} ;;
*) EXT=''; STEM=$b ;;
esac
STEM="$lead$STEM"
}
expand() { # expand <pattern> <index> <path>
local pat=$1 idx=$2 path=$3 # `local a=$1 b=${#a}` would read b unset
local out='' i=0 n=${#pat} c t run base
base=${path##*/}
while (( i < n )); do
c=${pat:i:1}
case $c in
'#')
run=0
while (( i < n )) && [[ ${pat:i:1} == '#' ]]; do (( run++, i++ )); done
out+=$(printf "%0${run}d" "$idx")
;;
'%')
t=${pat:i+1:1}
case $t in
s) out+=$BATCH ; i=$(( i + 2 )) ;;
m) out+=$(date -r "$path" +"$FMT") ; i=$(( i + 2 )) ;;
n) split_name "$base"; out+=$STEM ; i=$(( i + 2 )) ;;
e) split_name "$base"; out+=$EXT ; i=$(( i + 2 )) ;;
%) out+='%' ; i=$(( i + 2 )) ;;
*) out+='%' ; (( i++ )) ;;
esac
;;
*) out+=$c; (( i++ )) ;;
esac
done
printf '%s' "$out"
}
# ------------------------------------------------------------------ the ask
count=$#; width=${#count}; (( width < 2 )) && width=2
pad=$(printf '%*s' "$width" ''); pad=${pad// /#}
pattern=$(kdialog --title 'Rename with timestamp' \
--inputbox "Rename the $# selected item(s) to:
# ## ### number %s batch stamp ($BATCH)
%m file's own date %n old name %e extension" \
"${BATCH}_${pad}") || exit 0
[ -n "$pattern" ] || exit 0
# Keep the extension unless the pattern claims to handle it itself.
keep_ext=1
[[ $pattern == *.* || $pattern == *%e* ]] && keep_ext=0
# --------------------------------------------------------------- plan + check
declare -a SRC=() DST=() CLASH=()
declare -A SEEN=() IS_SRC=()
# A target that already exists is only a real clash if it is NOT one of the files
# we are about to rename away — otherwise a shuffle inside the selection (b→a,
# a→b) would be refused, and the two-phase rename below handles it fine.
for f in "$@"; do IS_SRC["$f"]=1; done
i=0
for f in "$@"; do
[ -e "$f" ] || fail "Not found: $f"
(( i++ ))
dir=${f%/*}; [ "$dir" = "$f" ] && dir=.
base=${f##*/}
new=$(expand "$pattern" "$i" "$f")
[ -n "$new" ] || fail "Pattern expands to an empty name for '$base'."
if (( keep_ext )); then split_name "$base"; [ -n "$EXT" ] && new="$new.$EXT"; fi
case $new in */*) fail "The pattern must not contain '/': $new" ;; esac
target="$dir/$new"
if [ -n "${SEEN[$target]:-}" ]; then
CLASH+=("$new ← both '${SEEN[$target]}' and '$base'")
elif [ -e "$target" ] && [ "$target" != "$f" ] && [ -z "${IS_SRC[$target]:-}" ]; then
CLASH+=("$new ← already exists in $dir")
fi
SEEN[$target]=$base
SRC+=("$f"); DST+=("$target")
done
if (( ${#CLASH[@]} )); then
fail "$(printf 'Refusing to rename — %d name collision(s):\n\n%s' \
"${#CLASH[@]}" "$(printf '%s\n' "${CLASH[@]}" | head -"$PREVIEW_LINES")")"
fi
# ------------------------------------------------------------------- preview
preview=''
for ((k = 0; k < ${#SRC[@]} && k < PREVIEW_LINES; k++)); do
preview+=$(printf '%s → %s\n' "${SRC[k]##*/}" "${DST[k]##*/}")$'\n'
done
(( ${#SRC[@]} > PREVIEW_LINES )) && preview+="… and $(( ${#SRC[@]} - PREVIEW_LINES )) more"$'\n'
kdialog --title 'Rename with timestamp' \
--yesno "Rename ${#SRC[@]} item(s)?
$preview" || exit 0
# --------------------------------------------------------------------- do it
# Two phases, so a pattern that shuffles names among the selected files (b→a,
# a→b) cannot eat one of them on the way.
declare -a TMP=()
for ((k = 0; k < ${#SRC[@]}; k++)); do
t="${SRC[k]%/*}/.lmrename.$$.$k"
if ! mv -n -- "${SRC[k]}" "$t" 2>/dev/null; then
for ((j = k - 1; j >= 0; j--)); do mv -n -- "${TMP[j]}" "${SRC[j]}" 2>/dev/null; done
fail "Could not move '${SRC[k]##*/}' aside. Nothing was renamed."
fi
TMP+=("$t")
done
done_n=0; failed=()
for ((k = 0; k < ${#TMP[@]}; k++)); do
if mv -n -- "${TMP[k]}" "${DST[k]}" 2>/dev/null; then
(( done_n++ ))
else
mv -n -- "${TMP[k]}" "${SRC[k]}" 2>/dev/null
failed+=("${SRC[k]##*/}")
fi
done
if (( ${#failed[@]} )); then
fail "$(printf 'Renamed %d of %d. Left untouched:\n\n%s' \
"$done_n" "${#SRC[@]}" "$(printf '%s\n' "${failed[@]}" | head -"$PREVIEW_LINES")")"
fi
notify-send -a 'laussyMenu' -i edit-rename 'Renamed' \
"$done_n item(s) — stamp $BATCH" 2>/dev/null
exit 0
~/.local/share/laussymenu/actions/gif-shrink.
#!/usr/bin/env bash
# LM-Name: Shrink this GIF…
# LM-Icon: image-gif
# LM-Mime: image/gif
# LM-Order: 30
#
# laussymenu action — version 2.1.0
# Makes a smaller copy of an animated GIF NEXT TO the original, which is never
# touched. Animation, frame count and timing are preserved; only the pixels, the
# palette and the LZW stream shrink.
#
# Three numbers, asked in one dialog, in order of how much they buy you.
# Measured on a 35 MB, 166-frame, 1280 px screen capture:
#
# width 1280 → 800 35 MB → 15 MB
# colours 256 → 128 → 64 15 MB → 12 MB → 8.9 MB
# lossy 0 → 30 → 80 → 150 15 MB → 8.4 → 6.8 → 6.0 MB
#
# The work is ffmpeg's two-pass palette route — palettegen reads the whole clip
# and builds one optimal palette, paletteuse maps the frames onto it — which
# beats a plain `convert -resize` on an animation by a wide margin. On top of
# that, diff_mode=rectangle stores only the rectangle that changed from frame to
# frame, the right bet for a screen recording where most of the picture sits
# still. Then gifsicle takes the LZW stream itself.
#
# On dithering, which is NOT offered because the answer does not vary: bayer is
# always right here. At a full palette no dither pattern changes the size at all
# (15 MB whichever you pick); at 64 colours, error diffusion costs 38% MORE
# (sierra2_4a 12 MB against bayer's 8.9 MB) because it sprays fine noise over
# flat areas, and noise is precisely what LZW cannot compress.
#
# Tunables (env, or ~/.config/laussymenu.conf) — these seed the dialog:
# LM_GIF_WIDTH=800 target width in px; narrower GIFs are never enlarged
# LM_GIF_COLORS=256 palette size, 4–256
# LM_GIF_LOSSY=30 gifsicle lossy level: 0 off, 30 safe, 80 visible on
# gradients but fine on flat interfaces, 200 is the ceiling
# LM_GIF_FPS= frames per second of the copy; empty keeps the original
# timing. Halving it roughly halves the file.
# LM_GIF_ASK=1 0 runs straight from the values above, no dialog
# LM_SUFFIX=-web appended to the basename of the copy
# LM_IMG_PREFIX=lw- a camera's IMG_4523.gif becomes lw-4523-web.gif; empty
# keeps the IMG name as it is
set -uo pipefail
# The environment beats the file. Sourcing the config plainly would overwrite an
# LM_* value the caller had already exported -- which is exactly what a dialog
# that execs a worker does -- so snapshot those, source, and put them back.
_lm_env=$(export -p | grep '^declare -x LM_' || true)
# shellcheck source=/dev/null
[ -r "$HOME/.config/laussymenu.conf" ] && . "$HOME/.config/laussymenu.conf"
[ -n "$_lm_env" ] && eval "$_lm_env"
WIDTH=${LM_GIF_WIDTH:-800}
COLORS=${LM_GIF_COLORS:-256}
LOSSY=${LM_GIF_LOSSY:-30}
FPS=${LM_GIF_FPS:-}
ASK=${LM_GIF_ASK:-1}
SUFFIX=${LM_SUFFIX:--web}
IMG_PREFIX=${LM_IMG_PREFIX-lw-} # set it empty to keep the camera's own name
notify() { # notify <urgency> <title> <body>
notify-send -a laussyMenu -u "$1" -i image-gif "$2" "$3" 2>/dev/null
}
hsize() { numfmt --to=iec --suffix=B --format='%.1f' "$1" 2>/dev/null || printf '%sB' "$1"; }
[ $# -gt 0 ] || { notify critical "laussyMenu" "No file given."; exit 1; }
command -v ffmpeg >/dev/null || { notify critical "laussyMenu" "ffmpeg is not installed."; exit 1; }
if [ "$ASK" = 1 ] && command -v kdialog >/dev/null; then
ans=$(kdialog --title "laussyMenu" \
--inputbox "Width (px), palette (4–256), lossy (0–200):" \
"$WIDTH $COLORS $LOSSY") || exit 0
read -r WIDTH COLORS LOSSY _ <<<"$ans"
case "$WIDTH$COLORS$LOSSY" in
''|*[!0-9]*)
kdialog --title "laussyMenu" --error "Expected three numbers, e.g. “800 128 30”."
exit 1 ;;
esac
fi
case "$WIDTH" in ''|*[!0-9]*) WIDTH=800 ;; esac
case "$COLORS" in ''|*[!0-9]*) COLORS=256 ;; esac
case "$LOSSY" in ''|*[!0-9]*) LOSSY=0 ;; esac
[ "$WIDTH" -lt 16 ] && WIDTH=16
[ "$COLORS" -lt 4 ] && COLORS=4
[ "$COLORS" -gt 256 ] && COLORS=256
[ "$LOSSY" -gt 200 ] && LOSSY=200
if [ "$LOSSY" -gt 0 ] && ! command -v gifsicle >/dev/null; then
notify normal "laussyMenu" "gifsicle is not installed — the lossy pass is skipped.\nInstall it with: sudo apt install gifsicle"
LOSSY=0
fi
# One palette pass over a long clip is slow — say something before the wait.
big=0
for f in "$@"; do
[ -f "$f" ] && [ "$(stat -c%s "$f")" -gt 5000000 ] && big=1
done
[ "$big" = 1 ] && notify low "laussyMenu" "Compressing $# GIF(s), this takes a moment…"
total_in=0 total_out=0 done_n=0 fail_n=0 last_out="" report=""
for f in "$@"; do
[ -f "$f" ] || { fail_n=$((fail_n + 1)); continue; }
# csv's own separator option refuses a space, so take the commas and split.
IFS=, read -r w h nframes <<<"$(ffprobe -v error -select_streams v:0 \
-show_entries stream=width,height,nb_frames -of csv=p=0 "$f" 2>/dev/null)"
case "$w" in ''|*[!0-9]*)
report+=$'\n'"✗ ${f##*/}: not a readable GIF"; fail_n=$((fail_n + 1)); continue ;;
esac
dir=${f%/*}; [ "$dir" = "$f" ] && dir=.
base=${f##*/}; base=${base%.*}
base=${base%"$SUFFIX"} # don't build foo-web-web.gif chains
# A camera's IMG_4523 says nothing on the wiki: IMG_4523 → lw-4523. Only the
# leading IMG goes; a name that is nothing but IMG keeps it, having no rest.
if [ -n "$IMG_PREFIX" ] && [[ $base =~ ^[Ii][Mm][Gg][-_]?(.+)$ ]]; then
base="$IMG_PREFIX${BASH_REMATCH[1]}"
fi
out="$dir/$base$SUFFIX.gif"
i=2; while [ -e "$out" ]; do out="$dir/$base$SUFFIX-$i.gif"; i=$((i + 1)); done
# Shrink only. A GIF already narrower than the target keeps its own width and
# is merely re-encoded, which on an unoptimised file is worth having anyway.
target=$WIDTH
[ "$w" -lt "$target" ] && target=$w
vf=""
[ -n "$FPS" ] && vf="fps=$FPS,"
[ "$target" != "$w" ] && vf+="scale=$target:-1:flags=lanczos,"
vf+="split[a][b];[a]palettegen=max_colors=$COLORS:stats_mode=diff[p]"
vf+=";[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"
if ! ffmpeg -loglevel error -y -i "$f" -vf "$vf" -loop 0 "$out" 2>/dev/null || [ ! -s "$out" ]; then
rm -f "$out"
report+=$'\n'"✗ ${f##*/}: ffmpeg could not re-encode it"; fail_n=$((fail_n + 1)); continue
fi
# gifsicle now takes the LZW stream. -O3 alone gains nothing on ffmpeg's
# output, which is already well packed; --lossy is what actually pays, by
# nudging pixels until the runs grow long enough to compress.
if command -v gifsicle >/dev/null; then
lossy=()
[ "$LOSSY" -gt 0 ] && lossy=(--lossy="$LOSSY")
gifsicle -O3 --careful "${lossy[@]}" --output "$out.tmp" -- "$out" 2>/dev/null \
&& [ -s "$out.tmp" ] && mv -f "$out.tmp" "$out"
rm -f "$out.tmp"
fi
in_b=$(stat -c%s "$f"); out_b=$(stat -c%s "$out")
IFS=, read -r nw nh <<<"$(ffprobe -v error -select_streams v:0 \
-show_entries stream=width,height -of csv=p=0 "$out" 2>/dev/null)"
total_in=$((total_in + in_b)); total_out=$((total_out + out_b))
done_n=$((done_n + 1)); last_out=$out
line="${out##*/} ${w}×${h} → ${nw}×${nh}, $(hsize "$in_b") → $(hsize "$out_b")"
[ "$out_b" -ge "$in_b" ] && line+=" ⚠ the original was smaller"
report+=$'\n'"$line"
done
if [ "$done_n" = 0 ]; then
notify critical "laussyMenu — nothing written" "${report:-No GIF could be processed.}"
exit 1
fi
pct=$(( total_in > 0 ? 100 - 100 * total_out / total_in : 0 ))
if [ "$done_n" = 1 ]; then
title="Shrunk ${last_out##*/} — ${pct}% smaller"
else
title="Shrunk $done_n GIFs — ${pct}% smaller ($(hsize "$total_in") → $(hsize "$total_out"))"
fi
[ "$fail_n" -gt 0 ] && title+=" ($fail_n failed)"
notify normal "$title" "${report#$'\n'}"
~/.local/share/laussymenu/qimgv-register, which laussymenu install calls to bind every action carrying an LM-QimgvKey to that key inside qimgv.
#!/usr/bin/env python3
"""qimgv-register — bind laussymenu actions to keys inside qimgv.
qimgv-register [--conf FILE] [--laussymenu PATH] [--status] NAME=KEY ...
Each NAME=KEY pair becomes a qimgv "script" called lm-NAME, running
laussymenu run NAME %file%
and a shortcut KEY -> that script. Called by `laussymenu install` for every
action carrying an `# LM-QimgvKey: KEY` header; --status only reports.
Why this exists: qimgv stores its scripts as a Qt-serialised QVariant inside
~/.config/qimgv/qimgv.conf, which no text editor can write by hand, and its
shortcut list REPLACES the built-in defaults as soon as it is non-empty. So a
one-line "add my key" would silently strip every other key in the viewer.
This helper produces the exact bytes qimgv 1.0.x (Qt5) writes itself, and
seeds the shortcut list with the full default set before adding ours.
Anything else in the file — the user's own scripts and keys, all other
groups — is carried over untouched; the lm-* entries are the only ones
rewritten. qimgv reads both at start-up only, so a running viewer needs a
restart to see a new key (QSettings merges on exit, so nothing is lost).
"""
import os, re, struct, sys
# qimgv's own defaults (actionmanager.cpp, initDefaults, non-Apple), as
# "action=shortcut" pairs — the form saveShortcuts() writes to the file.
DEFAULTS = [
("Right", "nextImage"), ("Left", "prevImage"),
("XButton2", "nextImage"), ("XButton1", "prevImage"),
("WheelDown", "nextImage"), ("WheelUp", "prevImage"),
("F", "toggleFullscreen"), ("F11", "toggleFullscreen"),
("LMB_DoubleClick", "toggleFullscreen"), ("MiddleButton", "exit"),
("Space", "toggleFitMode"), ("1", "fitWindow"), ("2", "fitWidth"),
("3", "fitNormal"), ("R", "resize"), ("H", "flipH"), ("V", "flipV"),
("Ctrl+R", "rotateRight"), ("Ctrl+L", "rotateLeft"),
("Ctrl+WheelUp", "zoomInCursor"), ("Ctrl+WheelDown", "zoomOutCursor"),
("+", "zoomIn"), ("-", "zoomOut"), ("Ctrl+Down", "zoomOut"),
("Ctrl+Up", "zoomIn"), ("Up", "scrollUp"), ("Down", "scrollDown"),
("Ctrl+O", "open"), ("Ctrl+S", "save"), ("Ctrl+Shift+S", "saveAs"),
("Ctrl+W", "setWallpaper"), ("X", "crop"), ("Ctrl+P", "print"),
("Alt+X", "exit"), ("Ctrl+Q", "exit"), ("Esc", "closeFullScreenOrExit"),
("Del", "moveToTrash"), ("Shift+Del", "removeFile"), ("C", "copyFile"),
("M", "moveFile"), ("Home", "jumpToFirst"), ("End", "jumpToLast"),
("Ctrl+Right", "seekVideoForward"), ("Ctrl+Left", "seekVideoBackward"),
(",", "frameStepBack"), (".", "frameStep"), ("Enter", "folderView"),
("Backspace", "folderView"), ("F5", "reloadImage"),
("Ctrl+C", "copyFileClipboard"), ("Ctrl+Shift+C", "copyPathClipboard"),
("F2", "renameFile"), ("RMB", "contextMenu"), ("Menu", "contextMenu"),
("I", "toggleImageInfo"), ("Ctrl+`", "toggleShuffle"),
("Ctrl+D", "showInDirectory"), ("`", "toggleSlideshow"),
("Ctrl+Z", "discardEdits"), ("Shift+Right", "nextDirectory"),
("Shift+Left", "prevDirectory"), ("Shift+F", "toggleFullscreenInfoBar"),
("Ctrl+V", "pasteFile"), ("P", "openSettings"),
]
# ---- Qt encodings -----------------------------------------------------------
def script_variant(command, blocking=False):
"""The @Variant(...) text QSettings writes for a qimgv Script value:
QVariant streamed with QDataStream::Qt_4_0 — user type id 127, the type
name as a NUL-terminated char*, then QString command (byte length +
UTF-16BE) and a bool."""
name = b"Script\0"
utf16 = command.encode("utf-16-be")
raw = (struct.pack(">I", 127) + struct.pack(">I", len(name)) + name
+ struct.pack(">I", len(utf16)) + utf16 + bytes([1 if blocking else 0]))
return "@Variant(" + raw.decode("latin-1") + ")"
HEX = set("0123456789abcdefABCDEF")
def ini_escape(s, force_quotes=False):
"""QSettings' iniEscapedString for a value that is not codec-encoded:
control and non-ASCII chars as \\xHH (and a following hex digit too, since
the reader is greedy), quotes when the value holds , ; = or edge spaces."""
out = []
needs_quotes = force_quotes
esc_digit = False
for ch in s:
o = ord(ch)
if ch in ";,=":
needs_quotes = True
if esc_digit and ch in HEX:
out.append("\\x%x" % o); continue
esc_digit = False
if ch == "\0":
out.append("\\0"); esc_digit = True
elif ch in "\a\b\f\n\r\t\v":
out.append({"\a": "\\a", "\b": "\\b", "\f": "\\f", "\n": "\\n",
"\r": "\\r", "\t": "\\t", "\v": "\\v"}[ch])
elif ch in '"\\':
out.append("\\" + ch)
elif o <= 0x1F or o >= 0x7F:
out.append("\\x%x" % o); esc_digit = True
else:
out.append(ch)
r = "".join(out)
if needs_quotes or (r and (r[0] == " " or r[-1] == " ")):
r = '"' + r + '"'
return r
def ini_unescape_list(s):
"""The reverse, for the shortcuts line: a comma-separated list whose
elements may be quoted and use the escapes above."""
items, cur, i, n = [], [], 0, len(s)
in_quotes = False
while i < n:
ch = s[i]
if ch == "\\" and i + 1 < n:
i += 1; ch = s[i]
if ch == "x":
j = i + 1
while j < n and s[j] in HEX: j += 1
cur.append(chr(int(s[i + 1:j], 16) if j > i + 1 else 0)); i = j; continue
cur.append({"0": "\0", "a": "\a", "b": "\b", "f": "\f", "n": "\n",
"r": "\r", "t": "\t", "v": "\v"}.get(ch, ch)); i += 1; continue
if ch == '"':
in_quotes = not in_quotes; i += 1; continue
if ch == "," and not in_quotes:
items.append("".join(cur).strip()); cur = []; i += 1; continue
cur.append(ch); i += 1
items.append("".join(cur).strip())
return [x for x in items if x]
# ---- the conf file ----------------------------------------------------------
def read_groups(path):
"""[(group, [line, ...]), ...] — comments and order preserved."""
groups, cur = [], (None, [])
if os.path.exists(path):
with open(path, encoding="utf-8", errors="surrogateescape") as f:
for line in f.read().splitlines():
m = re.match(r"^\[(.*)\]\s*$", line)
if m:
groups.append(cur); cur = (m.group(1), [])
else:
cur[1].append(line)
groups.append(cur)
return [g for g in groups if g[0] is not None or g[1]]
def write_groups(path, groups):
lines = []
for name, body in groups:
if name is not None:
if lines: lines.append("")
lines.append("[%s]" % name)
lines.extend(l for l in body if l.strip() != "")
tmp = path + ".lm-tmp"
with open(tmp, "w", encoding="utf-8", errors="surrogateescape") as f:
f.write("\n".join(lines) + "\n")
os.replace(tmp, path)
def group(groups, name):
for g in groups:
if g[0] == name: return g
g = (name, []); groups.append(g); return g
def parse_scripts(body):
"""[(name_raw, value_raw)] in index order from a [Scripts] body."""
d = {}
for line in body:
m = re.match(r"^script\\(\d+)\\(name|value)=(.*)$", line)
if m: d.setdefault(int(m.group(1)), {})[m.group(2)] = m.group(3)
return [(d[i].get("name", ""), d[i].get("value", "")) for i in sorted(d)]
def unquote_name(raw):
return ini_unescape_list(raw)[0] if raw else ""
def main(argv):
conf = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
"qimgv", "qimgv.conf")
lm = "laussymenu"
status = False
pairs = []
it = iter(argv)
for a in it:
if a == "--conf": conf = next(it)
elif a == "--laussymenu": lm = next(it)
elif a == "--status": status = True
elif "=" in a:
n, k = a.split("=", 1); pairs.append((n.strip(), k.strip()))
else:
sys.exit("qimgv-register: bad argument %r" % a)
groups = read_groups(conf)
scripts = parse_scripts(group(groups, "Scripts")[1])
ctl = group(groups, "Controls")[1]
cur = []
for line in ctl:
if line.startswith("shortcuts="):
cur = ini_unescape_list(line[len("shortcuts="):])
have = {} # key -> action, as qimgv holds them (file form is action=key)
for e in cur:
act, _, key = e.partition("=")
if key.endswith("eq") and not any(key == d[0] for d in DEFAULTS): key = key[:-2] + "="
if act and key: have[key] = act
if status:
names = {unquote_name(n) for n, _ in scripts}
for n, k in pairs:
bound = have.get(k) == "s:lm-" + n and ("lm-" + n) in names
print("%s %s -> %s%s" % ("ok " if bound else "!! ", k, "lm-" + n,
"" if bound else " (not registered — run: laussymenu install)"))
return 0 if all(have.get(k) == "s:lm-" + n for n, k in pairs) else 1
# Scripts: drop every lm-* entry, then add ours; the rest stays verbatim.
kept = [(n, v) for n, v in scripts if not unquote_name(n).startswith("lm-")]
for n, _ in pairs:
kept.append((ini_escape("lm-" + n),
ini_escape(script_variant("%s run %s %%file%%" % (lm, n)))))
body = ["script\\%d\\name=%s\nscript\\%d\\value=%s" % (i, n, i, v)
for i, (n, v) in enumerate(kept, 1)]
body = "\n".join(body).split("\n") if body else []
body.append("script\\size=%d" % len(kept))
groups[[g[0] for g in groups].index("Scripts")] = ("Scripts", body)
# Shortcuts: what the user has, or qimgv's defaults when nothing is set
# (a non-empty list replaces the defaults wholesale, so seed them), minus
# stale lm-* bindings and whatever sat on our keys, plus ours.
if not have:
have = dict(DEFAULTS)
have = {k: a for k, a in have.items() if not a.startswith("s:lm-")}
for n, k in pairs:
have[k] = "s:lm-" + n
entries = []
for k, a in have.items():
entries.append(ini_escape("%s=%s" % (a, k[:-1] + "eq" if k.endswith("=") else k)))
ctl[:] = [l for l in ctl if not l.startswith("shortcuts=")]
ctl.append("shortcuts=" + ", ".join(entries))
groups[[g[0] for g in groups].index("Controls")] = ("Controls", ctl)
os.makedirs(os.path.dirname(conf), exist_ok=True)
write_groups(conf, groups)
for n, k in pairs:
print("qimgv: %s -> lm-%s" % (k, n))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
~/.config/laussymenu.conf, sourced at every run, so retuning needs no reinstall.
# laussyMenu defaults — edit freely, no reinstall needed.
# Shrink for upload
LM_MAX=2000 # longest side in pixels (never enlarges)
LM_QUALITY=85 # JPEG quality ceiling
LM_SUFFIX=-web # copy is named foo-web.jpg next to foo.jpg
LM_KEEP_EXIF=0 # 1 keeps camera metadata / GPS in the copy
LM_IMG_PREFIX=lw- # IMG_4523.jpg → lw-4523-web.jpg; empty keeps the IMG name
# (also applies to the GIF shrinker)
# Browse in qimgv
# LM_VIEWER=qimgv # feh, gwenview… also work
LM_QIMGV_FOLDERVIEW=1 # right-clicking a FOLDER: 1 the thumbnail grid,
# 0 its first image, straight into single view
LM_BROWSE_PREFER=1 # open browse/ when a folder has one (4× faster scroll)
# Fast browse copy — downscaled JPEGs of a folder, into browse/
LM_BROWSE_WIDTH=1720 # width of the copies
LM_BROWSE_QUALITY=85 # their JPEG quality
LM_BROWSE_DIR=browse # subfolder they live in
LM_BROWSE_OPEN=1 # 0 to build them without opening the viewer
# LM_BROWSE_JOBS= # parallel conversions; empty means one per core
# Tag: keep the original — T in qimgv (or right-click) copies the full-size
# source of the frame on screen into a folder, for later retrieval; T again
# takes it back out. A browse/ copy is traced back to the original next door.
LM_TAG_DIR=tagged # bare name: <session>/tagged/ next to browse/;
# an absolute path collects everything in one place
# as <session>-<file> (e.g. ~/Pictures/tagged)
# Shrink an animated GIF — these three seed the dialog
LM_GIF_WIDTH=800 # target width in px (never enlarges a narrower GIF)
LM_GIF_COLORS=256 # palette size, 4–256
LM_GIF_LOSSY=30 # gifsicle lossy level: 0 off, 30 safe, 80 hard, 200 the ceiling
# LM_GIF_FPS= # frames per second of the copy; empty keeps the original timing
# LM_GIF_ASK=0 # skip the dialog and just use the values above
# Send to iPad
LM_IPAD_PORT=8642 # LAN port the hand-off server listens on
LM_IPAD_AUTOOPEN=1 # one file waiting → open it instead of listing
# LM_IPAD_SPOOL=~/.local/share/laussymenu/ipad
# Rename with timestamp
LM_RENAME_TSFMT=%Y%m%d-%H%M%S # strftime format for the %s and %m tokens
And this is the thing I no longer have to write, generated into ~/.local/share/kio/servicemenus/ and made executable. There are two such files since the iPad entry arrived—one per distinct MIME set, because KDE puts MimeType on the file and not on the action:
# Generated by laussymenu 1.3.0 — do not edit; edit the action scripts instead.
[Desktop Entry]
Name=laussyMenu
Type=Service
Icon=laussymenu
MimeType=image/jpeg;image/png;image/tiff;image/bmp;image/webp;image/heif;image/x-portable-pixmap
Actions=lm-image-shrink;lm-image-shrink-custom;
X-KDE-Submenu=laussyMenu
X-KDE-Priority=TopLevel
X-KDE-StartupNotify=false
[Desktop Action lm-image-shrink]
Name=Shrink for upload (2000 px, q85)
Icon=image-x-generic
Exec=/home/laussy/bin/laussymenu run image-shrink %F
[Desktop Action lm-image-shrink-custom]
Name=Shrink for upload (choose size / quality…)
Icon=image-resize-symbolic
Exec=/home/laussy/bin/laussymenu run image-shrink-custom %F
Entries that belong in here next: convert to PDF and merge PDFs, "copy path to clipboard", checksum, extract audio from a video, and a send to the wiki entry that uploads the shrunk image straight to Special:Upload and puts the [[File:…]] markup on the clipboard.