# bollux: a bash gemini client
# Author: Case Duckworth
# License: MIT
-# Version: 0.2.2
+# Version: 0.4.0
# Program information
PRGN="${0##*/}"
-VRSN=0.2.2
-# State
-REDIRECTS=0
-set -f
+VRSN=0.4.0
bollux_usage() {
cat <<END
END
}
-run() {
- log debug "$@"
+run() { # run COMMAND...
+ trap bollux_quit SIGINT
+ log debug "$*"
"$@"
}
-die() {
- ec="$1"
+die() { # die EXIT_CODE MESSAGE
+ local ec="$1"
shift
log error "$*"
exit "$ec"
}
-# pure bash bible trim_string
-trim() {
+# builtin replacement for `sleep`
+# https://github.com/dylanaraps/pure-bash-bible#use-read-as-an-alternative-to-the-sleep-command
+sleep() { # sleep SECONDS
+ read -rt "$1" <> <(:) || :
+}
+
+# https://github.com/dylanaraps/pure-bash-bible/
+trim_string() { # trim_string STRING
: "${1#"${1%%[![:space:]]*}"}"
: "${_%"${_##*[![:space:]]}"}"
printf '%s\n' "$_"
}
-log() {
+# cycle a variable, e.g. from 'one,two,three' => 'two,three,one'
+cycle_list() { # cycle_list LIST DELIM
+ local list="${!1}" delim="$2"
+ local first="${list%%${delim}*}"
+ local rest="${list#*${delim}}"
+ printf -v "$1" '%s%s%s' "${rest}" "${delim}" "${first}"
+}
+
+# determine the first element of a list, e.g. 'one,two,three' => 'one'
+first() { # first LIST DELIM
+ local list="${!1}" delim="$2"
+ printf '%s\n' "${list%%${delim}*}"
+}
+
+log() { # log LEVEL MESSAGE
[[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
+ local fmt
+
case "$1" in
- d* | D*) # debug
+ [dD]*) # debug
[[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
fmt=34
;;
- e* | E*) # error
+ [eE]*) # error
fmt=31
;;
*) fmt=1 ;;
esac
shift
- printf >&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*"
+
+ printf >&2 '\e[%sm%s:%s:\e[0m\t%s\n' "$fmt" "$PRGN" "${FUNCNAME[1]}" "$*"
}
# main entry point
bollux() {
- run bollux_config
- run bollux_args "$@"
- run history_init
+ run bollux_config # TODO: figure out better config method
+ run bollux_args "$@" # and argument parsing
+ run bollux_init
- if [[ ! "${BOLLUX_URL:+isset}" ]]; then
+ if [[ ! "${BOLLUX_URL:+x}" ]]; then
run prompt GO BOLLUX_URL
fi
- run blastoff "$BOLLUX_URL"
+ log d "BOLLUX_URL='$BOLLUX_URL'"
+
+ run blastoff -u "$BOLLUX_URL"
}
+# process command-line arguments
bollux_args() {
while getopts :hvq OPT; do
case "$OPT" in
fi
}
+# process config file and set variables
bollux_config() {
: "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/bollux.conf}"
fi
## behavior
- : "${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
+ : "${BOLLUX_TIMEOUT:=30}" # connection timeout
+ : "${BOLLUX_MAXREDIR:=5}" # max redirects
+ : "${BOLLUX_PORT:=1965}" # port number
+ : "${BOLLUX_PROTO:=gemini}" # default protocol
+ : "${BOLLUX_URL:=}" # start url
+ : "${BOLLUX_BYEMSG:=See You Space Cowboy ...}" # bye message
+ : "${BOLLUX_PRE_DISPLAY:=pre,alt,both}" # how to view PRE blocks
+ ## files
: "${BOLLUX_DATADIR:=${XDG_DATA_DIR:-$HOME/.local/share}/bollux}"
- BOLLUX_HISTFILE="$BOLLUX_DATADIR/history" # where to store the history
+ : "${BOLLUX_DOWNDIR:=.}" # where to save downloads
+ : "${BOLLUX_LESSKEY:=$BOLLUX_DATADIR/lesskey}" # where to store binds
+ : "${BOLLUX_PAGESRC:=$BOLLUX_DATADIR/pagesrc}" # where to save source
+ BOLLUX_HISTFILE="$BOLLUX_DATADIR/history" # where to save history
## typesetting
- : "${T_MARGIN:=4}" # left and right margin
- : "${T_WIDTH:=0}" # width of the viewport -- 0 = get term width
+ : "${T_MARGIN:=4}" # left and right margin
+ : "${T_WIDTH:=0}" # width of the viewport -- 0 = get term width
# colors -- these will be wrapped in \e[ __ m
C_RESET='\e[0m' # reset
: "${C_SIGIL:=35}" # sigil (=>, #, ##, ###, *, ```)
: "${C_HEADER2:=1}" # header 2 formatting
: "${C_HEADER3:=3}" # header 3 formatting
: "${C_LIST:=0}" # list formatting
+ : "${C_QUOTE:=3}" # quote formatting
: "${C_PRE:=0}" # preformatted text formatting
+ ## state
+ UC_BLANK=':?:'
}
+# quit happily
bollux_quit() {
- log x "Thanks for flying $PRGN"
+ printf '\e[1m%s\e[0m:\t\e[3m%s\e[0m\n' "$PRGN" "$BOLLUX_BYEMSG"
exit
}
+# trap C-c
+trap bollux_quit SIGINT
-set_title() {
- printf '\e]2;%s - bollux\007' "$*"
+# set the terminal title
+set_title() { # set_title STRING
+ printf '\e]2;%s\007' "$*"
}
-prompt() {
- prompt="$1"
+# prompt for input
+prompt() { # prompt [-u] PROMPT [READ_ARGS...]
+ local read_cmd=(read -e -r)
+ if [[ "$1" == "-u" ]]; then
+ read_cmd+=(-i "$BOLLUX_URL")
+ shift
+ fi
+ local prompt="$1"
shift
- read </dev/tty -e -r -p "$prompt> " "$@"
+ read_cmd+=(-p "$prompt> ")
+ "${read_cmd[@]}" </dev/tty "$@"
}
-blastoff() { # load a url
- local well_formed=true
+# load a URL
+blastoff() { # blastoff [-u] URL
+ local u
+
if [[ "$1" == "-u" ]]; then
- well_formed=false
- shift
+ u="$(run uwellform "$2")"
+ else
+ u="$1"
fi
- URL="$1"
- if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
- URL="$(run transform_resource "$BOLLUX_URL" "$1")"
+ local -a url
+ run utransform url "$BOLLUX_URL" "$u"
+ if ! ucdef url[1]; then
+ run ucset url[1] "$BOLLUX_PROTO"
fi
- [[ "$URL" != *://* ]] && URL="$BOLLUX_PROTO://$URL"
- URL="$(trim "$URL")"
- server="${URL#*://}"
- server="${server%%/*}"
+ {
+ if declare -Fp "${url[1]}_request" >/dev/null 2>&1; then
+ run "${url[1]}_request" "$url"
+ else
+ die 99 "No request handler for '${url[1]}'"
+ fi
+ } | run normalize | {
+ if declare -Fp "${url[1]}_response" >/dev/null 2>&1; then
+ run "${url[1]}_response" "$url"
+ else
+ log d \
+ "No response handler for '${url[1]}';" \
+ " passing thru"
+ passthru
+ fi
+ }
+}
- log d "URL='$URL' server='$server'"
+# URLS
+## https://tools.ietf.org/html/rfc3986
+uwellform() {
+ local u="$1"
+
+ if [[ "$u" != *://* ]]; then
+ u="$BOLLUX_PROTO://$u"
+ fi
- run request_url "$server" "$BOLLUX_PORT" "$URL" |
- run handle_response "$URL"
+ u="$(trim_string "$u")"
+
+ printf '%s\n' "$u"
}
-transform_resource() { # transform_resource BASE_URL REFERENCE_URL
- declare -A R B T # reference, base url, target
- eval "$(run parse_url B "$1")"
- eval "$(run 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]}"
+usplit() { # usplit NAME:ARRAY URL:STRING
+ local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
+ [[ $2 =~ $re ]] || return $?
+
+ # shellcheck disable=2034
+ local scheme="${BASH_REMATCH[2]}" \
+ authority="${BASH_REMATCH[4]}" \
+ path="${BASH_REMATCH[5]}" \
+ query="${BASH_REMATCH[7]}" \
+ fragment="${BASH_REMATCH[9]}"
+
+ # 0=url 1=scheme 2=authority 3=path 4=query 5=fragment
+ local i=1 c
+ for c in scheme authority path query fragment; do
+ if [[ "${!c}" || "$c" == path ]]; then
+ printf -v "$1[$i]" '%s' "${!c}"
+ else
+ # shellcheck disable=2059
+ printf -v "$1[$i]" "$UC_BLANK"
+ fi
+ ((i += 1))
+ done
+ # shellcheck disable=2059
+ printf -v "$1[0]" "$(ujoin "$1")" # inefficient I'm sure
+}
+
+ujoin() { # ujoin NAME:ARRAY
+ local -n U="$1"
+
+ if ucdef U[1]; then
+ printf -v U[0] "%s:" "${U[1]}"
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]="$(run remove_dot_segments "${R[path]}")"
- isdefined "R[query]" && T[query]="${R[query]}"
+ if ucdef U[2]; then
+ printf -v U[0] "${U[0]}//%s" "${U[2]}"
+ fi
+
+ printf -v U[0] "${U[0]}%s" "${U[3]}"
+
+ if ucdef U[4]; then
+ printf -v U[0] "${U[0]}?%s" "${U[4]}"
+ fi
+
+ if ucdef U[5]; then
+ printf -v U[0] "${U[0]}#%s" "${U[5]}"
+ fi
+
+ log d "${U[0]}"
+}
+
+ucdef() { [[ "${!1}" != "$UC_BLANK" ]]; } # ucdef NAME
+ucblank() { [[ -z "${!1}" ]]; } # ucblank NAME
+ucset() { # ucset NAME VALUE
+ run eval "${1}='$2'"
+ run ujoin "${1/\[*\]/}"
+}
+
+utransform() { # utransform TARGET:ARRAY BASE:STRING REFERENCE:STRING
+ local -a B R # base, reference
+ local -n T="$1" # target
+ usplit B "$2"
+ usplit R "$3"
+
+ # initialize T
+ for ((i = 1; i <= 5; i++)); do
+ T[$i]="$UC_BLANK"
+ done
+
+ # 0=url 1=scheme 2=authority 3=path 4=query 5=fragment
+ if ucdef R[1]; then
+ T[1]="${R[1]}"
+ if ucdef R[2]; then
+ T[2]="${R[2]}"
+ fi
+ if ucdef R[3]; then
+ T[3]="$(pundot "${R[3]}")"
+ fi
+ if ucdef R[4]; then
+ T[4]="${R[4]}"
+ fi
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]}"
+ if ucdef R[2]; then
+ T[2]="${R[2]}"
+ if ucdef R[2]; then
+ T[3]="$(pundot "${R[3]}")"
+ fi
+ if ucdef R[4]; then
+ T[4]="${R[4]}"
+ fi
else
- if isempty "R[path]"; then
- T[path]="${B[path]}"
- if isdefined R[query]; then
- T[query]="${R[query]}"
+ if ucblank R[3]; then
+ T[3]="${B[3]}"
+ if ucdef R[4]; then
+ T[4]="${R[4]}"
else
- T[query]="${B[query]}"
+ T[4]="${B[4]}"
fi
else
- if [[ "${R[path]}" == /* ]]; then
- T[path]="$(remove_dot_segments "${R[path]}")"
+ if [[ "${R[3]}" == /* ]]; then
+ T[3]="$(pundot "${R[3]}")"
else
- T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")"
- T[path]="$(remove_dot_segments "${T[path]}")"
+ T[3]="$(pmerge B R)"
+ T[3]="$(pundot "${T[3]}")"
+ fi
+ if ucdef R[4]; then
+ T[4]="${R[4]}"
fi
- isdefined R[query] && T[query]="${R[query]}"
fi
- T[authority]="${B[authority]}"
+ T[2]="${B[2]}"
fi
- T[scheme]="${B[scheme]}"
+ T[1]="${B[1]}"
fi
- isdefined R[fragment] && T[fragment]="${R[fragment]}"
- # cf. 5.3 -- recomposition
- local r=""
- isdefined "T[scheme]" && r="$r${T[scheme]}:"
- # remove the port from the authority
- 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
+ if ucdef R[5]; then
+ T[5]="${R[5]}"
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
+ ujoin T
}
-remove_dot_segments() { # 5.2.4
+pundot() { # pundot PATH:STRING
local input="$1"
- local output=
- # ^/\.(/|$) - BASH_REMATCH[0]
+ local output
while [[ "$input" ]]; do
if [[ "$input" =~ ^\.\.?/ ]]; then
input="${input#${BASH_REMATCH[0]}}"
elif [[ "$input" == . || "$input" == .. ]]; then
input=
else
- [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || log debug NOMATCH
+ [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || return 1
output="$output${BASH_REMATCH[1]}"
input="${BASH_REMATCH[2]}"
fi
printf '%s\n' "${output//\/\//\//}"
}
-parse_url() { # eval "$(split_url NAME STRING)" => NAME[...]
- local name="$1"
- local string="$2"
- # shopt -u extglob # TODO port re ^ to extglob syntax
- local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
- [[ $string =~ $re ]] || return $?
- # shopt -s extglob
-
- 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}" ]] &&
- run printf '%s[%s]=%q\n' "$name" "$c" "${!c}"
+pmerge() {
+ local -n b="$1"
+ local -n r="$2"
+
+ if ucblank r[3]; then
+ printf '%s\n' "${b[3]//\/\//\//}"
+ return
+ fi
+
+ if ucdef b[2] && ucblank b[3]; then
+ printf '/%s\n' "${r[3]//\/\//\//}"
+ else
+ local bp=""
+ if [[ "${b[3]}" == */* ]]; then
+ bp="${b[3]%/*}"
+ fi
+ printf '%s/%s\n' "${bp%/}" "${r[3]#/}"
+ fi
+}
+
+# https://github.com/dylanaraps/pure-bash-bible/
+uencode() { # uencode URL:STRING
+ local LC_ALL=C
+ for ((i = 0; i < ${#1}; i++)); do
+ : "${1:i:1}"
+ case "$_" in
+ [a-zA-Z0-9.~_-])
+ printf '%s' "$_"
+ ;;
+ *)
+ printf '%%%02X' "'$_"
+ ;;
+ esac
done
- # unclear if the path is always set even if empty but it looks that way
- run printf '%s[path]=%q\n' "$name" "$path"
+ printf '\n'
+}
+
+# https://github.com/dylanaraps/pure-bash-bible/
+udecode() { # udecode URL:STRING
+ : "${1//+/ }"
+ printf '%b\n' "${_//%/\\x}"
}
-# is a NAME defined ('set' in bash)?
-isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME
-# is a NAME defined AND empty?
-isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME
+# GEMINI
+# https://gemini.circumlunar.space/docs/specification.html
+gemini_request() { # gemini_request URL
+ local -a url
+ usplit url "$1"
-request_url() {
- local server="$1"
- local port="$2"
- local url="$3"
+ # get rid of userinfo
+ ucset url[2] "${url[2]#*@}"
- ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
- ssl_cmd+=(-servername "$server") # SNI
- run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null
+ local port
+ if [[ "${url[2]}" == *:* ]]; then
+ port="${url[2]#*:}"
+ ucset url[2] "${url[2]%:*}"
+ else
+ port=1965 # TODO variablize
+ fi
+
+ local ssl_cmd=(
+ openssl s_client
+ -crlf -quiet -connect "${url[2]}:$port"
+ -servername "${url[2]}" # SNI
+ -no_ssl3 -no_tls1 -no_tls1_1 # disable old TLS/SSL versions
+ )
+
+ run "${ssl_cmd[@]}" <<<"$url"
}
-handle_response() {
- local URL="$1" code meta
+gemini_response() { # gemini_response URL
+ local url code meta
+ local title
+ url="$1"
- 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"
- )"
+ # we need a loop here so it waits for the first line
+ while read -t "$BOLLUX_TIMEOUT" -r code meta ||
+ { (($? > 128)) && die 99 "Timeout."; }; do
break
done
- log x "[$code] $meta"
+ log d "[$code] $meta"
case "$code" in
- 1*)
+ 1*) # input
REDIRECTS=0
- run history_append "$URL" "$meta"
- run prompt "$meta" QUERY
- # shellcheck disable=2153
- run blastoff "?$QUERY"
+ BOLLUX_URL="$url"
+ case "$code" in
+ 10) run prompt "$meta" ;;
+ 11) run prompt "$meta" -s ;; # password input
+ esac
+ run blastoff "?$(uencode "$REPLY")"
;;
- 2*)
+ 2*) # OK
REDIRECTS=0
+ BOLLUX_URL="$url"
# read ahead to find a title
+ local pretitle
while read -r; do
- printf -v pretitle "%s\n" "$REPLY"
+ pretitle="$pretitle$REPLY"$'\n'
if [[ "$REPLY" =~ ^#[[:space:]]*(.*) ]]; then
title="${BASH_REMATCH[1]}"
break
fi
done
- run history_append "$URL" "$title"
+ run history_append "$url" "${title:-}"
+ # read the body out and pipe it to display
{
printf '%s' "$pretitle"
- while read -r; do printf '%s\n' "$REPLY"; done
- } | run display "$meta"
+ passthru
+ } | run display "$meta" "${title:-}"
;;
- 3*)
+ 3*) # redirect
((REDIRECTS += 1))
if ((REDIRECTS > BOLLUX_MAXREDIR)); then
die $((100 + code)) "Too many redirects!"
fi
- run blastoff "$meta"
+ BOLLUX_URL="$url"
+ run blastoff "$meta" # TODO: confirm redirect
;;
- 4*)
+ 4*) # temporary error
REDIRECTS=0
- die "$((100 + code))" "$code"
+ die "$((100 + code))" "Temporary error [$code]: $meta"
;;
- 5*)
+ 5*) # permanent error
REDIRECTS=0
- die "$((100 + code))" "$code"
+ die "$((100 + code))" "Permanent error [$code]: $meta"
;;
- 6*)
+ 6*) # certificate error
REDIRECTS=0
- die "$((100 + code))" "$code"
+ log d "Not implemented: Client certificates"
+ # TODO: recheck the speck
+ die "$((100 + code))" "[$code] $meta"
;;
*)
[[ -z "${code-}" ]] && die 100 "Empty response code."
- die "$((100 + code)) Unknown response code: $code."
+ die "$((100 + code))" "Unknown response code: $code."
;;
esac
}
-display() {
+# GOPHER
+# https://tools.ietf.org/html/rfc1436 protocol
+# https://tools.ietf.org/html/rfc4266 url
+gopher_request() { # gopher_request URL
+ local url server port type path
+ url="$1"
+ port=70
+
+ # RFC 4266
+ [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
+ server="${BASH_REMATCH[1]}"
+ port="${BASH_REMATCH[3]:-70}"
+ type="${BASH_REMATCH[6]:-1}"
+ path="${BASH_REMATCH[7]}"
+
+ log d "URL='$url' SERVER='$server' TYPE='$type' PATH='$path'"
+
+ exec 9<>"/dev/tcp/$server/$port"
+ printf '%s\r\n' "$path" >&9
+ passthru <&9
+}
+
+gopher_response() { # gopher_response URL
+ local url pre type cur_server
+ pre=false
+ url="$1"
+ # RFC 4266
+ [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
+ cur_server="${BASH_REMATCH[1]}"
+ type="${BASH_REMATCH[6]:-1}"
+
+ run history_append "$url" "" # gopher doesn't really have titles, huh
+
+ log d "TYPE='$type'"
+
+ case "$type" in
+ 0) # text
+ run display text/plain
+ ;;
+ 1) # menu
+ run gopher_convert | run display text/gemini
+ ;;
+ 3) # failure
+ die 203 "GOPHER: failed"
+ ;;
+ 7) # search
+ if [[ "$url" =~ $'\t' ]]; then
+ run gopher_convert | run display text/gemini
+ else
+ run prompt 'SEARCH'
+ run blastoff "$url $REPLY"
+ fi
+ ;;
+ *) # something else
+ run download "$url"
+ ;;
+ esac
+}
+
+# 'cat' but in pure bash
+passthru() {
+ while IFS= read -r; do
+ printf '%s\n' "$REPLY"
+ done
+}
+
+# convert gophermap to text/gemini (probably naive)
+gopher_convert() {
+ local type label path server port regex
+ # cf. https://github.com/jamestomasino/dotfiles-minimal/blob/master/bin/gophermap2gemini.awk
+ while IFS= read -r; do
+ printf -v regex '(.)([^\t]*)(\t([^\t]*)\t([^\t]*)\t([^\t]*))?'
+ if [[ "$REPLY" =~ $regex ]]; then
+ type="${BASH_REMATCH[1]}"
+ label="${BASH_REMATCH[2]}"
+ path="${BASH_REMATCH[4]:-/}"
+ server="${BASH_REMATCH[5]:-$cur_server}"
+ port="${BASH_REMATCH[6]}"
+ else
+ log e "CAN'T PARSE LINE"
+ printf '%s\n' "$REPLY"
+ continue
+ fi
+ case "$type" in
+ .) # end of file
+ printf '.\n'
+ break
+ ;;
+ i) # label
+ case "$label" in
+ '#'* | '*'[[:space:]]*)
+ if $pre; then
+ printf '%s\n' '```'
+ pre=false
+ fi
+ ;;
+ *)
+ if ! $pre; then
+ printf '%s\n' '```'
+ pre=true
+ fi
+ ;;
+ esac
+ printf '%s\n' "$label"
+ ;;
+ h) # html link
+ if $pre; then
+ printf '%s\n' '```'
+ pre=false
+ fi
+ printf '=> %s %s\n' "${path:4}" "$label"
+ ;;
+ T) # telnet link
+ if $pre; then
+ printf '%s\n' '```'
+ pre=false
+ fi
+ printf '=> telnet://%s:%s/%s%s %s\n' \
+ "$server" "$port" "$type" "$path" "$label"
+ ;;
+ *) # other type
+ if $pre; then
+ printf '%s\n' '```'
+ pre=false
+ fi
+ printf '=> gopher://%s:%s/%s%s %s\n' \
+ "$server" "$port" "$type" "$path" "$label"
+ ;;
+ esac
+ done
+ if $pre; then
+ printf '%s\n' '```'
+ fi
+ # close the connection
+ exec 9<&-
+ exec 9>&-
+}
+
+# display the fetched content
+display() { # display METADATA [TITLE]
+ local -a less_cmd
+ local i mime charset
# split header line
local -a hdr
- local i
- IFS=$'\n' read -d "" -ra hdr <<<"${1//;/$'\n'}"
+ IFS=';' read -ra hdr <<<"$1"
+ # title is optional but nice looking
+ local title
+ if (($# == 2)); then
+ title="$2"
+ fi
- mime="$(trim "${hdr[0],,}")"
+ mime="$(trim_string "${hdr[0],,}")"
for ((i = 1; i <= "${#hdr[@]}"; i++)); do
- h="$(trim "${hdr[$i]}")"
+ h="${hdr[$i]}"
case "$h" in
- charset=*) charset="${h#charset=}" ;;
+ *charset=*) charset="${h#*=}" ;;
esac
done
[[ -z "$mime" ]] && mime="text/gemini"
- if [[ -z "$charset" ]]; then
- charset="utf-8"
- fi
+ [[ -z "$charset" ]] && charset="utf-8"
log debug "mime='$mime'; charset='$charset'"
case "$mime" in
text/*)
- set_title "$BOLLUX_URL"
- less_cmd=(less -R)
- {
- [[ -r "$BOLLUX_LESSKEY" ]] || mklesskey "$BOLLUX_LESSKEY"
- } && less_cmd+=(-k "$BOLLUX_LESSKEY")
+ set_title "$title${title:+ - }bollux"
+ # render ANSI color escapes and don't wrap pre-formatted blocks
+ less_cmd=(less -RS)
+ mklesskey "$BOLLUX_LESSKEY" && less_cmd+=(-k "$BOLLUX_LESSKEY")
+ local helpline="o:open, g/G:goto, [:back, ]:forward, r:refresh"
less_cmd+=(
- -Pm'bollux$'
- -PM'o\:open, g\:goto, r\:refresh$'
- -M
+ # 'status'line
+ -Pm"$(less_prompt_escape "$BOLLUX_URL") - bollux$"
+ # helpline
+ -P="$(less_prompt_escape "$helpline")$"
+ # start with statusline
+ -m
+ # float content to the top
+ +k
)
- submime="${mime#*/}"
- if declare -F | grep -q "$submime"; then
- log d "typeset_$submime"
- {
- normalize_crlf |
- iconv -f "${charset^^}" -t "UTF-8" |
- tee "$BOLLUX_PAGESRC" |
- run "typeset_$submime" |
- run "${less_cmd[@]}" && bollux_quit
- } || run handle_keypress "$?"
+ local typeset
+ local submime="${mime#*/}"
+ if declare -Fp "typeset_$submime" &>/dev/null; then
+ typeset="typeset_$submime"
else
- log "cat"
- {
- normalize_crlf |
- iconv -f "${charset^^}" -t "UTF-8" |
- tee "$BOLLUX_PAGESRC" |
- run "${less_cmd[@]}" && bollux_quit
- } || run handle_keypress "$?"
+ typeset="passthru"
fi
+
+ {
+ run iconv -f "${charset^^}" -t "UTF-8" |
+ run tee "$BOLLUX_PAGESRC" |
+ run "$typeset" | #cat
+ run "${less_cmd[@]}" && bollux_quit
+ } || run handle_keypress "$?"
;;
*) run download "$BOLLUX_URL" ;;
esac
}
-mklesskey() {
- lesskey -o "$1" - <<-END
+# escape strings for the less prompt
+less_prompt_escape() { # less_prompt_escape STRING
+ local i
+ for ((i = 0; i < ${#1}; i++)); do
+ : "${1:i:1}"
+ case "$_" in
+ [\?:\.%\\]) printf '\%s' "$_" ;;
+ *) printf '%s' "$_" ;;
+ esac
+ done
+ printf '\n'
+}
+
+# generate a lesskey(1) file for custom keybinds
+mklesskey() { # mklesskey FILENAME
+ 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
+ G quit 5 # 53 goto a url (pre-filled)
+ ` quit 6 # 54 cycle BOLLUX_PRE_DISPLAY and refresh
# other keybinds
\40 forw-screen-force
+ h left-scroll
+ l right-scroll
+ ? status # 'status' will show a little help thing.
+ = noaction
END
}
-normalize_crlf() {
+# normalize files
+normalize() {
shopt -s extglob
while IFS= read -r; do
+ # normalize line endings
printf '%s\n' "${REPLY//$'\r'?($'\n')/}"
done
shopt -u extglob
}
+# typeset a text/gemini document
typeset_gemini() {
local pre=false
local ln=0 # link number
(
:
:
- ) # XXX this doesn't work!?
+ ) # dumb formatting brought to you by shfmt
log d "LINES=$LINES; COLUMNS=$COLUMNS"
T_WIDTH=$COLUMNS
fi
log d "T_WIDTH=$T_WIDTH"
log d "WIDTH=$WIDTH"
+ log d "$BOLLUX_PRE_DISPLAY"
while IFS= read -r; do
case "$REPLY" in
'```'*)
+ PRE_LINE_FORCE=false
if $pre; then
pre=false
else
pre=true
fi
+ case "${BOLLUX_PRE_DISPLAY%%,*}" in
+ pre)
+ :
+ ;;
+ alt | both)
+ $pre && PRE_LINE_FORCE=true \
+ gemini_pre "${REPLY#\`\`\`}"
+ ;;
+ esac
continue
;;
- =\>*)
+ '=>'*)
: $((ln += 1))
gemini_link "$REPLY" $pre "$ln"
;;
- \#*) gemini_header "$REPLY" $pre ;;
- \**)
- if [[ "$REPLY" =~ ^\*[[:space:]]+ ]]; then
- gemini_list "$REPLY" $pre
- else
- gemini_text "$REPLY" $pre
- fi
+ '#'*) gemini_header "$REPLY" $pre ;;
+ '*'[[:space:]]*)
+ gemini_list "$REPLY" $pre
+ ;;
+ '>'*)
+ gemini_quote "$REPLY" $pre
;;
*) gemini_text "$REPLY" $pre ;;
esac
gemini_link() {
local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)"
- local s t a l # sigil, text, annotation(url), line
+ local s t a # sigil, text, annotation(url)
+ local ln="$3"
if ! ${2-false} && [[ "$1" =~ $re ]]; then
s="${BASH_REMATCH[1]}"
a="${BASH_REMATCH[2]}"
fi
printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
- printf -v l "\e[${C_LINK_NUMBER}m[%d]${C_RESET} \
- \e[${C_LINK_TITLE}m%s${C_RESET} \
- \e[${C_LINK_URL}m%s${C_RESET}\n" \
- "$3" "$t" "$a"
- fold_line "$WIDTH" "$l"
+ printf "\e[${C_LINK_NUMBER}m[%d]${C_RESET} " "$ln"
+ fold_line -n -B "\e[${C_LINK_TITLE}m" -A "${C_RESET}" \
+ -l "$((${#ln} + 3))" -m "${T_MARGIN}" \
+ "$WIDTH" "$(trim_string "$t")"
+ fold_line -B " \e[${C_LINK_URL}m" \
+ -A "${C_RESET}" \
+ -l "$((${#ln} + 3 + ${#t}))" \
+ -m "$((T_MARGIN + ${#ln} + 2))" \
+ "$WIDTH" "$a"
else
gemini_pre "$1"
fi
gemini_header() {
local re="^(#+)[[:blank:]]*(.*)"
- local s t a l # sigil, text, annotation(lvl), line
+ local s t a # sigil, text, annotation(lvl)
if ! ${2-false} && [[ "$1" =~ $re ]]; then
s="${BASH_REMATCH[1]}"
a="${#BASH_REMATCH[1]}"
hdrfmt="$(eval echo "\$C_HEADER$a")"
printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
- printf -v l "\e[${hdrfmt}m%s${C_RESET}\n" "$t"
- fold_line "$WIDTH" "$l"
+ fold_line -B "\e[${hdrfmt}m" -A "${C_RESET}" -m "${T_MARGIN}" \
+ "$WIDTH" "$t"
else
gemini_pre "$1"
fi
gemini_list() {
local re="^(\*)[[:blank:]]*(.*)"
- local s t a l # sigil, text, annotation(n/a), line
+ local s t # sigil, text
if ! ${2-false} && [[ "$1" =~ $re ]]; then
s="${BASH_REMATCH[1]}"
t="${BASH_REMATCH[2]}"
- printf "\e[${C_SIGIL}m%${S_MARGIN}s " "$s"
- printf -v l "\e[${C_LIST}m%s${C_RESET}\n" "$t"
- fold_line "$WIDTH" "$l"
+ printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
+ fold_line -B "\e[${C_LIST}m" -A "${C_RESET}" -m "$T_MARGIN" \
+ "$WIDTH" "$t"
+ else
+ gemini_pre "$1"
+ fi
+}
+
+gemini_quote() {
+ local re="^(>)[[:blank:]]*(.*)"
+ local s t # sigil, text
+ if ! ${2-false} && [[ "$1" =~ $re ]]; then
+ s="${BASH_REMATCH[1]}"
+ t="${BASH_REMATCH[2]}"
+
+ printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
+ fold_line -B "\e[${C_QUOTE}m" -A "${C_RESET}" -m "$T_MARGIN" \
+ "$WIDTH" "$t"
else
gemini_pre "$1"
fi
gemini_text() {
if ! ${2-false}; then
printf "%${S_MARGIN}s " ' '
- fold_line "$WIDTH" "$1"
+ fold_line -m "$T_MARGIN" \
+ "$WIDTH" "$1"
else
gemini_pre "$1"
fi
}
gemini_pre() {
- printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```'
- printf "\e[${C_PRE}m%s${C_RESET}\n" "$1"
+ # Print preformatted text, dependent on $BOLLUX_PRE_DISPLAY and
+ # $PRE_LINE_FORCE
+ if [[ alt != "${BOLLUX_PRE_DISPLAY%%,*}" ]] || $PRE_LINE_FORCE; then
+ printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```'
+ printf "\e[${C_PRE}m%s${C_RESET}\n" "$1"
+ fi
}
-fold_line() { # fold_line WIDTH TEXT
- local width="$1"
- local margin="${2%%[![:space:]]*}"
- if [[ "$margin" ]]; then
- margin="${#margin}"
- else
- margin="$T_MARGIN"
+# wrap lines on words to WIDTH
+fold_line() { # fold_line [OPTIONS...] WIDTH TEXT
+ # see getopts, below, for options
+ local newline=true
+ local -i margin_all=0 margin_first=0 width ll=0 wl=0 wn=0
+ local before="" after=""
+ OPTIND=0
+ while getopts nm:f:l:B:A: OPT; do
+ case "$OPT" in
+ n) # -n = no trailing newline
+ newline=false
+ ;;
+ m) # -m MARGIN = margin for all lines
+ margin_all="$OPTARG"
+ ;;
+ f) # -f MARGIN = margin for first line
+ margin_first="$OPTARG"
+ ;;
+ l) # -l LENGTH = length of line before starting fold
+ ll="$OPTARG"
+ ;;
+ B) # -B BEFORE = text to insert before each line
+ before="$OPTARG"
+ ;;
+ A) # -A AFTER = text to insert after each line
+ after="$OPTARG"
+ ;;
+ *) return 1 ;;
+ esac
+ done
+ shift "$((OPTIND - 1))"
+ width="$1"
+ ll=$((ll % width))
+ #shellcheck disable=2086
+ set -- $2
+
+ local plain=""
+ if ((margin_first > 0 && ll == 0)); then
+ printf "%${margin_first}s" " "
+ fi
+ if [[ -n "$before" ]]; then
+ printf '%b' "$before"
fi
- local ll=0 wl plain
- # shellcheck disable=2086
- set -- $2 # TODO: is this the best way?
-
for word; do
+ ((wn += 1))
+ shopt -s extglob
plain="${word//$'\x1b'\[*([0-9;])m/}"
+ shopt -u extglob
wl=$((${#plain} + 1))
if (((ll + wl) >= width)); then
- printf "\n%${margin}s" ' '
+ printf "${after:-}\n%${margin_all}s${before:-}" ' '
ll=$wl
else
- ll=$((ll + wl))
+ ((ll += wl))
fi
- printf '%s ' "$word"
+ printf '%s' "$word"
+ ((wn != $#)) && printf ' '
done
- printf '\n'
+ [[ -n "$after" ]] && printf '%b' "$after"
+ $newline && printf '\n'
}
-handle_keypress() {
+# use the exit code from less (see mklesskey) to do things
+handle_keypress() { # handle_keypress CODE
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"
+ prompt GO
+ run blastoff -u "$REPLY"
;;
50) # [ - back in the history
run history_back || {
52) # r - re-request the current resource
run blastoff "$BOLLUX_URL"
;;
- *) # 53-57 -- still available for binding
+ 53) # G - goto a url (pre-filled with current)
+ run prompt -u GO
+ run blastoff -u "$REPLY"
+ ;;
+ 54) # ` - change alt-text visibility and refresh
+ run cycle_list BOLLUX_PRE_DISPLAY ,
+ run blastoff "$BOLLUX_URL"
+ ;;
+ 55) # 55-57 -- still available for binding
+ die "$?" "less(1) error"
;;
esac
}
-select_url() {
+# select a URL from a text/gemini file
+select_url() { # select_url FILE
run mapfile -t < <(extract_links <"$1")
+ if ((${#MAPFILE[@]} == 0)); then
+ log e "No links on this page!"
+ sleep 0.5
+ run blastoff "$BOLLUX_URL"
+ fi
+ PS3="OPEN> "
select u in "${MAPFILE[@]}"; do
case "$REPLY" in
q) bollux_quit ;;
+ [^0-9]*) run blastoff -u "$REPLY" && break ;;
esac
- run blastoff "$(gawk '{print $1}' <<<"$u")" && break
+ run blastoff "${u%%[[:space:]]*}" && break
done </dev/tty
}
+# extract the links from a text/gemini file
extract_links() {
local url alt
- while read -r line; do
- if [[ "$line" =~ ^=\>[[:space:]]*([^[:space:]]+)([[:space:]]+(.*))?$ ]]; then
+ local re="^=>[[:space:]]*([^[:space:]]+)([[:space:]]+(.*))?$"
+ while read -r; do
+ log d $re
+ if [[ $REPLY =~ $re ]]; then
url="${BASH_REMATCH[1]}"
alt="${BASH_REMATCH[3]}"
done
}
+# download $BOLLUX_URL
download() {
tn="$(mktemp)"
log x "Downloading: '$BOLLUX_URL' => '$tn'..."
fi
}
-history_init() {
+# initialize bollux
+bollux_init() {
+ # Trap cleanup
+ trap bollux_cleanup INT QUIT EXIT
+ # State
+ REDIRECTS=0
+ set -f
+ # History
declare -a HISTORY # history is kept in an array
HN=0 # position of history in the array
run mkdir -p "${BOLLUX_HISTFILE%/*}"
}
+# clean up on exit
+bollux_cleanup() {
+ # Stubbed in case of need in future
+ :
+}
+
+# append a URL to history
history_append() { # history_append URL TITLE
BOLLUX_URL="$1"
# date/time, url, title (best guess)
((HN += 1))
}
+# move back in history (session)
history_back() {
log d "HN=$HN"
((HN -= 2))
fi
run blastoff "${HISTORY[$HN]}"
}
+
+# move forward in history (session)
history_forward() {
log d "HN=$HN"
if ((HN >= ${#HISTORY[@]})); then
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
run bollux "$@"
-else
- BOLLUX_LOGLEVEL=DEBUG
fi