laussyMenu
Fabrice P. Lauss𝕪’s ℤetetic Web

laussyMenu

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.

Why this needed doing at all

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:

  • The header read [DesktopEntry] instead of [Desktop Entry]. One missing space. The file is then simply not a desktop file, and no error is reported anywhere.
  • It lived in ~/.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/.
  • It was not executable. Since KDE Frameworks 6, a service menu .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.

Architecture

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 first action: shrinking images for upload

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.

What the recipe actually does

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.

Measured

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

Tuning

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.

The second action: sending a PDF to the iPad

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.

Notability has no letterbox

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.

How it works

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.

The two things that silently break it

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.

Details that turned out to matter

  • Range requests. Safari asks for byte ranges when rendering a PDF. A server that only ever answers 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.
  • Going straight to the paper. One file waiting is the normal case, so the index redirects to it instead of showing a list of one. But only while it is unread: once fetched, the bookmark falls back to the list, or there would be no way of ever reaching the remove buttons.
  • Reading a file does not consume it. Files stay in the spool until I clear them, and clearing drops the hard link only—the original is untouched.
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

Commands

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.

Notes and gotchas

  • Restart Dolphin after the first install. New menus are usually picked up on the next right-click, but a Dolphin that has already cached its actions will not show them until it is restarted.
  • 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.
  • Ubuntu ships ImageMagick 6, so the command is convert, not magick. On ImageMagick 7 the geometry argument 2000x2000> needs quoting against the shell in interactive use—inside the script it already is.
  • Installing 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.
  • Notifications come through notify-send under the application name laussyMenu, so they can be muted or styled separately in System Settings → Notifications.

Versions

  1. v°1.0 on 1 August (2026) — first version: the generator, and image shrinking
  2. v°1.0.1 on 1 August (2026) — action identifiers use - rather than _, which desktop-file-validate wants, and a Name key in the main group
  3. v°1.0.2 on 1 August (2026) — dense one-liners unfolded, so that the listings below fit the width of this page
  4. v°1.1.0 on 3 August (2026) — a second entry: hand a PDF to the iPad over the local network, for Notability
  5. v°1.2.0 on 5 August (2026) — a third entry, Rename with timestamp…, and its catalogue, LaussyMenu services
  6. v°1.2.1 on 5 August (2026) — the submenu carries its own icon (LM_MENU_ICON, laussymenu from the hicolor theme by default) instead of borrowing one from whichever entry matched; doctor checks it
  7. v°1.3.0 on 3 September (2026) — LM-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

Source

The driver

~/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

An action

~/.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'}"

The variant that asks

~/.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" "$@"

The iPad entry

~/.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")"

The hand-off server

~/.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} &middot; {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">&times;</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())

Browse this folder in qimgv

~/.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"

Make a fast browse copy

~/.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

Tag: keep the original

~/.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

Rename with timestamp…

~/.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

Shrink this GIF…

~/.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'}"

The qimgv keys

~/.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:]))

The defaults

~/.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

What comes out

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

To do

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.