Recently Written · git

sh

Small shell and Python scripts

git clone https://github.com/equwal/sh

Log | Files | Refs


commit 8e35cab050ef84debb5bbcd70e0b04b7235a9a30
equwal <13551856+equwal@users.noreply.github.com>
2026-10-04 22:51:27 -0700

Import scripts from ~/src/sh unchanged

 bm                        | 686 ++++++++++++++++++++++++++++++++++++++++++++++
 git-update-repos          |  32 +++
 git-update-worker.sh      |  28 ++
 group4ify                 |  15 +
 group4ify-aux             |   2 +
 http-collect              | 193 +++++++++++++
 jpegify                   |  15 +
 jpegify-aux               |   2 +
 macho                     |  25 ++
 mpdmenu                   |  76 +++++
 pashare                   |  13 +
 pomodoro                  |  83 ++++++
 pomodoro-files/config.def |   2 +
 sbm/bm-commit             |  36 +++
 sbm/bm-page               | 212 ++++++++++++++
 sbm/bm-sync               | 248 +++++++++++++++++
 sbm/bm-title              |  33 +++
 slock-usb-key             |  24 ++
 tag-music                 |  49 ++++
 windervolage              |  57 ++++
 20 files changed, 1831 insertions(+)
diff --git a/bm b/bm
new file mode 100755
index 0000000..5b5f090
--- /dev/null
+++ b/bm
@@ -0,0 +1,686 @@
+#!/bin/sh
+
+# bm: bookmarks in a plain file, picked with dmenu (or fzf).
+#
+#     URL<tab>description<tab>tag tag tag
+#
+# bm owns that file and nothing else. Importing, link checking, the web page
+# and so on are separate bm-* filters; see README.
+
+DMENULINES=${DMENULINES:-15}
+DATADIR=${XDG_DATA_HOME:-$HOME/.local/share}/sbm
+BOOKMARKS=${BOOKMARKS:-$DATADIR/bookmarks}
+USERTAGS=${USERTAGS:-$DATADIR/usertags}
+ENGINES=${SBM_ENGINES:-$DATADIR/engines}
+SEARCH=${SBM_SEARCH:-https://duckduckgo.com/?q=%s}
+USAGE=$BOOKMARKS.usage
+WLOCK=$BOOKMARKS.lock
+locked=
+TAB=$(printf '\t')
+
+# Two URLs are the same bookmark when they differ only in scheme, a leading
+# www., trailing slashes or the case of the host.
+AWK_NORM='
+function norm(u,    host, rest, i) {
+    sub(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, "", u)
+    i = index(u, "/")
+    if (i) { host = substr(u, 1, i - 1); rest = substr(u, i) }
+    else   { host = u; rest = "" }
+    host = tolower(host)
+    sub(/^www\./, "", host)
+    sub(/\/+$/, "", rest)
+    return host rest
+}'
+
+die () {
+    printf 'bm: %s\n' "$*" >&2
+    exit 1
+}
+
+have () {
+    command -v "$1" >/dev/null 2>&1
+}
+
+usage () {
+    cat <<'EOF'
+usage: bm [-t tag] [-S order] [action]
+Without an action: browse. Pick a bookmark, then what to do with it. Text
+that is no bookmark is opened as an address, or searched for on the web.
+  -o, --open        open a bookmark, an address or a web search
+                    (-p, --plumb is the same)
+  -c, --copy        copy a bookmark's URL to the clipboard
+      --print       print a bookmark's URL
+  -a, --add [url]   add a bookmark; the url defaults to the selection
+  -e, --edit        open the bookmark file in $EDITOR at a bookmark
+  -d, --delete      delete a bookmark
+  -l, --list        print the bookmarks
+  -m, --merge       add the bookmarks on stdin, skipping the known ones
+  -t, --tag <tag>   only consider bookmarks with this tag
+  -S, --sort <o>    used (default), recent, url, desc or tag
+  -h, --help
+EOF
+}
+
+# ---- external programs: menu, clipboard, opener ----
+
+# dmenu when there is a display to draw it on and bm does not run in a
+# terminal, for example from a key of the window manager. In a terminal fzf
+# is the menu, because its preview shows the page of the bookmark under the
+# cursor. fzf also stands in when dmenu is not installed.
+pickmenu () {
+    [ -n "$SBM_MENU" ] && return
+    if [ -n "$DISPLAY$WAYLAND_DISPLAY" ] && ! { [ -t 0 ] && [ -t 2 ]; }; then
+        order='dmenu fzf'
+    else
+        order='fzf dmenu'
+    fi
+    for menu in $order; do
+        if have "$menu"; then
+            SBM_MENU=$menu
+            return
+        fi
+    done
+    # shellcheck disable=SC2016
+    die 'no menu program found: install dmenu or fzf, or set $SBM_MENU'
+}
+
+# $SBM_MENU may carry arguments, so it is split on purpose.
+custommenu () {
+    # shellcheck disable=SC2086
+    $SBM_MENU "$@"
+}
+
+fzfcmd () {
+    # shellcheck disable=SC2086
+    fzf -i --reverse --height=40% $SBM_FZF_OPTS "$@"
+}
+
+# fzfpick <fzf options>: the chosen line, or else what was typed, which is
+# what dmenu gives by itself.
+fzfpick () {
+    out=$(fzfcmd --print-query "$@")
+    chosen=$(printf '%s\n' "$out" | sed -n 2p)
+    printf '%s\n' "${chosen:-$(printf '%s\n' "$out" | sed -n 1p)}"
+}
+
+# pick <prompt>: choices on stdin; the chosen line, or the typed text.
+pick () {
+    case $SBM_MENU in
+        fzf)   fzfpick --prompt="$1 " ;;
+        dmenu) dmenu -i -l "$DMENULINES" -p "$1" ;;
+        *)     custommenu pick "$1" ;;
+    esac
+}
+
+# pickmulti <prompt>: like pick, for any number of lines.
+pickmulti () {
+    choices=$(cat)
+    case $SBM_MENU in
+        fzf)
+            while :; do
+                out=$(printf '%s\n' "$choices" | fzfcmd --multi --print-query \
+                      --prompt="$1 " --header='TAB marks, ENTER confirms. No match: ENTER creates the typed tag, ESC finishes.')
+                chosen=$(printf '%s\n' "$out" | sed 1d)
+                if [ -n "$chosen" ]; then
+                    printf '%s\n' "$chosen"
+                    break
+                fi
+                typed=$(printf '%s\n' "$out" | sed -n 1p)
+                [ -n "$typed" ] || break
+                printf '%s\n' "$typed"
+            done
+            ;;
+        dmenu)
+            while :; do
+                one=$(printf 'done | (choose when done)\n%s\n' "$choices" \
+                      | dmenu -i -l "$DMENULINES" -p "$1")
+                # An empty answer means the menu was closed.
+                [ -z "$one" ] || [ "${one%% *}" = 'done' ] && break
+                printf '%s\n' "$one"
+            done
+            ;;
+        *)  printf '%s\n' "$choices" | custommenu multi "$1" ;;
+    esac
+}
+
+# ask <prompt> [default]: free text on stdout.
+ask () {
+    case $SBM_MENU in
+        fzf)
+            printf '%s%s: ' "$1" "${2:+ [$2]}" >&2
+            IFS= read -r reply || reply=
+            printf '%s\n' "${reply:-$2}"
+            ;;
+        dmenu)
+            if [ -n "$2" ]; then printf '%s\n' "$2"; fi | dmenu -p "$1:"
+            ;;
+        *)
+            if [ -n "$2" ]; then printf '%s\n' "$2"; fi | custommenu ask "$1"
+            ;;
+    esac
+}
+
+clip_out () {
+    # shellcheck disable=SC2086,SC2016
+    if   [ -n "$SBM_COPY" ]; then $SBM_COPY
+    elif [ -n "$WAYLAND_DISPLAY" ] && have wl-copy; then wl-copy
+    elif have xclip; then xclip -i -selection clipboard
+    elif have xsel; then xsel -ib
+    else die 'no clipboard program found: set $SBM_COPY'
+    fi
+}
+
+# The primary selection first, then the clipboard. Never fatal: without
+# either there is just no URL to suggest.
+clip_in () {
+    # shellcheck disable=SC2086
+    if   [ -n "$SBM_PASTE" ]; then $SBM_PASTE
+    elif [ -n "$WAYLAND_DISPLAY" ] && have wl-paste; then
+        wl-paste -n -p; echo; wl-paste -n
+    elif have xclip; then
+        xclip -o; echo; xclip -o -selection clipboard
+    elif have xsel; then
+        xsel -op; echo; xsel -ob
+    fi 2>/dev/null
+}
+
+opener () {
+    # shellcheck disable=SC2086,SC2016
+    if   [ -n "$SBM_OPEN" ]; then $SBM_OPEN "$1"
+    elif have xdg-open; then xdg-open "$1"
+    else die 'no opener found: set $SBM_OPEN'
+    fi
+}
+
+# ---- the bookmark file ----
+
+# Bookmarks restricted to $tagfilter and ordered by $sortorder, each followed
+# by a fourth field: how often it was used.
+rows () {
+    SBM_TAG=$tagfilter SBM_ORDER=$sortorder SBM_USAGE=$USAGE awk -F'\t' '
+        BEGIN {
+            while ((getline line < ENVIRON["SBM_USAGE"]) > 0) {
+                split(line, f, "\t")
+                used[f[1]] = f[2]
+            }
+            order = ENVIRON["SBM_ORDER"]
+        }
+        NF == 0 || /^#/ { next }
+        ENVIRON["SBM_TAG"] != "" {
+            hit = 0
+            n = split($3, tags, " ")
+            for (i = 1; i <= n; i++)
+                if (tags[i] == ENVIRON["SBM_TAG"]) hit = 1
+            if (!hit) next
+        }
+        {
+            if (order == "used")        key = sprintf("%09d", 999999999 - used[$1])
+            else if (order == "recent") key = sprintf("%09d", 999999999 - NR)
+            else if (order == "desc")   key = tolower($2)
+            else if (order == "tag")    key = tolower($3)
+            else                        key = ""
+            printf "%s\t%s\t%s\t%s\t%s\t%d\n", key, tolower($1), $1, $2, $3, used[$1]
+        }' "$BOOKMARKS" | LC_ALL=C sort -t"$TAB" -k1,1 -k2,2 | cut -f3-
+}
+
+# details <url>: a bookmark in full, one field per line.
+details () {
+    rows | SBM_URL=$1 awk -F'\t' '$1 == ENVIRON["SBM_URL"] {
+        printf "%s\n%s\ntags: %s\nused: %d\n", $1, $2, $3, $4
+        exit
+    }'
+}
+
+is_bookmark () {
+    SBM_URL=$1 awk -F'\t' '$1 == ENVIRON["SBM_URL"] { found = 1; exit }
+        END { exit !found }' "$BOOKMARKS"
+}
+
+# For fzf a fifth field is added, and it is all that is listed. The first
+# four feed the preview, which follows the cursor: the bookmark in full, then
+# the text of its page when bm-page is installed. fzf stops the preview when
+# the cursor moves on, so the short sleep keeps a fast scroll from fetching
+# each page on the way. A sleep that knows no fractions fails and skips it.
+PREVIEW="printf '%s\n%s\ntags: %s\nused: %s\n\n' {1} {2} {3} {4}; sleep 0.3 2>/dev/null; bm-page {1} 2>/dev/null"
+fzfrows () {
+    awk -F'\t' 'NF {
+        desc = $2
+        if (length(desc) > 44) desc = substr(desc, 1, 42) ".."
+        printf "%s\t%-44s  %-18s  %s\n", $0, desc, $3, $1
+    }'
+}
+
+# withpreview <fzf command and options>: for the output of fzfrows.
+withpreview () {
+    "$@" --delimiter="$TAB" --with-nth=5.. \
+         --preview-window=right:50%:wrap --preview="$PREVIEW"
+}
+
+# choose <prompt>: a bookmark's URL, or whatever else the user typed.
+choose () {
+    if [ "$SBM_MENU" = fzf ]; then
+        rows | fzfrows | withpreview fzfpick --prompt="$1 "
+    else
+        rows | cut -f1-3 | pick "$1"
+    fi | cut -f1
+}
+
+# choose_url <prompt>: like choose, but only a bookmark will do.
+choose_url () {
+    if [ -z "$(rows)" ]; then
+        [ -n "$tagfilter" ] && die "no bookmarks tagged '$tagfilter'"
+        die 'no bookmarks yet; add one with: bm --add'
+    fi
+    url=$(choose "$1")
+    [ -n "$url" ] || exit 1
+    is_bookmark "$url" || die "not a bookmark: $url"
+    printf '%s\n' "$url"
+}
+
+# find_dup <url>: print the bookmark that is the same page as <url>, if any.
+find_dup () {
+    SBM_URL=$1 awk -F'\t' "$AWK_NORM"'
+        BEGIN { want = norm(ENVIRON["SBM_URL"]) }
+        NF && !/^#/ && norm($1) == want { print; exit }' "$BOOKMARKS"
+}
+
+# tmpfile <file>: create an empty temporary file and print its name. Beside
+# <file> if that directory can be written to, else in $TMPDIR. noclobber makes
+# the shell create it with O_EXCL, so a file or symlink planted under that
+# name in a shared /tmp is refused rather than followed: mktemp, in POSIX.
+tmpfile () {
+    for candidate in "$1.tmp.$$" "${TMPDIR:-/tmp}/sbm.$$.${1##*/}"; do
+        if (umask 077; set -C; : > "$candidate") 2>/dev/null; then
+            printf '%s\n' "$candidate"
+            return 0
+        fi
+    done
+    return 1
+}
+
+# rewrite <file>: replace <file> with stdin. Through cat, not mv, so that a
+# symlinked file stays a symlink and keeps its mode.
+rewrite () {
+    tmp=$(tmpfile "$1") || die "cannot create a temporary file for $1"
+    cat > "$tmp" && cat "$tmp" > "$1"
+    rc=$?
+    rm -f "$tmp"
+    return $rc
+}
+
+# lock: take the write lock of the bookmark file, a directory beside it. bm
+# holds it while it changes the file, and bm-sync while it reads or writes
+# the file, so that neither writes over a change of the other. A lock whose
+# process is gone is taken over. After $SBM_LOCK_WAIT seconds (default 10)
+# bm stops.
+lock () {
+    waited=0
+    until mkdir "$WLOCK" 2>/dev/null; do
+        pid=$(cat "$WLOCK/pid" 2>/dev/null)
+        if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then
+            [ "$(cat "$WLOCK/pid" 2>/dev/null)" != "$pid" ] || rm -rf "$WLOCK"
+            continue
+        fi
+        [ "$waited" -lt "${SBM_LOCK_WAIT:-10}" ] \
+            || die "$BOOKMARKS is in use (bm -e or bm-sync). If neither runs, remove $WLOCK"
+        sleep 1
+        waited=$((waited + 1))
+    done
+    echo $$ > "$WLOCK/pid"
+    locked=1
+    # Also in the subshell of add_inside, which does not keep these traps.
+    trap unlock EXIT
+    trap 'exit 1' HUP INT TERM
+}
+
+unlock () {
+    [ -z "$locked" ] || rm -rf "$WLOCK"
+    locked=
+}
+
+# bump <url>: count a use, for the "used" order. The counts live beside the
+# bookmark file, which stays a plain list: url, count, time of last use.
+# srand() returns the previous seed, and the first seed is the time of day:
+# that is "date +%s" for a POSIX that has no %s.
+bump () {
+    [ -e "$USAGE" ] || : > "$USAGE" || return 0
+    SBM_URL=$1 awk -F'\t' '
+        BEGIN { OFS = "\t"; srand(); now = srand() }
+        $1 == ENVIRON["SBM_URL"] { $2 = $2 + 1; $3 = now; seen = 1 }
+        { print }
+        END { if (!seen) print ENVIRON["SBM_URL"], 1, now }
+    ' "$USAGE" | rewrite "$USAGE"
+}
+
+# learn_tags: tag names on stdin; the ones $USERTAGS lacks are appended to it.
+learn_tags () {
+    [ -e "$USERTAGS" ] || : > "$USERTAGS"
+    while IFS= read -r name; do
+        [ -n "$name" ] || continue
+        sed 's/[ |].*//' "$USERTAGS" | grep -Fxq -- "$name" \
+            || printf '%s | \n' "$name" >> "$USERTAGS"
+    done
+}
+
+# changed <message>: the file was written. bm-commit, where installed, keeps
+# its history in git. bm-sync, where installed, tells your other machines; it
+# runs in the background, so that a slow network never holds the menu up.
+changed () {
+    if have bm-commit; then bm-commit "$1"; fi
+    if have bm-sync; then bm-sync -q >/dev/null 2>&1 & fi
+}
+
+# ---- actions ----
+
+copy_url () {
+    bump "$1"
+    printf '%s' "$1" | clip_out
+}
+
+open_url () {
+    bump "$1"
+    opener "$1"
+}
+
+# The editor reads the file when it starts and writes it when you save, so
+# bm holds the lock until the editor ends. bm-sync then waits, and syncs the
+# edit after it.
+edit_url () {
+    lock
+    line=$(SBM_URL=$1 awk -F'\t' \
+           '$1 == ENVIRON["SBM_URL"] { print NR; exit }' "$BOOKMARKS")
+    ${VISUAL:-${EDITOR:-vi}} "+${line:-1}" "$BOOKMARKS"
+    unlock
+    changed 'bm: edit'
+}
+
+delete_url () {
+    lock
+    for file in "$BOOKMARKS" "$USAGE"; do
+        [ -e "$file" ] || continue
+        SBM_URL=$1 awk -F'\t' '$1 != ENVIRON["SBM_URL"]' "$file" \
+            | rewrite "$file" || die "could not rewrite $file"
+    done
+    unlock
+    changed "bm: delete $1"
+}
+
+# urlencode <text>: for a query string. awk has no ord(), hence the table.
+urlencode () {
+    printf '%s' "$1" | LC_ALL=C awk '
+        BEGIN { for (i = 1; i < 256; i++) ord[sprintf("%c", i)] = i }
+        {
+            for (i = 1; i <= length($0); i++) {
+                c = substr($0, i, 1)
+                if (c ~ /[A-Za-z0-9._~-]/) out = out c
+                else if (c == " ")         out = out "+"
+                else                       out = out sprintf("%%%02X", ord[c])
+            }
+        }
+        END { printf "%s", out }'
+}
+
+# go <text>: open text that is not a bookmark. One word with "://" or a dot
+# in it is an address. Anything else is a web search: by the engine whose
+# keyword is the first word ($ENGINES holds "keyword | url with %s" lines),
+# or else by $SEARCH.
+go () {
+    case $1 in
+        *' '*) ;;
+        *://*) opener "$1"; return ;;
+        *.*)   opener "https://$1"; return ;;
+    esac
+    engine=$SEARCH
+    query=$1
+    if [ "${1%% *}" != "$1" ] && [ -r "$ENGINES" ]; then
+        found=$(SBM_KEY=${1%% *} awk -F' *[|] *' \
+                '$1 == ENVIRON["SBM_KEY"] { print $2; exit }' "$ENGINES")
+        if [ -n "$found" ]; then
+            engine=$found
+            query=${1#* }
+        fi
+    fi
+    opener "$(printf '%s\n' "$engine" | sed "s|%s|$(urlencode "$query")|")"
+}
+
+plumb () {
+    url=$(choose 'open:')
+    [ -n "$url" ] || exit 1
+    if is_bookmark "$url"; then open_url "$url"; else go "$url"; fi
+}
+
+# Exit status 3 means "already bookmarked".
+add () {
+    url=$1
+    if [ -z "$url" ]; then
+        url=$(ask 'url' "$(clip_in | grep '://' | head -n 1)")
+    fi
+    url=$(printf '%s' "$url" | tr -d '[:space:]')
+    [ -n "$url" ] || exit 1
+
+    dup=$(find_dup "$url")
+    if [ -n "$dup" ]; then
+        printf 'bm: already bookmarked:\n%s\n' "$dup" >&2
+        exit 3
+    fi
+
+    # bm-title, where installed, fetches the page's title.
+    title=
+    if have bm-title; then title=$(bm-title "$url"); fi
+    desc=$(ask 'description' "$title" | tr '\t' ' ')
+
+    [ -e "$USERTAGS" ] || : > "$USERTAGS"
+    names=$(pickmulti 'tags:' < "$USERTAGS" | sed 's/[ |].*//' | sed '/^$/d' \
+            | sort -u)
+    printf '%s\n' "$names" | learn_tags
+    tags=$(printf '%s\n' "$names" | paste -sd ' ' -)
+
+    lock
+    printf '%s\t%s\t%s\n' "$url" "$desc" "$tags" >> "$BOOKMARKS"
+    unlock
+    changed "bm: add $url"
+}
+
+# merge: bookmark lines on stdin (a bare URL per line will do); the ones not
+# known yet are appended. This is how the output of bm-import gets in.
+merge () {
+    new=$(tmpfile "$BOOKMARKS") || die 'cannot create a temporary file'
+    lock
+    SBM_FILE=$BOOKMARKS SBM_NEW=$new awk -F'\t' "$AWK_NORM"'
+        BEGIN {
+            while ((getline line < ENVIRON["SBM_FILE"]) > 0) {
+                split(line, f, "\t")
+                seen[norm(f[1])] = 1
+            }
+        }
+        NF == 0 || /^#/ { next }
+        seen[norm($1)]++ { skipped++; next }
+        { printf "%s\t%s\t%s\n", $1, $2, $3 > ENVIRON["SBM_NEW"]; added++ }
+        END { printf "added %d, skipped %d duplicates\n", added, skipped }'
+    cut -f3 "$new" | tr ' ' '\n' | sort -u | learn_tags
+    cat "$new" >> "$BOOKMARKS"
+    unlock
+    rm -f "$new"
+    changed 'bm: merge'
+}
+
+nextorder () {
+    case $sortorder in
+        used)   sortorder=recent ;;
+        recent) sortorder=url ;;
+        url)    sortorder=desc ;;
+        desc)   sortorder=tag ;;
+        *)      sortorder=used ;;
+    esac
+}
+
+# add exits, which must not end a browsing session; and when it refuses a
+# duplicate the user has to get to see that before the list comes back.
+add_inside () {
+    ( add '' )
+    if [ $? -eq 3 ]; then
+        ask 'already bookmarked; press ENTER' >/dev/null
+    fi
+}
+
+# browse_fzf: fzf can bind keys, so one list does everything. Opening and
+# copying leave it, the other keys come back to it.
+browse_fzf () {
+    while :; do
+        header="ENTER open  ^Y copy  ^A add  ^D delete  ^E edit  ^S sort [$sortorder]"
+        out=$(rows | fzfrows | withpreview fzfcmd --print-query \
+              --expect=ctrl-y,ctrl-a,ctrl-d,ctrl-e,ctrl-s \
+              --header="$header" --prompt='bookmark: ')
+        [ -n "$out" ] || exit 1
+        typed=$(printf '%s\n' "$out" | sed -n 1p)
+        key=$(printf '%s\n' "$out" | sed -n 2p)
+        url=$(printf '%s\n' "$out" | sed -n 3p | cut -f1)
+
+        case $key in
+            ctrl-a) add_inside; continue ;;
+            ctrl-s) nextorder; continue ;;
+        esac
+        if [ -z "$url" ]; then
+            [ -n "$key" ] && continue
+            [ -n "$typed" ] || exit 1
+            go "$typed"
+            exit 0
+        fi
+        case $key in
+            ctrl-y) copy_url "$url"; exit 0 ;;
+            ctrl-e) edit_url "$url" ;;
+            ctrl-d)
+                case $(ask "delete $url? (y/N)") in
+                    y|Y|yes) delete_url "$url" ;;
+                esac
+                ;;
+            *)      open_url "$url"; exit 0 ;;
+        esac
+    done
+}
+
+# browse_menu: dmenu can neither bind keys nor preview, so a second menu does
+# both: it shows the bookmark in full and asks what to do with it. Closing it
+# goes back to the list; closing the list leaves.
+browse_menu () {
+    while :; do
+        chosen=$( { printf '[add]\n'; rows | cut -f1-3; } | pick 'bookmark:')
+        [ -n "$chosen" ] || exit 1
+        if [ "$chosen" = '[add]' ]; then
+            add_inside
+            continue
+        fi
+        url=$(printf '%s\n' "$chosen" | cut -f1)
+        if ! is_bookmark "$url"; then
+            go "$chosen"
+            exit 0
+        fi
+        action=$( { printf 'open\ncopy\nedit\ndelete\n'
+                    details "$url" | sed 's/^/  /'; } | pick 'do:')
+        case $action in
+            open)   open_url "$url"; exit 0 ;;
+            copy)   copy_url "$url"; exit 0 ;;
+            edit)   edit_url "$url" ;;
+            delete) delete_url "$url" ;;
+        esac
+    done
+}
+
+# ---- main ----
+
+setaction () {
+    if [ -n "$action" ]; then
+        printf 'bm: only one action at a time\n' >&2
+        exit 2
+    fi
+    action=$1
+}
+
+# needarg <flag> <count of remaining arguments> <value>
+needarg () {
+    if [ "$2" -lt 2 ] || [ -z "$3" ]; then
+        printf 'bm: %s needs an argument\n' "$1" >&2
+        exit 2
+    fi
+}
+
+action=
+addurl=
+tagfilter=
+sortorder=${SBM_SORT:-used}
+while [ $# -gt 0 ]; do
+    case $1 in
+        -h|--help)   setaction help ;;
+        -c|--copy)   setaction copy ;;
+        --print)     setaction print ;;
+        -o|--open|-p|--plumb) setaction open ;;
+        -e|--edit)   setaction edit ;;
+        -d|--delete) setaction delete ;;
+        -l|--list)   setaction list ;;
+        -m|--merge)  setaction merge ;;
+        -a|--add)
+            setaction add
+            case ${2-} in
+                ''|-*) ;;
+                *) addurl=$2; shift ;;
+            esac
+            ;;
+        -t|--tag)
+            needarg "$1" $# "${2-}"
+            tagfilter=$2
+            shift
+            ;;
+        -S|--sort)
+            needarg "$1" $# "${2-}"
+            sortorder=$2
+            shift
+            ;;
+        *)
+            printf 'bm: unknown argument: %s\n' "$1" >&2
+            usage >&2
+            exit 2
+            ;;
+    esac
+    shift
+done
+
+case $sortorder in
+    used|recent|url|desc|tag) ;;
+    *)
+        printf 'bm: unknown sort order: %s\n' "$sortorder" >&2
+        exit 2
+        ;;
+esac
+
+if [ "$action" = help ]; then
+    usage
+    exit 0
+fi
+
+mkdir -p "$(dirname "$BOOKMARKS")" || die "cannot create directory for $BOOKMARKS"
+[ -e "$BOOKMARKS" ] || : > "$BOOKMARKS" || die "cannot create $BOOKMARKS"
+
+if awk '!/\t/ && !/^#/ && NF { found = 1; exit } END { exit !found }' "$BOOKMARKS"
+then
+    printf 'bm: %s has lines in the old format; run: bm-migrate\n' "$BOOKMARKS" >&2
+fi
+
+case $action in
+    list|merge) ;;
+    *) pickmenu ;;
+esac
+
+case $action in
+    '')
+        if [ "$SBM_MENU" = fzf ]; then browse_fzf; else browse_menu; fi
+        ;;
+    open)   plumb ;;
+    copy)   url=$(choose_url 'copy:')   && copy_url "$url" ;;
+    print)  url=$(choose_url 'print:')  && bump "$url" && printf '%s\n' "$url" ;;
+    edit)   url=$(choose_url 'edit:')   && edit_url "$url" ;;
+    delete) url=$(choose_url 'delete:') && delete_url "$url" \
+                && printf 'deleted %s\n' "$url" ;;
+    add)    add "$addurl" ;;
+    list)   rows | cut -f1-3 ;;
+    merge)  merge ;;
+esac
diff --git a/git-update-repos b/git-update-repos
new file mode 100644
index 0000000..1784322
--- /dev/null
+++ b/git-update-repos
@@ -0,0 +1,32 @@
+#!/bin/sh
+# Update all git repos in directory tree
+dir=${1:-"."}
+results=$(mktemp)
+log=$(mktemp)
+trap "tsp -K; rm -f "$results" "$log"" EXIT
+echo "Updating repos in: "$dir""
+tsp -S 8
+find ""$dir"" -name ".git" -type d 2>/dev/null | while read git_dir; do
+	tsp -n git-update-worker.sh "$(dirname ""$git_dir"")" ""$results"" ""$log""
+done
+tail -f ""$log"" &
+tail_pid=$!
+tsp -w
+kill "$tail_pid" 2>/dev/null
+conflicts=$(grep -c "^REBASE_CONFLICT:" ""$results"" 2>/dev/null || echo 0)
+echo "CHANGES:"
+grep "^UPDATED:" ""$results"" | while IFS='|' read prefix repo changelog; do
+	repo_name=$(echo ""$repo"" | sed "s|^"$dir"/||" | sed 's|^./||')
+	echo "\033[32m"$repo_name":\033[0m"
+	echo ""$changelog"" | tr ';' '\n' | sed 's/^/  /' | sed 's/^  $//'
+done
+if grep -q "^REBASE_CONFLICT:" ""$results""; then
+	echo "\033[31mREBASE CONFLICTS:\033[0m"
+	grep "^REBASE_CONFLICT:" ""$results"" | while IFS='|' read prefix repo _; do
+		repo_name=$(echo ""$repo"" | sed "s|^"$dir"/||" | sed 's|^./||')
+		echo "\033[31m"$repo_name": requires manual resolution\033[0m"
+	done
+fi
+if [ ""$conflicts"" ] && [ ""$conflicts"" -gt 0 ] 2>/dev/null; then
+	notify-send "Git Rebase Conflicts" ""$conflicts" repos need manual resolution" -u critical -t 10000
+fi
diff --git a/git-update-worker.sh b/git-update-worker.sh
new file mode 100755
index 0000000..9926866
--- /dev/null
+++ b/git-update-worker.sh
@@ -0,0 +1,28 @@
+#!/bin/sh
+repo="$1"
+results="$2"
+log="$3"
+name=$(basename "$repo")
+old=$(cd "$repo" && git rev-parse HEAD 2>/dev/null)
+if [ -f "$repo/.git/FETCH_HEAD" ]; then
+	if [ "$(find "$repo/.git/FETCH_HEAD" -mmin -5 2>/dev/null)" ]; then
+		echo "[$name] skipped" >> "$log"
+		exit
+	fi
+fi
+(cd "$repo" && git remote update >/dev/null 2>&1)
+if (cd "$repo" && git pull --rebase >/dev/null 2>&1); then
+	:
+elif (cd "$repo" && git rebase --abort >/dev/null 2>&1); then
+	echo "REBASE_CONFLICT:$repo|" >> "$results"
+	echo "[$name] REBASE CONFLICT" >> "$log"
+	exit
+else
+	echo "[$name] FAILED" >> "$log"
+	exit
+fi
+new=$(cd "$repo" && git rev-parse HEAD 2>/dev/null)
+[ "$old" = "$new" ] && { echo "[$name] up to date" >> "$log"; exit; }
+echo "[$name] UPDATED" >> "$log"
+changelog=$(cd "$repo" && git log --oneline --no-merges -3 "$old..$new" 2>/dev/null | tr '\n' ';')
+echo "UPDATED:$repo|$changelog" >> "$results"
\ No newline at end of file
diff --git a/group4ify b/group4ify
new file mode 100755
index 0000000..3674ca6
--- /dev/null
+++ b/group4ify
@@ -0,0 +1,15 @@
+#!/bin/bash
+TEMP="$(mktemp)"
+[ -e "$TEMP" ] && rm "$TEMP"
+mkdir "$TEMP"
+BASE="$(basename "$1")"
+cp "$1" "$TEMP"
+
+cd "$TEMP"
+pdfseparate "$PWD"/"$1" %d.pdf
+rm "$BASE"
+find . -name '*.pdf' -print0 | grep -z -v group4 | xargs -0 -L1 group4ify-aux
+cat <(find . -name '*-group4.pdf' | sed 's:^./::' | sort -h | sed 's:^\|$:":g') <(echo "$BASE"-group4.pdf) | xargs pdfunite
+cd -
+mv "$TEMP"/"$BASE"-group4.pdf .
+rm -r "$TEMP"
diff --git a/group4ify-aux b/group4ify-aux
new file mode 100755
index 0000000..02daa1b
--- /dev/null
+++ b/group4ify-aux
@@ -0,0 +1,2 @@
+#!/bin/sh
+convert -density 300 -compress Group4 -type bilevel "$1" "$1"-group4.pdf
diff --git a/http-collect b/http-collect
new file mode 100755
index 0000000..6ca88e0
--- /dev/null
+++ b/http-collect
@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+
+
+from argparse import ArgumentParser
+from os import environ
+from os.path import isfile
+from random import randint
+from re import match
+from socket import socket, AF_INET, SOCK_STREAM
+from subprocess import check_output
+from sys import stderr
+from time import sleep
+from uuid import uuid4
+
+from flask import Flask, request
+from qrcode import QRCode
+
+
+app = Flask(__name__)
+secret = str(uuid4())
+
+
+def html(body):
+    out = '''
+<!DOCTYPE html>
+<html>
+    <head>
+        <link href="data:," rel="icon">
+        <meta charset="UTF-8">
+        <meta name="viewport" content="width=device-width,initial-scale=1.0">
+        <title>http-collect</title>
+        <style>
+body {
+    max-width: 400px;
+    margin: 0px auto;
+    padding: 3em;
+    font-family: sans-serif;
+}
+input, textarea {
+    width: 100%;
+    height: 8em;
+    margin: 0.5em 0px;
+}
+        </style>
+    </head>
+    <body>
+    '''
+    out += body
+    out += '''
+    </body>
+</html>
+    '''
+
+    return out
+
+
+def sanitize_fs(s):
+    return s.strip().lstrip('.').replace('/', '_')
+
+
+def sanitize_html(s):
+    return s.replace('&', '&amp').replace('<', '&lt;').replace('>', '&gt;')
+
+
+def suffixify(basename, suff):
+    m = match(r'(.+)\.([^.]+)$', basename)
+    if not m:
+        return f'{basename} ({suff:03d})'
+
+    return f'{m.group(1)} ({suff:03d}).{m.group(2)}'
+
+
+def get_final_name(original_name):
+    candidate = base_name = sanitize_fs(original_name)
+    suff = 1
+    while isfile(candidate):
+        candidate = suffixify(base_name, suff)
+        suff += 1
+    return candidate
+
+
+def determine_host_and_port(args):
+    host = None
+    port = None
+
+    if args.host:
+        host = args.host
+    if args.port:
+        port = args.port
+
+    if host is None:
+        rt = check_output(['ip', '-4', 'route']).decode('UTF-8')
+        for line in rt.splitlines():
+            m = match(r'default.* src ([0-9.]+)', line)
+            if m:
+                host = m.group(1)
+
+        if host is None:
+            print('Could not find my IPv4, using 0.0.0.0', file=stderr)
+            host = '0.0.0.0'
+
+    success = False
+    for _ in range(5):
+        if port is None:
+            try_port = randint(1025, 65535)
+        else:
+            try_port = port
+
+        try:
+            s = socket(AF_INET, SOCK_STREAM)
+            s.bind((host, try_port))
+            s.listen()
+        except Exception as e:
+            print(f'Failed to use {host}:{try_port}: {repr(e)}')
+            sleep(1)
+        else:
+            port = try_port
+            success = True
+            break
+        finally:
+            s.close()
+
+    if not success:
+        raise Exception('Could not find free port')
+
+    url = f'http://{host}:{port}/{secret}'
+
+    qr = QRCode()
+    qr.add_data(f'http://{host}:{port}/{secret}')
+    qr.print_ascii(tty=True)
+
+    print()
+    print(url)
+    print()
+
+    return host, port
+
+
+@app.route('/<secret_from_url>', methods=['GET'])
+def form(secret_from_url):
+    if secret_from_url != secret:
+        return 'no', 403
+
+    return html('''
+    <form method="POST" enctype="multipart/form-data">
+        <input type="file" name="file" multiple>
+        <textarea name="textarea"></textarea>
+        <input type="submit">
+    </form>
+    ''')
+
+
+@app.route('/<secret_from_url>', methods=['POST'])
+def save(secret_from_url):
+    if secret_from_url != secret:
+        return 'no', 403
+
+    saved = []
+
+    for file in request.files.getlist('file'):
+        if file.filename:
+            final_name = get_final_name(file.filename)
+            file.save(final_name)
+            saved.append(f'<li>{sanitize_html(final_name)}</li>')
+
+    if request.form.get('textarea'):
+        final_name = get_final_name('plain.txt')
+        with open(final_name, 'w') as fp:
+            fp.write(request.form.get('textarea'))
+        saved.append(f'<li>{sanitize_html(final_name)}</li>')
+
+    num = len(saved)
+    suffix = '' if num == 1 else 's'
+    body = f'<p>{num} file{suffix} saved:</p><ul>'
+    body += '\n'.join(saved)
+    body += '</ul>'
+
+    body += f'<p><a href="/{secret}">Back</a></p>'
+    return html(body)
+
+
+if __name__ == '__main__':
+    parser = ArgumentParser()
+    parser.add_argument('--host')
+    parser.add_argument('--port', type=int)
+    parser.add_argument('--secret')
+    args = parser.parse_args()
+
+    if args.secret:
+        secret = args.secret
+
+    host, port = determine_host_and_port(args)
+    app.run(host=host, port=port)
diff --git a/jpegify b/jpegify
new file mode 100755
index 0000000..5768d5c
--- /dev/null
+++ b/jpegify
@@ -0,0 +1,15 @@
+#!/bin/bash
+TEMP="$(mktemp)"
+[ -e "$TEMP" ] && rm "$TEMP"
+mkdir "$TEMP"
+BASE="$(basename "$1")"
+cp "$1" "$TEMP"
+
+cd "$TEMP"
+pdfseparate "$PWD"/"$1" %d.pdf
+rm "$BASE"
+find . -name '*.pdf' -print0 | grep -z -v jpeg | xargs -0 -L1 jpegify-aux
+cat <(find . -name '*-jpeg.pdf' | sed 's:^./::' | sort -h | sed 's:^\|$:":g') <(echo "$BASE"-jpeg.pdf) | xargs pdfunite
+cd -
+mv "$TEMP"/"$BASE"-jpeg.pdf .
+rm -r "$TEMP"
diff --git a/jpegify-aux b/jpegify-aux
new file mode 100755
index 0000000..2690656
--- /dev/null
+++ b/jpegify-aux
@@ -0,0 +1,2 @@
+#!/bin/sh
+convert -density 100 -compress JPEG  -quality 70 "$1" "$1"-jpeg.pdf
diff --git a/macho b/macho
new file mode 100755
index 0000000..2566d7d
--- /dev/null
+++ b/macho
@@ -0,0 +1,25 @@
+#!/bin/sh
+
+export FZF_DEFAULT_OPTS='
+--height=30%
+--layout=reverse
+--prompt="Manual: "
+--preview="echo {1} | sed -E \"s/^\((.+)\)/\1/\" | xargs -I{S} man -Pcat {S} {2} 2>/dev/null"'
+
+while getopts ":s:" opt; do
+    case $opt in
+        s ) SECTION=$OPTARG; shift; shift;;
+        \?) echo "Invalid option: -$OPTARG" >&2; exit 1;;
+        : ) echo "Option -$OPTARG requires an argument" >&2; exit 1;;
+    esac
+done
+
+manual=$(apropos -s ${SECTION:-''} ${@:-.} | \
+    grep -v -E '^.+ \(0\)' |\
+    awk '{print $2 "    " $1}' | \
+    sort | \
+    fzf  | \
+    sed -E 's/^\((.+)\)/\1/')
+
+[ -z "$manual" ] && exit 0
+man $manual
diff --git a/mpdmenu b/mpdmenu
new file mode 100755
index 0000000..d32d957
--- /dev/null
+++ b/mpdmenu
@@ -0,0 +1,76 @@
+#!/bin/bash
+
+all_name='[ALL]'
+mode=library
+
+d_artist() {
+    mpc list artist | sort -f | dmenu -p artist "${dmenu_args[@]}"
+}
+
+d_album() {
+    local artist="$1"
+    local albums
+
+    mapfile -t albums < <(mpc list album artist "$artist")
+    if (( ${#albums[@]} > 1 )) ; then
+        {
+            printf '%s\n' "$all_name"
+            printf '%s\n' "${albums[@]}" | sort -f
+        } | dmenu -p album "${dmenu_args[@]}"
+    else
+        # We only have one album, so just use that.
+        printf '%s\n' "${albums[0]}"
+    fi
+}
+
+d_playlist() {
+    local format="%position% %title%"
+    local extra_format="(%artist% - %album%)"
+    local track
+    local num_extras
+
+    # If all tracks are from the same artist and album, no need to display that
+    num_extras=$(mpc playlist -f "$extra_format" | sort | uniq | wc -l)
+    (( num_extras == 1 )) || format+=" $extra_format"
+
+    track=$(mpc playlist -f "$format" | dmenu -p track "${dmenu_args[@]}")
+    printf '%s' "${track%% *}"
+}
+
+i=2
+
+for arg do
+    if [[ $arg == :: ]]; then
+        dmenu_args=( "${@:$i}" )
+        break
+    fi
+
+    case "$arg" in
+        -l) mode=library ;;
+        -p) mode=playlist ;;
+    esac
+
+    let i++
+done
+
+case "$mode" in
+    library)
+        artist=$(d_artist)
+        [[ $artist ]] || exit 1
+
+        album=$(d_album "$artist")
+        [[ $album ]] || exit 2
+
+        mpc clear
+        if [[ $album == "$all_name" ]]; then
+            mpc find artist "$artist" | sort | mpc add
+        else
+            mpc find artist "$artist" album "$album" | sort | mpc add
+        fi
+
+        mpc play >/dev/null
+    ;;
+    playlist)
+        mpc play "$(d_playlist)" >/dev/null
+    ;;
+esac
diff --git a/pashare b/pashare
new file mode 100755
index 0000000..8b47bd8
--- /dev/null
+++ b/pashare
@@ -0,0 +1,13 @@
+#!/bin/sh
+case "$1" in
+  start)
+    $0 stop 
+    pactl load-module module-simple-protocol-tcp rate=48000 format=s16le channels=2 source=alsa_output.pci-0000_00_1f.3.iec958-stereo.monitor record=true port=8000
+    ;;
+  stop)
+    pactl unload-module `pactl list | grep 'Monitor Source' | grep tcp -B1 | grep M | sed 's/[^0-9]//g'`
+    ;;
+  *)
+    echo "Usage: $0 start|stop" >&2
+    ;;
+esac
diff --git a/pomodoro b/pomodoro
new file mode 100755
index 0000000..ac786b0
--- /dev/null
+++ b/pomodoro
@@ -0,0 +1,83 @@
+#!/bin/sh
+
+remaining() {
+  if [ -e $statusfile ]; then
+    stime=$(cat $statusfile | awk '/start/ {print $2}')
+    time=$(cat $configfile | awk '/'$(current)' / {print $2}')
+    echo $(($stime - $(date +%s) + $time))
+  fi
+}
+
+current() {
+  ! [ -e $statusfile ] && echo "null" || cat $statusfile | awk '/current/ {print $2}'
+}
+
+next() {
+  cat $configfile | awk '/'$(current)' / {print $3}'
+}
+
+update() {
+  printf "current %s\nstart %s" $1 $(date +%s) > $statusfile
+}
+
+list() {
+  cat $configfile | awk '{print $1}'
+}
+
+configdir="$HOME/.config/pomodoro"
+configfile="$configdir/config"
+statusfile="/tmp/.pomodoro"
+
+# Check for config file, if it doesnt exist, copy the default.
+if ! [ -e $configfile ]; then
+  mkdir -p $configdir
+  cp /usr/share/doc/pomodoro/config.def $configfile
+fi
+
+# Set flags for the arguments.
+while getopts ":sgk" opt; do
+  case $opt in
+    s)
+      s=0
+      ;;
+    k)
+      k=0
+      ;;
+    g)
+      g=0
+      ;;
+    \?)
+      exit 1
+  esac
+done
+
+# If no input arguments, show a simple menu.
+if [ $# -le 0 ]; then 
+  selection=$(printf "start\nkill" | dmenu)
+  [ $? -ne 0 ] && exit
+  [ "$selection" = "start" ] && s=0 || k=0
+fi
+
+# Handlers.
+if [ $s ]; then
+  if [ $# -le 1 ]; then
+    profile=$(list | dmenu)
+    [ $? -ne 0 ] && exit
+  else
+    profile=$2
+  fi
+  update $profile
+fi
+
+[ $k ] && rm $statusfile
+
+if [ $g ]; then
+  [ "$(current)" = "null" ] && exit 1
+  if [ $(remaining) -le 0 ]; then
+    notify-send "Timer $(current)" "done"
+    update $(next)
+    pomodoro -g
+  else
+    echo $(printf "%s %02d:%02d" $(current) $(($(remaining) / 60)) $(($(remaining) % 60)))
+  fi
+fi
diff --git a/pomodoro-files/config.def b/pomodoro-files/config.def
new file mode 100644
index 0000000..e8b6f2f
--- /dev/null
+++ b/pomodoro-files/config.def
@@ -0,0 +1,2 @@
+work 1500 break
+break 300 work
diff --git a/sbm/bm-commit b/sbm/bm-commit
new file mode 100755
index 0000000..fbefcca
--- /dev/null
+++ b/sbm/bm-commit
@@ -0,0 +1,36 @@
+#!/bin/sh
+
+# bm-commit [message]: commit the bookmark file, and the tag file beside it,
+# when their directory is a git repository of its own. bm runs it after every
+# change when it is installed. It never pushes or pulls.
+#
+#     cd ~/.local/share/sbm && git init
+#     printf '%s\n' '*.usage' '*.bak*' > .gitignore
+#
+# A file that merely sits somewhere inside a bigger repository (your dotfiles,
+# say) is left alone, unless SBM_GIT=1. SBM_GIT=0 turns commits off.
+
+DATADIR=${XDG_DATA_HOME:-$HOME/.local/share}/sbm
+BOOKMARKS=${BOOKMARKS:-$DATADIR/bookmarks}
+USERTAGS=${USERTAGS:-$DATADIR/usertags}
+
+[ "${SBM_GIT:-auto}" != 0 ] || exit 0
+dir=$(cd "$(dirname "$BOOKMARKS")" 2>/dev/null && pwd -P) || exit 0
+top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || exit 0
+[ "${SBM_GIT:-auto}" = 1 ] || [ "$top" = "$dir" ] || exit 0
+
+set -- "${1:-bm: update}" "$BOOKMARKS"
+if [ -e "$USERTAGS" ] \
+    && [ "$(cd "$(dirname "$USERTAGS")" 2>/dev/null && pwd -P)" = "$dir" ]
+then
+    set -- "$@" "$USERTAGS"
+fi
+message=$1
+shift
+
+git -C "$dir" add -- "$@" 2>/dev/null || exit 0
+git -C "$dir" diff --cached --quiet -- "$@" && exit 0
+if ! git -C "$dir" commit -q -m "$message" -- "$@" >/dev/null 2>&1; then
+    printf 'bm-commit: git commit failed in %s\n' "$dir" >&2
+    exit 1
+fi
diff --git a/sbm/bm-page b/sbm/bm-page
new file mode 100755
index 0000000..41d73ce
--- /dev/null
+++ b/sbm/bm-page
@@ -0,0 +1,212 @@
+#!/bin/sh
+
+# bm-page: print a web page as plain text: its title, a blank line, then its
+# text. When it is installed, the fzf list of bm shows the page of the
+# bookmark under the cursor in the preview, live. SBM_FETCH=0 keeps it off
+# the network.
+#
+#     bm-page https://suckless.org/ | less
+#
+# Only http and https pages are fetched. curl stops after 5 seconds, and dd
+# passes at most 512k of the page. Scripts, styles and comments are left
+# out. Control characters are removed, so that a page cannot send escape
+# sequences to the terminal.
+
+if [ $# -ne 1 ]; then
+    printf 'usage: bm-page <url>\n' >&2
+    exit 2
+fi
+[ "${SBM_FETCH:-1}" != 0 ] || exit 0
+case $(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') in
+    http://*|https://*) ;;
+    *) exit 0 ;;
+esac
+
+# The first awk reads the page as one record (RS is a control character),
+# then splits it at each "<". Each part starts with a tag and ends with the
+# text after it, so one pass through the parts reads the page in linear
+# time. The second awk tidies the white space.
+curl -sL --max-time 5 -A 'Mozilla/5.0' -- "$1" 2>/dev/null \
+    | dd bs=1024 count=512 2>/dev/null | LC_ALL=C awk '
+    function utf8(c) {
+        if (c == 160) return " "
+        if (c < 128) return sprintf("%c", c)
+        if (c < 2048) return sprintf("%c%c", 192 + int(c / 64), 128 + c % 64)
+        if (c < 65536)
+            return sprintf("%c%c%c", 224 + int(c / 4096), 128 + int(c / 64) % 64, 128 + c % 64)
+        if (c < 1114112)
+            return sprintf("%c%c%c%c", 240 + int(c / 262144), 128 + int(c / 4096) % 64,
+                           128 + int(c / 64) % 64, 128 + c % 64)
+        return ""
+    }
+    function entity(name,    n, i) {
+        if (name ~ /^#[0-9]+$/) return utf8(substr(name, 2) + 0)
+        if (name ~ /^#[xX][0-9A-Fa-f]+$/) {
+            n = 0
+            for (i = 3; i <= length(name); i++)
+                n = n * 16 + index("0123456789abcdef", tolower(substr(name, i, 1))) - 1
+            return utf8(n)
+        }
+        if (name in named) return named[name]
+        return "&" name ";"
+    }
+    function decode(s,    out, i, j, name) {
+        out = ""
+        while ((i = index(s, "&")) > 0) {
+            out = out substr(s, 1, i - 1)
+            s = substr(s, i + 1)
+            j = index(s, ";")
+            name = substr(s, 1, j - 1)
+            if (j > 1 && j <= 12 && name ~ /^(#[0-9]+|#[xX][0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]*)$/) {
+                out = out entity(name)
+                s = substr(s, j + 1)
+            } else {
+                out = out "&"
+            }
+        }
+        return out s
+    }
+    # text: the text of the page, or of its title. As in a browser, a line
+    # break in the text is a space. The line breaks come from the tags: brk
+    # tells how many are waiting for the next text.
+    function text(s) {
+        if (intitle) {
+            title = title " " s
+            return
+        }
+        s = decode(s)
+        gsub(/[ \t\r\n]+/, " ", s)
+        if (s == "" || s == " ") {
+            if (s == " " && !brk) printf " "
+            return
+        }
+        if (brk) {
+            printf "%s", brk == 2 ? "\n\n" : "\n"
+            brk = 0
+            sub(/^ /, "", s)
+        }
+        printf "%s", s
+    }
+    BEGIN {
+        RS = "\001"
+        named["amp"] = "&"; named["lt"] = "<"; named["gt"] = ">"; named["quot"] = "\""
+        named["apos"] = "\047"; named["nbsp"] = " "
+        named["ndash"] = utf8(8211); named["mdash"] = utf8(8212); named["hellip"] = utf8(8230)
+        named["lsquo"] = utf8(8216); named["rsquo"] = utf8(8217)
+        named["ldquo"] = utf8(8220); named["rdquo"] = utf8(8221)
+        named["laquo"] = utf8(171); named["raquo"] = utf8(187); named["middot"] = utf8(183)
+        named["bull"] = utf8(8226); named["copy"] = utf8(169); named["reg"] = utf8(174)
+        named["trade"] = utf8(8482); named["deg"] = utf8(176); named["times"] = utf8(215)
+        named["euro"] = utf8(8364)
+        # Tags that start a line, and tags that start a paragraph.
+        split("br div li tr dt dd header footer nav main section article aside " \
+              "figure figcaption form details summary address hr", b, " ")
+        for (i in b) line[b[i]] = 1
+        split("p h1 h2 h3 h4 h5 h6 ul ol dl table blockquote pre", b, " ")
+        for (i in b) para[b[i]] = 1
+        # Tags whose content is no text of the page.
+        split("script style noscript template svg iframe object", b, " ")
+        for (i in b) hidden[b[i]] = 1
+    }
+    { page = page $0 }
+    END {
+        if (substr(page, 1, 5) == "%PDF-") {
+            print "(a PDF document)"
+            exit
+        }
+        n = split(page, part, "<")
+        text(part[1])
+        for (i = 2; i <= n; i++) {
+            p = part[i]
+            if (comment || substr(p, 1, 3) == "!--") {
+                k = index(p, "-->")
+                comment = !k
+                if (k && skip == "") text(substr(p, k + 3))
+                continue
+            }
+            k = index(p, ">")
+            if (!k) {
+                # A "<" in the text, or the end of a page that dd cut off.
+                if (skip == "" && i < n) text("<" p)
+                continue
+            }
+            tag = tolower(substr(p, 1, k - 1))
+            closing = substr(tag, 1, 1) == "/"
+            name = tag
+            sub(/^\//, "", name)
+            sub(/[^a-z0-9].*/, "", name)
+            rest = substr(p, k + 1)
+            if (skip != "") {
+                if (closing && name == skip) {
+                    skip = ""
+                    text(rest)
+                }
+                continue
+            }
+            if (name in hidden && !closing && substr(tag, length(tag)) != "/") {
+                skip = name
+                continue
+            }
+            if (name == "title" && !done) {
+                if (closing) {
+                    intitle = 0
+                    done = 1
+                    t = decode(title)
+                    gsub(/[ \t\r\n]+/, " ", t)
+                    printf "\n\001T%s\n", t
+                    brk = 0
+                } else {
+                    intitle = 1
+                }
+            } else if (name == "main") {
+                # Marks for the second awk: the main part of the page.
+                printf "\n\001%s\n", closing ? ">" : "<"
+                brk = 0
+            } else if (name in para) {
+                brk = 2
+            } else if (name in line) {
+                if (!brk) brk = 1
+            } else if (name == "td" || name == "th") {
+                text(" ")
+            }
+            text(rest)
+        }
+        print ""
+    }' | LC_ALL=C awk '
+    {
+        gsub(/\302\240/, " ")
+        gsub(/\302[\200-\237]/, "")
+        gsub(/[ \t\r]+/, " ")
+        sub(/^ /, "")
+        sub(/ $/, "")
+    }
+    /^\001T/ {
+        if (title == "") {
+            title = substr($0, 3)
+            sub(/^ /, "", title)
+        }
+        next
+    }
+    /^\001</ { if (!opened) { opened = 1; from = n + 1 }; next }
+    /^\001>/ { if (opened && !closed) { closed = 1; to = n }; next }
+    { line[++n] = $0 }
+    # The title, then the main part of the page when the page marks one, so
+    # that menus do not fill the preview. At most one blank line in a row.
+    END {
+        if (!opened) from = 1
+        if (!closed) to = n
+        if (title != "") {
+            print title
+            seen = blank = 1
+        }
+        for (i = from; i <= to; i++) {
+            if (line[i] == "") {
+                blank = seen
+                continue
+            }
+            if (blank) print ""
+            print line[i]
+            seen = 1
+            blank = 0
+        }
+    }' | tr -d '\000-\010\013-\037\177'
diff --git a/sbm/bm-sync b/sbm/bm-sync
new file mode 100755
index 0000000..b4e233d
--- /dev/null
+++ b/sbm/bm-sync
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+# bm-sync: keep the bookmark file the same on all your devices, through an
+# sbm-sync server (https://github.com/equwal/sbm-sync). bm runs it after each
+# change when it is installed; you can also run it yourself, or from cron.
+#
+# The server merges: bookmarks that you add or remove here are added or
+# removed there, and the other way round. The file stays a plain file.
+
+DATADIR=${XDG_DATA_HOME:-$HOME/.local/share}/sbm
+BOOKMARKS=${BOOKMARKS:-$DATADIR/bookmarks}
+CONFIG=${SBM_SYNC_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/sbm/sync}
+SERVER=https://sbmsync.com
+STATE=$BOOKMARKS.sync   # name of the version of the last sync
+LOCK=$BOOKMARKS.sync.lock
+WLOCK=$BOOKMARKS.lock   # the write lock of bm
+quiet=
+
+usage () {
+    cat <<'EOF'
+usage: bm-sync [-q] [login [server] | logout | status]
+Without a command: sync the bookmark file now.
+  login     sign in to the server (default https://sbmsync.com)
+            and sync; asks for your email and password
+  logout    sign out on this computer
+  status    show the server and the time of the last sync
+  -q        print nothing but errors
+EOF
+}
+
+die () {
+    printf 'bm-sync: %s\n' "$*" >&2
+    exit 1
+}
+
+say () {
+    [ -n "$quiet" ] || printf 'bm-sync: %s\n' "$*"
+}
+
+# setting <name>: a value from the config file.
+setting () {
+    sed -n "s/^$1=//p" "$CONFIG" 2>/dev/null | head -n 1
+}
+
+# private <file>: make <file> an empty file that only you can read. umask
+# sets the mode of a new file. chmod sets the mode of a file that exists
+# already, for example the config file of an earlier login.
+private () {
+    (umask 077; : > "$1") && chmod 600 "$1"
+}
+
+cleanup () {
+    rm -rf "$work"
+    [ -z "$locked" ] || rm -rf "$LOCK"
+    wunlock
+}
+
+# wlock: take the write lock of bm, as bm does. bm holds it while it changes
+# the file: while bm -e runs, that is until the editor ends. After
+# $SBM_LOCK_WAIT seconds (default 10) bm-sync stops, and the next change in
+# bm syncs again.
+wlock () {
+    waited=0
+    until mkdir "$WLOCK" 2>/dev/null; do
+        pid=$(cat "$WLOCK/pid" 2>/dev/null)
+        if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then
+            [ "$(cat "$WLOCK/pid" 2>/dev/null)" != "$pid" ] || rm -rf "$WLOCK"
+            continue
+        fi
+        [ "$waited" -lt "${SBM_LOCK_WAIT:-10}" ] \
+            || die "$BOOKMARKS is in use (bm -e?): sync again later"
+        sleep 1
+        waited=$((waited + 1))
+    done
+    echo $$ > "$WLOCK/pid"
+    wlocked=1
+}
+
+wunlock () {
+    [ -z "$wlocked" ] || rm -rf "$WLOCK"
+    wlocked=
+}
+
+login () {
+    server=${1:-$SERVER}
+    server=${server%/}
+    printf 'email: ' >&2
+    read -r email || exit 1
+    printf 'password: ' >&2
+    stty -echo 2>/dev/null
+    IFS= read -r password
+    rc=$?
+    stty echo 2>/dev/null
+    printf '\n' >&2
+    [ $rc -eq 0 ] || exit 1
+
+    # The password goes through stdin: on the command line, other users
+    # could see it with ps.
+    code=$(printf '%s' "$password" | curl -sS -o "$work/out" -w '%{http_code}' \
+        --data-urlencode "email=$email" --data-urlencode 'password@-' \
+        -- "$server/api/login") || die "cannot reach $server"
+    [ "$code" = 200 ] || die "$(cat "$work/out")"
+    token=$(tr -d '\r\n' < "$work/out")
+
+    mkdir -p "$(dirname "$CONFIG")" || exit 1
+    private "$CONFIG" || exit 1
+    printf 'server=%s\ntoken=%s\n' "$server" "$token" > "$CONFIG"
+    rm -f "$STATE"
+    say "signed in to $server"
+    sync
+}
+
+logout () {
+    if [ -e "$CONFIG" ]; then
+        private "$work/auth"
+        printf 'Authorization: Bearer %s\n' "$(setting token)" > "$work/auth"
+        curl -sS -o /dev/null -X POST -H "@$work/auth" -- "$(setting server)/api/logout" \
+            2>/dev/null
+        rm -f "$CONFIG" "$STATE"
+    fi
+    say 'signed out'
+}
+
+status () {
+    if [ ! -e "$CONFIG" ]; then
+        echo 'not signed in: run bm-sync login'
+        return
+    fi
+    echo "server: $(setting server)"
+    if [ -e "$STATE" ]; then
+        echo "last sync: $(ls -l "$STATE" | awk '{ print $6, $7, $8 }')"
+    else
+        echo 'last sync: never'
+    fi
+}
+
+# sync: send the file with the name of its last version; the server gives
+# back the merged file and the name of the new version.
+sync () {
+    [ -e "$CONFIG" ] || die 'not signed in: run bm-sync login'
+    server=$(setting server)
+    token=$(setting token)
+    [ -n "$server" ] && [ -n "$token" ] || die "$CONFIG is damaged: run bm-sync login"
+
+    # One sync at a time. A second bm-sync leaves a note and exits: the
+    # first one then syncs again, so that no change waits for the next one.
+    if ! mkdir "$LOCK" 2>/dev/null; then
+        pid=$(cat "$LOCK/pid" 2>/dev/null)
+        if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
+            : > "$LOCK/again"
+            exit 0
+        fi
+        rm -rf "$LOCK"   # left by a bm-sync that was killed
+        mkdir "$LOCK" 2>/dev/null || die "cannot lock $LOCK"
+    fi
+    locked=1
+    echo $$ > "$LOCK/pid"
+
+    private "$work/auth"
+    printf 'Authorization: Bearer %s\n' "$token" > "$work/auth"
+    if [ ! -e "$BOOKMARKS" ]; then
+        mkdir -p "$(dirname "$BOOKMARKS")" && : > "$BOOKMARKS" || exit 1
+    fi
+
+    tries=0
+    while :; do
+        rm -f "$LOCK/again"
+        # bm writes a delete as an empty file and then the new text. Without
+        # the lock, bm-sync could send the half-written file, and the server
+        # would delete the lines that it lacks.
+        wlock
+        cat "$BOOKMARKS" > "$work/sent"
+        wunlock
+        # An empty file with a version would delete all bookmarks on the
+        # server, and then on all devices. Without a version, the server
+        # deletes nothing, and sends all bookmarks back.
+        [ -s "$work/sent" ] || rm -f "$STATE"
+        base=$(cat "$STATE" 2>/dev/null)
+        code=$(curl -sS --max-time 60 -o "$work/out" -D "$work/head" -w '%{http_code}' \
+            -H "@$work/auth" -H 'Content-Type: text/plain; charset=utf-8' \
+            --data-binary "@$work/sent" -- "$server/api/sync?base=$base") \
+            || die "cannot reach $server"
+        case $code in
+            200) ;;
+            401) die 'not signed in: run bm-sync login' ;;
+            *)   die "$(cat "$work/out")" ;;
+        esac
+        version=$(tr -d '\r' < "$work/head" | sed -n 's/^[Ss][Bb][Mm]-[Vv][Ee][Rr][Ss][Ii][Oo][Nn]: *//p')
+        [ -n "$version" ] || die 'the server gave no version'
+
+        # Hold the lock from the check to the write, so that no change of bm
+        # comes between them.
+        wlock
+        # bm changed the file while it was away: send the new file. The
+        # answer to the old one is of no use.
+        if ! cmp -s "$work/sent" "$BOOKMARKS"; then
+            wunlock
+            tries=$((tries + 1))
+            [ $tries -lt 5 ] || die 'the file changes all the time: try again later'
+            continue
+        fi
+        wrote=
+        if ! cmp -s "$work/out" "$BOOKMARKS"; then
+            # Forget the version first, and keep the new one only after the
+            # write. If the write stops part way (a full disk), the next sync
+            # sends no version, so the server deletes none of the lost lines.
+            rm -f "$STATE"
+            # Through cat, not mv, as in bm: a symlinked file stays a symlink.
+            cat "$work/out" > "$BOOKMARKS" || die "cannot write $BOOKMARKS"
+            wrote=1
+        fi
+        printf '%s\n' "$version" > "$STATE"
+        wunlock
+        if [ -n "$wrote" ] && command -v bm-commit >/dev/null 2>&1; then
+            bm-commit 'bm-sync: merge'
+        fi
+        [ -e "$LOCK/again" ] || break
+    done
+    say "in sync: $(grep -c -v -e '^#' -e '^[[:space:]]*$' "$BOOKMARKS") bookmarks"
+}
+
+while [ $# -gt 0 ]; do
+    case $1 in
+        -q) quiet=1 ;;
+        -h|--help) usage; exit 0 ;;
+        -*) usage >&2; exit 2 ;;
+        *)  break ;;
+    esac
+    shift
+done
+
+command -v curl >/dev/null 2>&1 || die 'curl is not installed'
+# mkdir fails when the name exists: a directory planted in a shared /tmp is
+# refused, not used.
+work=${TMPDIR:-/tmp}/bm-sync.$$
+mkdir -m 700 "$work" || die "cannot create $work"
+locked=
+wlocked=
+trap cleanup EXIT
+trap 'exit 1' INT TERM
+
+case ${1:-sync} in
+    login)  shift; login "$@" ;;
+    logout) logout ;;
+    status) status ;;
+    sync)   sync ;;
+    *)      usage >&2; exit 2 ;;
+esac
diff --git a/sbm/bm-title b/sbm/bm-title
new file mode 100755
index 0000000..84a17ba
--- /dev/null
+++ b/sbm/bm-title
@@ -0,0 +1,33 @@
+#!/bin/sh
+
+# bm-title: print the <title> of a web page, or nothing.
+# When it is installed, "bm --add" offers the title as the description.
+# SBM_FETCH=0 keeps it off the network.
+
+if [ $# -ne 1 ]; then
+    printf 'usage: bm-title <url>\n' >&2
+    exit 2
+fi
+[ "${SBM_FETCH:-1}" != 0 ] || exit 0
+
+# Lines are gathered until the title has been closed; awk then leaves, and
+# curl stops downloading. dd caps it at 64k for pages that are one long line
+# (it may pass a little less when its reads come up short; never more).
+curl -sL --max-time 3 -A 'Mozilla/5.0' -- "$1" 2>/dev/null \
+    | dd bs=1024 count=64 2>/dev/null | LC_ALL=C awk '
+    function title(s,    start, end) {
+        start = index(tolower(s), "<title")
+        if (!start) return
+        s = substr(s, start)
+        s = substr(s, index(s, ">") + 1)
+        end = index(tolower(s), "</title")
+        if (end) s = substr(s, 1, end - 1)
+        gsub(/&lt;/, "<", s); gsub(/&gt;/, ">", s); gsub(/&quot;/, "\"", s)
+        gsub(/&#39;|&#x27;|&apos;/, "\047", s); gsub(/&nbsp;/, " ", s)
+        gsub(/&amp;/, "\\&", s)
+        gsub(/[ \t\r]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s)
+        print s
+    }
+    { page = page " " $0 }
+    index(tolower($0), "</title") || NR >= 500 { exit }
+    END { title(page) }'
diff --git a/slock-usb-key b/slock-usb-key
new file mode 100755
index 0000000..6aed366
--- /dev/null
+++ b/slock-usb-key
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+
+
+from os.path import expanduser
+from subprocess import Popen
+
+import signal
+import pyudev
+
+
+signal.signal(signal.SIGCHLD, signal.SIG_IGN)
+
+with open(expanduser('~/.slock-usb-key'), 'r') as fp:
+    serial = fp.read().strip()
+
+context = pyudev.Context()
+monitor = pyudev.Monitor.from_netlink(context)
+monitor.filter_by('block')
+for device in iter(monitor.poll, None):
+    if device.get('ID_SERIAL') == serial:
+        if device.action == 'add':
+            Popen(['sudo', '/usr/bin/killall', 'slock'])
+        elif device.action == 'remove':
+            Popen(['slock'])
diff --git a/tag-music b/tag-music
new file mode 100755
index 0000000..92d6323
--- /dev/null
+++ b/tag-music
@@ -0,0 +1,49 @@
+#!/bin/sh
+
+err() { echo "Usage:
+	tag [OPTIONS] file
+Options:
+	-a: artist/author
+	-t: song/chapter title
+	-A: album/book title
+	-n: track/chapter number
+	-N: total number of tracks/chapters
+	-d: year of publication
+	-g: genre
+	-c: comment
+You will be prompted for title, artist, album and track if not given." && exit 1 ;}
+
+while getopts "a:t:A:n:N:d:g:c:" o; do case "${o}" in
+	a) artist="${OPTARG}" ;;
+	t) title="${OPTARG}" ;;
+	A) album="${OPTARG}" ;;
+	n) track="${OPTARG}" ;;
+	N) total="${OPTARG}" ;;
+	d) date="${OPTARG}" ;;
+	g) genre="${OPTARG}" ;;
+	c) comment="${OPTARG}" ;;
+	*) printf "Invalid option: -%s\\n" "$OPTARG" && err ;;
+esac done
+
+shift $((OPTIND - 1))
+
+file="$1"
+
+temp="$(mktemp -p "$(dirname "$file")")"
+trap 'rm -f $temp' HUP INT QUIT TERM PWR EXIT
+
+[ ! -f "$file" ] && echo 'Provide file to tag.' && err
+
+[ -z "$title" ] && echo 'Enter a title.' && read -r title
+[ -z "$artist" ] && echo 'Enter an artist.' && read -r artist
+[ -z "$album" ] && echo 'Enter an album.' && read -r album
+[ -z "$track" ] && echo 'Enter a track number.' && read -r track
+
+cp -f "$file" "$temp" && ffmpeg -i "$temp" -map 0 -y -codec copy \
+	-metadata title="$title" \
+	-metadata album="$album" \
+	-metadata artist="$artist" \
+	-metadata track="${track}${total:+/"$total"}" \
+	${date:+-metadata date="$date"} \
+	${genre:+-metadata genre="$genre"} \
+	${comment:+-metadata comment="$comment"} "$file"
diff --git a/windervolage b/windervolage
new file mode 100755
index 0000000..faac9fd
--- /dev/null
+++ b/windervolage
@@ -0,0 +1,57 @@
+#!/bin/sh
+#
+# wv - Wiedervorlage (bring forward file)
+# schedule a reminder mail to yourself at a given date using at(1)
+#
+# markus schnalke <meillo@marmaro.de>
+
+queue=w
+
+cd  # move to home directory
+
+listone() {
+	atq -q "$queue" | awk "/^$1\t/ {sub(/ $queue .*/,\"\");print}" 
+	at -c "$1" | sed '
+		1,/^# wv/d;
+		/^mail /{s,[^"]*",,; s,".*,,};
+		s,^,\t,
+		'
+}
+
+list() {
+	if [ $# -eq 0 ] ; then
+		atq -q "$queue" | while read id rest ; do
+			listone "$id"
+		done
+	else
+		for i in "$@" ; do
+			listone "$i"
+		done
+	fi
+}
+
+
+if [ $# -eq 0 ] ; then
+	cat >&2 <<!
+Usage: ${0##*/} timespec...
+       ${0##*/} -l [jobid...]
+!
+	exit 1
+fi
+
+if [ "$1" = "-l" ] ; then
+	shift
+	list "$@"
+	exit
+fi
+
+echo At: `date +"%Y-%m-%d %H:%M" -d "$*"`
+printf "Subject: "
+read sub
+
+at -q "$queue" "$@" <<!
+# wv
+mail -s "Wv: $sub" `id -un` <<EOF
+`cat`
+EOF
+!