bollux/bollux

518 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# bollux: a bash gemini client
# Author: Case Duckworth
# License: MIT
# Version: 0.1
# Program information
PRGN="${0##*/}"
VRSN=0.1
# State
REDIRECTS=0
bollux_usage() {
cat <<END
$PRGN (v. $VRSN): a bash gemini client
usage:
$PRGN [-h]
$PRGN [-q] [-v] [URL]
flags:
-h show this help and exit
-q be quiet: log no messages
-v verbose: log more messages
parameters:
URL the URL to start in
If not provided, the user will be prompted.
END
}
run() {
log debug "$@"
"$@"
}
die() {
ec="$1"
shift
log error "$*"
exit "$ec"
}
trim() { sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'; }
log() {
[[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
case "$1" in
d* | D*) # debug
[[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
fmt=34
;;
e* | E*) # error
fmt=31
;;
*) fmt=1 ;;
esac
shift
printf >&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*"
}
# main entry point
bollux() {
run bollux_args "$@"
run bollux_config
if [[ ! "${BOLLUX_URL:+isset}" ]]; then
run prompt GO BOLLUX_URL
fi
run blastoff "$BOLLUX_URL"
}
bollux_args() {
while getopts :hvq OPT; do
case "$OPT" in
h)
bollux_usage
exit
;;
v) BOLLUX_LOGLEVEL=DEBUG ;;
q) BOLLUX_LOGLEVEL=QUIET ;;
:) die 1 "Option -$OPTARG requires an argument" ;;
*) die 1 "Unknown option: -$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
if (($# == 1)); then
BOLLUX_URL="$1"
fi
}
bollux_config() {
: "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/config}"
if [ -f "$BOLLUX_CONFIG" ]; then
# shellcheck disable=1090
. "$BOLLUX_CONFIG"
else
log debug "Can't load config file '$BOLLUX_CONFIG'."
fi
: "${BOLLUX_DOWNDIR:=.}" # where to save downloads
: "${BOLLUX_LOGLEVEL:=3}" # log level
: "${BOLLUX_MAXREDIR:=5}" # max redirects
: "${BOLLUX_PORT:=1965}" # port number
: "${BOLLUX_PROTO:=gemini}" # default protocol
: "${BOLLUX_LESSKEY:=/tmp/bollux-lesskey}" # where to store binds
: "${BOLLUX_PAGESRC:=/tmp/bollux-src}" # where to save the page source
: "${BOLLUX_URL:=}" # start url
}
prompt() {
prompt="$1"
shift
read </dev/tty -e -r -p "$prompt> " "$@"
}
blastoff() { # load a url
local well_formed=true
if [[ "$1" == "-u" ]]; then
well_formed=false
shift
fi
URL="$1"
if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
URL="$(run transform_resource "$BOLLUX_URL" "$1")"
fi
[[ "$URL" != *://* ]] && URL="$BOLLUX_PROTO://$URL"
URL="$(trim <<<"$URL")"
server="${URL#*://}"
server="${server%%/*}"
run request_url "$server" "$BOLLUX_PORT" "$URL" |
run handle_response "$URL"
}
transform_resource() { # transform_resource BASE_URL REFERENCE_URL
declare -A R B T # reference, base url, target
eval "$(parse_url B "$1")"
eval "$(parse_url R "$2")"
# A non-strict parser may ignore a scheme in the reference
# if it is identical to the base URI's scheme.
if ! "${STRICT:-true}" && [[ "${R[scheme]}" == "${B[scheme]}" ]]; then
unset "${R[scheme]}"
fi
# basically pseudo-code from spec ported to bash
if isdefined "R[scheme]"; then
T[scheme]="${R[scheme]}"
isdefined "R[authority]" && T[authority]="${R[authority]}"
isdefined R[path] &&
T[path]="$(remove_dot_segments "${R[path]}")"
isdefined "R[query]" && T[query]="${R[query]}"
else
if isdefined "R[authority]"; then
T[authority]="${R[authority]}"
isdefined "R[authority]" &&
T[path]="$(remove_dot_segments "${R[path]}")"
isdefined R[query] && T[query]="${R[query]}"
else
if isempty "R[path]"; then
T[path]="${B[path]}"
if isdefined R[query]; then
T[query]="${R[query]}"
else
T[query]="${B[query]}"
fi
else
if [[ "${R[path]}" == /* ]]; then
T[path]="$(remove_dot_segments "${R[path]}")"
else
T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")"
T[path]="$(remove_dot_segments "${T[path]}")"
fi
isdefined R[query] && T[query]="${R[query]}"
fi
T[authority]="${B[authority]}"
fi
T[scheme]="${B[scheme]}"
fi
isdefined R[fragment] && T[fragment]="${R[fragment]}"
# cf. 5.3 -- recomposition
local r=""
isdefined "T[scheme]" && r="$r${T[scheme]}:"
isdefined "T[authority]" && r="$r//${T[authority]}"
r="$r${T[path]}"
isdefined T[query] && r="$r?${T[query]}"
isdefined T[fragment] && r="$r#${T[fragment]}"
printf '%s\n' "$r"
}
merge_paths() { # 5.2.3
# shellcheck disable=2034
B_authority="$1"
B_path="$2"
R_path="$3"
# if R_path is empty, get rid of // in B_path
if [[ -z "$R_path" ]]; then
printf '%s\n' "${B_path//\/\//\//}"
return
fi
if isdefined "B_authority" && isempty "B_path"; then
printf '/%s\n' "${R_path//\/\//\//}"
else
if [[ "$B_path" == */* ]]; then
B_path="${B_path%/*}/"
else
B_path=""
fi
printf '%s/%s\n' "${B_path%/}" "${R_path#/}"
fi
}
remove_dot_segments() { # 5.2.4
local input="$1"
local output=
# ^/\.(/|$) - BASH_REMATCH[0]
while [[ "$input" ]]; do
if [[ "$input" =~ ^\.\.?/ ]]; then
input="${input#${BASH_REMATCH[0]}}"
elif [[ "$input" =~ ^/\.(/|$) ]]; then
input="/${input#${BASH_REMATCH[0]}}"
elif [[ "$input" =~ ^/\.\.(/|$) ]]; then
input="/${input#${BASH_REMATCH[0]}}"
[[ "$output" =~ /?[^/]+$ ]]
output="${output%${BASH_REMATCH[0]}}"
elif [[ "$input" == . || "$input" == .. ]]; then
input=
else
[[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || echo NOMATCH >&2
output="$output${BASH_REMATCH[1]}"
input="${BASH_REMATCH[2]}"
fi
done
printf '%s\n' "${output//\/\//\//}"
}
parse_url() { # eval "$(split_url NAME STRING)" => NAME[...]
local name="$1"
local string="$2"
local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
[[ $string =~ $re ]] || return $?
local scheme="${BASH_REMATCH[2]}"
local authority="${BASH_REMATCH[4]}"
local path="${BASH_REMATCH[5]}"
local query="${BASH_REMATCH[7]}"
local fragment="${BASH_REMATCH[9]}"
for c in scheme authority query fragment; do
[[ "${!c}" ]] &&
printf '%s[%s]=%q\n' "$name" "$c" "${!c}"
done
# unclear if the path is always set even if empty but it looks that way
printf '%s[path]=%q\n' "$name" "$path"
}
# is a NAME defined ('set' in bash)?
isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME
# is a NAME defined AND empty?
isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME
request_url() {
local server="$1"
local port="$2"
local url="$3"
ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
ssl_cmd+=(-servername "$server") # SNI
run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null
}
handle_response() {
local url="$1" code meta
while read -r -d $'\r' hdr; do
code="$(gawk '{print $1}' <<<"$hdr")"
meta="$(
gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$hdr"
)"
break
done
log x "[$code] $meta"
case "$code" in
1*)
REDIRECTS=0
BOLLUX_URL="$URL"
run prompt "$meta" QUERY
run blastoff "?$QUERY"
;;
2*)
REDIRECTS=0
BOLLUX_URL="$URL"
run display "$meta"
;;
3*)
((REDIRECTS += 1))
if ((REDIRECTS > BOLLUX_MAXREDIR)); then
die $((100 + code)) "Too many redirects!"
fi
BOLLUX_URL="$URL"
run blastoff "$meta"
;;
4*)
REDIRECTS=0
die "$((100 + code))" "$code"
;;
5*)
REDIRECTS=0
die "$((100 + code))" "$code"
;;
6*)
REDIRECTS=0
die "$((100 + code))" "$code"
;;
*) die "$((100 + code)) Unknown response code: $code." ;;
esac
}
display() {
case "$1" in
*\;*)
mime="$(cut -d\; -f1 <<<"$1" | trim)"
charset="$(cut -d\; -f2 <<<"$1" | trim)"
;;
*) mime="$(trim <<<"$1")" ;;
esac
[[ -z "$mime" ]] && mime="text/gemini"
if [[ -z "$charset" ]]; then
charset="utf-8"
else
charset="${charset#charset=}"
fi
log debug "mime=$mime; charset=$charset"
case "$mime" in
text/*)
less_cmd=(less -R)
{
[[ -r "$BOLLUX_LESSKEY" ]] || mklesskey "$BOLLUX_LESSKEY"
} && less_cmd+=(-k "$BOLLUX_LESSKEY")
less_cmd+=(
-Pm'bollux$'
-PM'o\:open, g\:goto, r\:refresh$'
-M
)
submime="${mime#*/}"
if declare -F | grep -q "$submime"; then
log d "typeset_$submime"
{
normalize_crlf |
tee "$BOLLUX_PAGESRC" |
run "typeset_$submime" |
run "${less_cmd[@]}"
} || run handle_keypress "$?"
else
log "cat"
{
normalize_crlf |
tee "$BOLLUX_PAGESRC" |
run "${less_cmd[@]}"
} || run handle_keypress "$?"
fi
;;
*) run download "$BOLLUX_URL" ;;
esac
}
mklesskey() {
lesskey -o "$1" - <<-END
#command
o quit 0 # 48 open a link
g quit 1 # 49 goto a url
[ quit 2 # 50 back
] quit 3 # 51 forward
r quit 4 # 52 re-request / download
END
}
normalize_crlf() {
gawk 'BEGIN{RS="\n\n"}{gsub(/\r\n?/,"\n");print;print ""}'
}
typeset_gemini() {
gawk '
BEGIN {
pre = 0
margin = margin ? margin : 4
txs = ""
lns = "\033[1m"
lus = "\033[36m"
lts = "\033[4m"
pfs = ""
h1s = "\033[1;4m"
h2s = "\033[1m"
h3s = "\033[3m"
lis = ""
res = "\033[0m"
ms = "\033[35m"
}
/```/ {
pre = ! pre
next
}
pre {
mark = "```"
fmt = pfs "%s" res
text = $0
}
/^#/ {
match($0, /#+/)
mark = substr($0, RSTART, RLENGTH)
sub(/#+[[:space:]]*/, "", $0)
level = length(mark)
if (level == 1) {
fmt = h1s "%s" res
} else if (level == 2) {
fmt = h2s "%s" res
} else {
fmt = h3s "%s" res
}
}
/^=>/ {
mark = "=>"
sub(/=>[[:space:]]*/, "", $0)
desc = $1
text = ""
for (w = 2; w <= NF; w++) {
text = text (text ? " " : "") $w
}
fmt = lns "[" (++ln) "]" res " " lts "%s" res "\t" lus "%s" res
}
/^\*[[:space:]]/ {
mark = "*"
sub(/\*[[:space:]]*/, "", $0)
fmt = lis "%s" res
}
{
mark = mark ? mark : mark
fmt = fmt ? fmt : "%s"
text = text ? text : $0
desc = desc ? desc : ""
printf ms "%" (margin-1) "s " res fmt "\n", mark, text, desc
mark = fmt = text = desc = ""
}
'
}
handle_keypress() {
case "$1" in
48) # o - open a link -- show a menu of links on the page
run select_url "$BOLLUX_PAGESRC"
;;
49) # g - goto a url -- input a new url
prompt GO URL
run blastoff -u "$URL"
;;
50) # [ - back in the history
run history_back
;;
51) # ] - forward in the history
run history_forward
;;
52) # r - re-request the current resource
run blastoff "$BOLLUX_URL"
;;
*) # 53-57 -- still available for binding
;;
esac
}
select_url() {
run mapfile -t < <(extract_links <"$1")
select u in "${MAPFILE[@]}"; do
run blastoff "$(gawk '{print $1}' <<<"$u")" && break
done </dev/tty
}
extract_links() {
gawk -F$'\t' '
/^=>/ {
sub(/=>[[:space:]]*/,"")
if ($2)
printf "%s (\033[34m%s\033[0m)\n", $1, $2
else
printf "%s\n", $1
}'
}
download() {
tn="$(mktemp)"
log x "Downloading: '$BOLLUX_URL' => '$tn'..."
dd status=progress >"$tn"
fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}"
if [[ -f "$fn" ]]; then
log x "Saved '$tn'."
elif mv "$tn" "$fn"; then
log x "Saved '$fn'."
else
log error "Error saving '$fn': downloaded to '$tn'."
fi
}
history_back() { log error "Not implemented."; }
history_forward() { log error "Not implemented."; }
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
run bollux "$@"
else
BOLLUX_LOGLEVEL=DEBUG
fi