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('&', '&').replace('<', '<').replace('>', '>') + + +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(/</, "<", s); gsub(/>/, ">", s); gsub(/"/, "\"", s) + gsub(/'|'|'/, "\047", s); gsub(/ /, " ", s) + gsub(/&/, "\\&", 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 +!