citadel

My dotfiles, scripts and nix configs
git clone git://jb55.com/citadel
Log | Files | Refs | README | LICENSE

commit fa011019579f2e7873048a74c4b41dfdf213f202
parent 2cb53c9535ca35d9e0e879c18f6b69adb531c4af
Author: William Casarin <jb55@jb55.com>
Date:   Sat, 19 Sep 2020 05:16:04 -0700

bin: get everything up to sync

Diffstat:
Mbin/.gitignore | 2++
Abin/agena | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abin/arcsize | 2++
Abin/book | 6++++++
Abin/btc-txs-raw | 11+++++++++++
Mbin/gemini | 2+-
Abin/killspotify | 2++
Abin/lessr | 8++++++++
Abin/lockmac | 5+++++
Abin/macip | 4++++
Abin/notmuch-emacs-mua | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abin/paged | 2++
Abin/pdfsave | 20++++++++++++++++++++
Abin/procmem | 6++++++
Abin/procmemall | 2++
Dbin/reader | 2--
16 files changed, 502 insertions(+), 3 deletions(-)

diff --git a/bin/.gitignore b/bin/.gitignore @@ -12,3 +12,5 @@ /sacc /otsclear /txtnish +/zebra +/zoom-link-opener diff --git a/bin/agena b/bin/agena @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 + +import argparse +import mimetypes +import os +import shlex +import subprocess +import socket +import socketserver +import ssl +import sys +import tempfile +import urllib.parse + +try: + import chardet + _HAS_CHARDET = True +except ImportError: + _HAS_CHARDET = False + +HOST, PORT = "0.0.0.0", 1965 + +class AgenaHandler(socketserver.BaseRequestHandler): + + def setup(self): + """ + Wrap socket in SSL session. + """ + self.request = context.wrap_socket(self.request, server_side=True) + + def handle(self): + # Parse request URL, make sure it's for a Gopher resource + self.parse_request() + if self.request_scheme != "gopher": + self.send_gemini_header(50, "Agena only proxies to gopher resources.") + return + # Try to do a Gopher transaction with the remote host + try: + filename = self.download_gopher_resource() + except UnicodeError: + self.send_gemini_header(43, "Remote gopher host served content in an unrecognisable character encoding.") + return + except: + self.send_gemini_header(43, "Couldn't connect to remote gopher host.") + return + # Handle what we received based on item type + if self.gopher_itemtype == "0": + self.handle_text(filename) + elif self.gopher_itemtype == "1": + self.handle_menu(filename) + elif self.gopher_itemtype == "h": + self.handle_html(filename) + elif self.gopher_itemtype in ("9", "g", "I", "s"): + self.handle_binary(filename) + # Clean up + self.request.close() + os.unlink(filename) + + def send_gemini_header(self, status, meta): + """ + Send a Gemini header, and close the connection if the status code does + not indicate success. + """ + self.request.send("{} {}\r\n".format(status, meta).encode("UTF-8")) + if status / 10 != 2: + self.request.close() + + def parse_request(self): + """ + Read a URL from the Gemini client and parse it up into parts, + including separating out the Gopher item type. + """ + requested_url = self.request.recv(1024).decode("UTF-8").strip() + if "://" not in requested_url: + requested_url = "gemini://" + requested_url + parsed = urllib.parse.urlparse(requested_url) + self.request_scheme = parsed.scheme + self.gopher_host = parsed.hostname + self.gopher_port = parsed.port or 70 + if parsed.path and parsed.path[0] == '/' and len(parsed.path) > 1: + self.gopher_itemtype = parsed.path[1] + self.gopher_selector = parsed.path[2:] + else: + # Use item type 1 for top-level selector + self.gopher_itemtype = "1" + self.gopher_selector = parsed.path + self.gopher_query = parsed.query + + def download_gopher_resource(self): + """ + Download the requested Gopher resource to a temporary file. + """ + print("Requesting {} from {}...".format(self.gopher_selector, self.gopher_host), end="") + + # Send request and read response + s = socket.create_connection((self.gopher_host, self.gopher_port)) + if self.gopher_query: + request = self.gopher_selector + '\t' + self.gopher_query + else: + request = self.gopher_selector + request += '\r\n' + s.sendall(request.encode("UTF-8")) + response= s.makefile("rb").read() + + # Transcode text responses into UTF-8 + if self.gopher_itemtype in ("0", "1", "h"): + # Try some common encodings + for encoding in ("UTF-8", "ISO-8859-1"): + try: + response = response.decode("UTF-8") + break + except UnicodeDecodeError: + pass + else: + # If we didn't break out of the loop above, none of the + # common encodings worked. If we have chardet installed, + # try to autodetect. + if _HAS_CHARDET: + detected = chardet.detect(response) + response = response.decode(detected["encoding"]) + else: + # Surrender. + raise UnicodeDecodeError + # Re-encode as God-fearing UTF-8 + response = response.encode("UTF-8") + + # Write gopher response to temp file + tmpf = tempfile.NamedTemporaryFile("wb", delete=False) + size = tmpf.write(response) + tmpf.close() + print("wrote {} bytes to {}...".format(size, tmpf.name)) + return tmpf.name + + def handle_text(self, filename): + """ + Send a Gemini response for a downloaded Gopher resource whose item + type indicates it should be plain text. + """ + self._serve_file("text/plain", filename) + + def handle_menu(self, filename): + """ + Send a Gemini response for a downloaded Gopher resource whose item + type indicates it should be a menu. + """ + self.send_gemini_header(20, "text/gemini") + with open(filename,"r") as fp: + for line in fp: + if line.strip() == ".": + continue + elif line.startswith("i"): + # This is an "info" line. Just strip off the item type + # and send the item name, ignorin the dummy selector, etc. + self.request.send((line[1:].split("\t")[0]+"\r\n").encode("UTF-8")) + else: + # This is an actual link to a Gopher resource + gemini_link = self.gopher_link_to_gemini_link(line) + self.request.send(gemini_link.encode("UTF-8")) + + def gopher_link_to_gemini_link(self, line): + """ + Convert one line of a Gopher menu to one line of a Geminimap. + """ + + # Code below pinched from VF-1 + + # Split on tabs. Strip final element after splitting, + # since if we split first we loose empty elements. + parts = line.split("\t") + parts[-1] = parts[-1].strip() + # Discard Gopher+ noise + if parts[-1] == "+": + parts = parts[:-1] + + # Attempt to assign variables. This may fail. + # It's up to the caller to catch the Exception. + name, path, host, port = parts + itemtype = name[0] + name = name[1:] + port = int(port) + if itemtype == "h" and path.startswith("URL:"): + url = path[4:] + else: + url = "gopher://%s%s/%s%s" % ( + host, + "" if port == 70 else ":%d" % port, + itemtype, + path + ) + + return "=> {} {}\r\n".format(url, name) + + def handle_html(self, filename): + """ + Send a Gemini response for a downloaded Gopher resource whose item + type indicates it should be HTML. + """ + self._serve_file("text/html", filename) + + def handle_binary(self, filename): + """ + Send a Gemini response for a downloaded Gopher resource whose item + type indicates it should be a binary file. Uses file(1) to sniff MIME + types. + """ + # Detect MIME type + out = subprocess.check_output( + shlex.split("file --brief --mime-type %s" % filename) + ) + mimetype = out.decode("UTF-8").strip() + self._serve_file(mimetype, filename) + + def _serve_file(self, mime, filename): + """ + Send a Gemini response with a given MIME type whose body is the + contents of the specified file. + """ + self.send_gemini_header(20, mime) + with open(filename,"rb") as fp: + self.request.send(fp.read()) + +if __name__ == "__main__": + + parser = argparse.ArgumentParser(description= +"""Agena is a simple Gemini-to-Gopher designed to be run locally to +let you seamlessly access Gopherspace from inside a Gemini client.""") + parser.add_argument('--cert', type=str, nargs="?", default="cert.pem", + help='TLS certificate file.') + parser.add_argument('--key', type=str, nargs="?", default="key.pem", + help='TLS private key file.') + parser.add_argument('--port', type=int, nargs="?", default=PORT, + help='TCP port to serve on.') + parser.add_argument('--host', type=str, nargs="?", default=HOST, + help='TCP host to serve on.') + args = parser.parse_args() + print(args) + + if not (os.path.exists(args.cert) and os.path.exists(args.key)): + print("Couldn't find cert.pem and/or key.pem. :(") + sys.exit(1) + + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain(certfile=args.cert, keyfile=args.key) + + socketserver.TCPServer.allow_reuse_address = True + agena = socketserver.TCPServer((args.host, args.port), AgenaHandler) + try: + agena.serve_forever() + except KeyboardInterrupt: + agena.shutdown() + agena.server_close() + diff --git a/bin/arcsize b/bin/arcsize @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +cat /proc/spl/kstat/zfs/arcstats | grep -E ^size | awk '{print $3}' diff --git a/bin/book b/bin/book @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +BOOKS_DIR="$HOME/docs/books" + +find "$BOOKS_DIR" -name '*.txt' -type f -printf "%f\n" | + fzf | + xargs -I{} lessr $BOOKS_DIR/{} diff --git a/bin/btc-txs-raw b/bin/btc-txs-raw @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +WALLETS=${WALLETS:-$(bcli listwallets | jq -r '.[]' | paste -sd" " )} + +(for wallet in $WALLETS +do + bcli -rpcwallet="$wallet" "$@" listtransactions '*' 2000 \ + | jq -rc '.[] | {label: .label, address: .address, category: .category, amount: .amount, blocktime: .blocktime}' +done) \ + | jq -src 'sort_by(.time) | .[] | [.label,.address,.category,.amount,(.blocktime | strftime("%F %R"))] | @tsv' + diff --git a/bin/gemini b/bin/gemini @@ -1,2 +1,2 @@ #!/usr/bin/env sh -exec reader $GEMINICLIENT "$@" +exec $GEMINICLIENT "$@" diff --git a/bin/killspotify b/bin/killspotify @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +ps ax | grep spotify-wrapped | grep -v grep | head -n1 | awk '{print $1}' | xargs kill diff --git a/bin/lessr b/bin/lessr @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +dir=$(dirname "$1") +base=$(basename "$1") + +export LESSHISTFILE="$dir/.$base.hst" + +exec less "$@" diff --git a/bin/lockmac b/bin/lockmac @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +ip=$(macip) + +ssh "$ip" "osascript -e 'tell application \"System Events\" to keystroke \"q\" using {control down, command down}'" diff --git a/bin/macip b/bin/macip @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +exec arp -a -i enp30s0 | grep -e '40:6c:8f:39:43:e9' -e 'c4:41:1e:75:42:71' | sedcut '(\([0-9\.]\+\))' + diff --git a/bin/notmuch-emacs-mua b/bin/notmuch-emacs-mua @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# +# notmuch-emacs-mua - start composing a mail on the command line +# +# Copyright © 2014 Jani Nikula +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/ . +# +# Authors: Jani Nikula <jani@nikula.org> +# + +set -eu + +# escape: "expand" '\' as '\\' and '"' as '\"' +# calling convention: escape -v var "$arg" (like in bash printf). +escape () +{ + local __escape_arg__=${3//\\/\\\\} + printf -v $2 '%s' "${__escape_arg__//\"/\\\"}" +} + +EMACS=${EMACS:-emacs} +EMACSCLIENT=${EMACSCLIENT:-emacsclient} + +PRINT_ONLY= +NO_WINDOW= +USE_EMACSCLIENT= +AUTO_DAEMON= +CREATE_FRAME= +ELISP= +MAILTO= +HELLO= + +# Short options compatible with mutt(1). +while getopts :s:c:b:i:h opt; do + # Handle errors and long options. + case "${opt}" in + :) + echo "$0: short option -${OPTARG} requires an argument." >&2 + exit 1 + ;; + \?) + opt=$1 + if [ "${OPTARG}" != "-" ]; then + echo "$0: unknown short option -${OPTARG}." >&2 + exit 1 + fi + + case "${opt}" in + # Long options with arguments. + --subject=*|--to=*|--cc=*|--bcc=*|--body=*) + OPTARG=${opt#--*=} + opt=${opt%%=*} + ;; + # Long options without arguments. + --help|--print|--no-window-system|--client|--auto-daemon|--create-frame|--hello) + ;; + *) + echo "$0: unknown long option ${opt}, or argument mismatch." >&2 + exit 1 + ;; + esac + # getopts does not do this for what it considers errors. + OPTIND=$((OPTIND + 1)) + ;; + esac + + escape -v OPTARG "${OPTARG-none}" + + case "${opt}" in + --help|h) + exec man notmuch-emacs-mua + ;; + --subject|s) + ELISP="${ELISP} (message-goto-subject) (insert \"${OPTARG}\")" + ;; + --to) + ELISP="${ELISP} (message-goto-to) (insert \"${OPTARG}, \")" + ;; + --cc|c) + ELISP="${ELISP} (message-goto-cc) (insert \"${OPTARG}, \")" + ;; + --bcc|b) + ELISP="${ELISP} (message-goto-bcc) (insert \"${OPTARG}, \")" + ;; + --body|i) + ELISP="${ELISP} (message-goto-body) (insert-file \"${OPTARG}\")" + ;; + --print) + PRINT_ONLY=1 + ;; + --no-window-system) + NO_WINDOW="-nw" + ;; + --client) + USE_EMACSCLIENT="yes" + ;; + --auto-daemon) + AUTO_DAEMON="--alternate-editor=" + CREATE_FRAME="-c" + ;; + --create-frame) + CREATE_FRAME="-c" + ;; + --hello) + HELLO=1 + ;; + *) + # We should never end up here. + echo "$0: internal error (option ${opt})." >&2 + exit 1 + ;; + esac + + shift $((OPTIND - 1)) + OPTIND=1 +done + +# Positional parameters. +for arg; do + escape -v arg "${arg}" + case $arg in + mailto:*) + if [ -n "${MAILTO}" ]; then + echo "$0: more than one mailto: argument." >&2 + exit 1 + fi + MAILTO="${arg}" + ;; + *) + ELISP="${ELISP} (message-goto-to) (insert \"${arg}, \")" + ;; + esac +done + +if [ -n "${MAILTO}" ]; then + if [ -n "${ELISP}" ]; then + echo "$0: mailto: is not compatible with other message parameters." >&2 + exit 1 + fi + ELISP="(browse-url-mail \"${MAILTO}\")" +elif [ -z "${ELISP}" -a -n "${HELLO}" ]; then + ELISP="(notmuch)" +else + ELISP="(notmuch-mua-new-mail) ${ELISP}" +fi + +# Kill the terminal/frame if we're creating one. +if [ -z "$USE_EMACSCLIENT" -o -n "$CREATE_FRAME" -o -n "$NO_WINDOW" ]; then + ELISP="${ELISP} (message-add-action #'save-buffers-kill-terminal 'exit)" +fi + +escape -v pwd "$PWD" + +# The crux of it all: construct an elisp progn and eval it. +ELISP="(prog1 'done (require 'notmuch) (cd \"$pwd\") ${ELISP})" + +if [ -n "$PRINT_ONLY" ]; then + echo ${ELISP} + exit 0 +fi + +if [ -n "$USE_EMACSCLIENT" ]; then + # Evaluate the progn. + exec ${EMACSCLIENT} ${NO_WINDOW} ${CREATE_FRAME} ${AUTO_DAEMON} --eval "${ELISP}" +else + exec ${EMACS} ${NO_WINDOW} --eval "${ELISP}" +fi diff --git a/bin/paged b/bin/paged @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +exec "$@" | less -R diff --git a/bin/pdfsave b/bin/pdfsave @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +#PANDOC='pandoc --metadata=fontfamily:libertine' +PANDOC='pandoc --pdf-engine=xelatex --metadata=fontfamily:libertine -V geometry:margin=1.2in --variable urlcolor=cyan' +#PANDOC='pandoc -V geometry:margin=1.2in --variable urlcolor=cyan' + +usage () { + printf "usage: pdfsave file.md out.pdf OR <file.md pdfsave out.pdf [markdown]\n" >&2 + exit 1 +} + +if [ -t 0 ]; then + [ $# -eq 0 ] && usage + outfile=${2:-"$(mktemp)".pdf} + $PANDOC "$1" -o "$outfile" +else + outfile=${1:-"$(mktemp)".pdf} + ext="${2:-markdown}" + $PANDOC -f "$ext" -o "$outfile" +fi diff --git a/bin/procmem b/bin/procmem @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +ps -eo pmem,rss,comm | + grep -v '\[' | + awk 'NR>2{mem[$3]+=$2*1024}END {for(k in mem) print mem[k] "\t" k};' | + sort -gk 1 | + column -t -s $'\t' diff --git a/bin/procmemall b/bin/procmemall @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +procmem | awk '{print $2}' | paste -sd+ | bc diff --git a/bin/reader b/bin/reader @@ -1,2 +0,0 @@ -#!/usr/bin/env bash -exec urxvtc -fn 'xft:Inconsolata:size=18' -e "$@"