#!/usr/bin/env bash
-# bollux: a bash gemini client or whatever
-# Author: Case Duckworth <acdw@acdw.net>
+# bollux: a bash gemini client
+# Author: Case Duckworth
# License: MIT
-# Version: -0.7
+# Version: 0.2.2
-# set -euo pipefail # strict mode
+# Program information
+PRGN="${0##*/}"
+VRSN=0.2.2
+# State
+REDIRECTS=0
+set -f
-### constants ###
-PRGN="${0##*/}" # program name
-DLDR="${BOLLUX_DOWNDIR:=.}" # where to download
-LOGL="${BOLLUX_LOGLEVEL:=3}" # log level
-MAXR="${BOLLUX_MAXREDIR:=5}" # max redirects
-PORT="${BOLLUX_PORT:=1965}" # port number
-PROT="${BOLLUX_PROTO:=gemini}" # protocol
-RDRS=0 # redirects
-VRSN=-0.7 # version number
-
-# shellcheck disable=2120
bollux_usage() {
- cat <<END_USAGE >&2
- $PRGN ($VRSN): a bash gemini client
- usage:
- $PRGN [-h]
- $PRGN [-L LVL] [URL]
- options:
- -h show this help
- -L LVL set the loglevel to LVL.
- Default: $BOLLUX_LOGLEVEL
- The loglevel is between 0 and 5, with
- lower levels being more dire.
- parameters:
- URL the URL to navigate view or download
-END_USAGE
- exit "${1:-0}"
-}
-
-# LOGLEVELS:
-# 0 - application fatal error
-# 1 - application warning
-# 2 - response error
-# 3 - response logging
-# 4 - application logging
-# 5 - diagnostic
-
-### utility functions ###
-# a better echo
-put() { printf '%s\n' "$*"; }
-
-# conditionally log events to stderr
-# lower = more important
-log() { # log [LEVEL] [<] MESSAGE
+ 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"
+}
+
+# pure bash bible trim_string
+trim() {
+ : "${1#"${1%%[![:space:]]*}"}"
+ : "${_%"${_##*[![:space:]]}"}"
+ printf '%s\n' "$_"
+}
+
+log() {
+ [[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
case "$1" in
- -)
- lvl="-1"
- shift
+ d* | D*) # debug
+ [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
+ fmt=34
;;
- [0-5])
- lvl="$1"
- shift
+ e* | E*) # error
+ fmt=31
;;
- *) lvl=4 ;;
+ *) fmt=1 ;;
esac
+ shift
+ printf >&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*"
+}
- output="$*"
- if ((lvl < LOGL)); then
- if (($# == 0)); then
- while IFS= read -r line; do
- output="$output${output:+$'\n'}$line"
- done
- fi
- printf '\e[34m%s\e[0m:\t%s\n' "$PRGN" "$output" >&2
+# main entry point
+bollux() {
+ run bollux_config
+ run bollux_args "$@"
+ run history_init
+
+ if [[ ! "${BOLLUX_URL:+isset}" ]]; then
+ run prompt GO BOLLUX_URL
fi
+
+ run blastoff "$BOLLUX_URL"
}
-# halt and catch fire
-die() { # die [EXIT-CODE] MESSAGE
- case "$1" in
- [0-9]*)
- ec="$1"
- shift
- ;;
- *) ec=1 ;;
- esac
+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
+}
- log 0 "$*"
- exit "$ec"
+bollux_config() {
+ : "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/bollux.conf}"
+
+ if [ -f "$BOLLUX_CONFIG" ]; then
+ # shellcheck disable=1090
+ . "$BOLLUX_CONFIG"
+ else
+ log debug "Can't load config file '$BOLLUX_CONFIG'."
+ fi
+
+ ## behavior
+ : "${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
+ ## files
+ : "${BOLLUX_DATADIR:=${XDG_DATA_DIR:-$HOME/.local/share}/bollux}"
+ : "${BOLLUX_DOWNDIR:=.}" # where to save downloads
+ : "${BOLLUX_LESSKEY:=$BOLLUX_DATADIR/lesskey}" # where to store binds
+ : "${BOLLUX_PAGESRC:=$BOLLUX_DATADIR/pagesrc}" # where to save the source
+ BOLLUX_HISTFILE="$BOLLUX_DATADIR/history" # where to save the history
+ ## typesetting
+ : "${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_LINK_NUMBER:=1}" # link number
+ : "${C_LINK_TITLE:=4}" # link title
+ : "${C_LINK_URL:=36}" # link URL
+ : "${C_HEADER1:=1;4}" # header 1 formatting
+ : "${C_HEADER2:=1}" # header 2 formatting
+ : "${C_HEADER3:=3}" # header 3 formatting
+ : "${C_LIST:=0}" # list formatting
+ : "${C_PRE:=0}" # preformatted text formatting
+}
+
+bollux_quit() {
+ log x "$BOLLUX_BYEMSG"
+ exit
}
-# ask the user for input
-ask() { # ask PROMPT [READ_OPT...]
+set_title() {
+ printf '\e]2;%s - bollux\007' "$*"
+}
+
+prompt() {
prompt="$1"
shift
read </dev/tty -e -r -p "$prompt> " "$@"
}
-# fail if something isn't installed
-require() { hash "$1" 2>/dev/null || die 127 "Requirement '$1' not found."; }
+blastoff() { # load a url
+ local well_formed=true
+ if [[ "$1" == "-u" ]]; then
+ well_formed=false
+ shift
+ fi
+ URL="$1"
-# trim a string
-trim() { sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'; }
+ if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
+ URL="$(run transform_resource "$BOLLUX_URL" "$1")"
+ fi
+ [[ "$URL" != *://* ]] && URL="$BOLLUX_PROTO://$URL"
+ URL="$(trim "$URL")"
-# stubs for when things aren't implemented (fully)
-NOT_IMPLEMENTED() { die 200 "NOT IMPLEMENTED!!!"; }
-NOT_FULLY_IMPLEMENTED() { log 1 "NOT FULLY IMPLEMENTED!!!"; }
+ server="${URL#*://}"
+ server="${server%%/*}"
-### gemini ###
-# url functions
-# normalize a path from /../ /./ /
-normalize_path() { # normalize_path <<< PATH
- gawk '{
- if ($0 == "" || $0 ~ /^\/\/[^\/]/) {
- return -1
- }
- split($0, path, /\//)
- for (c in path) {
- if (path[c] == "" || path[c] == ".") {
- continue
- }
- if (path[c] == "..") {
- sub(/[^\/]+$/, "", ret)
- continue
- }
- if (! ret || match(ret, /\/$/)) {
- slash = ""
- } else {
- slash = "/"
- }
- ret = ret slash path[c]
- }
- print ret
- }'
-}
-
-# split a url into the URL array
-split_url() {
- gawk '{
- if (match($0, /^[A-Za-z]+:/)) {
- arr["scheme"] = substr($0, RSTART, RLENGTH)
- $0 = substr($0, RLENGTH + 1)
- }
- if (match($0, /^\/\/[^\/?#]+?/) || (match($0, /^[^\/?#]+?/) && scheme)) {
- arr["authority"] = substr($0, RSTART, RLENGTH)
- $0 = substr($0, RLENGTH + 1)
- }
- if (match($0, /^\/?[^?#]+/)) {
- arr["path"] = substr($0, RSTART, RLENGTH)
- $0 = substr($0, RLENGTH + 1)
- }
- if (match($0, /^\?[^#]+/)) {
- arr["query"] = substr($0, RSTART, RLENGTH)
- $0 = substr($0, RLENGTH + 1)
- }
- if (match($0, /^#.*/)) {
- arr["fragment"] = substr($0, RSTART, RLENGTH)
- $0 = substr($0, RLENGTH + 1)
- }
- for (part in arr) {
- printf "URL[\"%s\"]=\"%s\"\n", part, arr[part]
- }
- }'
-}
-
-# example.com => gemini://example.com/
-_address() { # _address URL
- addr="$1"
-
- [[ "$addr" != *://* ]] && addr="$PROT://$addr"
- trim <<<"$addr"
-}
-
-# return only the server part from an address, with the port added
-# gemini://example.com/path/to/file => example.com:1965
-_server() {
- serv="$(_address "$1")" # normalize first
- serv="${serv#*://}"
- serv="${serv%%/*}"
- if [[ "$serv" != *:* ]]; then
- serv="$serv:$PORT"
+ log d "URL='$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 "$(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]}"
fi
- trim <<<"$serv"
+
+ # 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]}"
+ 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]}:"
+ # 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"
}
-# request a gemini page
-# by default, extract the server from the url
-request() { # request [-s SERVER] URL
- case "$1" in
- -s)
- serv="$(_server "$2")"
- addr="$(_address "$3")"
- ;;
- *)
- serv="$(_server "$1")"
- addr="$(_address "$1")"
- ;;
- esac
+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
- log 5 "serv: $serv"
- log 5 "addr: $addr"
+ 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
+}
- sslcmd=(openssl s_client -crlf -ign_eof -quiet -connect "$serv")
- # use SNI
- sslcmd+=(-servername "${serv%:*}")
- log "${sslcmd[@]}"
- "${sslcmd[@]}" <<<"$addr" 2>/dev/null
+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 =~ ^(/?[^/]*)(/?.*)$ ]] || log debug NOMATCH
+ output="$output${BASH_REMATCH[1]}"
+ input="${BASH_REMATCH[2]}"
+ fi
+ done
+ printf '%s\n' "${output//\/\//\//}"
}
-# handle the response
-# cf. gemini://gemini.circumlunar.space/docs/spec-spec.txt
-handle() { # handle URL < RESPONSE
- URL="$1"
- while read -d $'\r' -r head; do
- break # wait to read the first line
+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}"
+ done
+ # unclear if the path is always set even if empty but it looks that way
+ run 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"
+
+ # support for TLS v1.3 and v1.2
+ ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
+ ssl_cmd+=(-servername "$server") # SNI
+ ssl_cmd_tls1_2=("${ssl_cmd[@]}" -tls1_2)
+ ssl_cmd_tls1_3=("${ssl_cmd[@]}" -tls1_3)
+
+ # always try to connect with TLS v1.3 first
+ run "${ssl_cmd_tls1_3[@]}" <<<"$url" 2>/dev/null || run "${ssl_cmd_tls1_2[@]}" <<<"$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
- code="$(gawk '{print $1}' <<<"$head")"
- meta="$(gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$head")"
- log 5 "[$code] $meta"
+ log x "[$code] $meta"
case "$code" in
- 1*) # INPUT
- log 3 "Input"
- RDRS=0 # this is not a redirect
- ask "$meta" QUERY
- bollux "$URL?$QUERY"
+ 1*)
+ REDIRECTS=0
+ run history_append "$URL" "$meta"
+ run prompt "$meta" QUERY
+ # shellcheck disable=2153
+ run blastoff "?$QUERY"
;;
- 2*) # SUCCESS
- log 3 "Success"
- RDRS=0 # this is not a redirect
- case "$code" in
- 20) log 5 "- OK" ;;
- 21) log 5 "- End of client certificate session" ;;
- *) log 2 "- Unknown response code: '$code'." ;;
- esac
- display "$meta"
+ 2*)
+ REDIRECTS=0
+ # read ahead to find a title
+ pretitle=
+ while read -r; do
+ pretitle="$pretitle$REPLY"$'\n'
+ if [[ "$REPLY" =~ ^#[[:space:]]*(.*) ]]; then
+ title="${BASH_REMATCH[1]}"
+ break
+ fi
+ done
+ run history_append "$URL" "${title:-}"
+ {
+ printf '%s' "$pretitle"
+ while read -r; do
+ printf '%s\n' "$REPLY"
+ done
+ } | run display "$meta"
;;
- 3*) # REDIRECT
- log 3 "Redirecting"
- case "$code" in
- 30) log 5 "- Temporary" ;;
- 31) log 5 "- Permanent" ;;
- *) log 2 "- Unknown response code: '$code'." ;;
- esac
- ((RDRS += 1))
- ((RDRS > MAXR)) && die "$code" "Too many redirects!"
- bollux "$meta"
+ 3*)
+ ((REDIRECTS += 1))
+ if ((REDIRECTS > BOLLUX_MAXREDIR)); then
+ die $((100 + code)) "Too many redirects!"
+ fi
+ run blastoff "$meta"
;;
- 4*) # TEMPORARY FAILURE
- log 2 "Temporary failure"
- RDRS=0 # this is not a redirect
- case "$code" in
- 41) log 5 "- Server unavailable" ;;
- 42) log 5 "- CGI error" ;;
- 43) log 5 "- Proxy error" ;;
- 44) log 5 "- Rate limited" ;;
- *) log 2 "- Unknown response code: '$code'." ;;
- esac
- exit "$code"
+ 4*)
+ REDIRECTS=0
+ die "$((100 + code))" "$code"
;;
- 5*) # PERMANENT FAILURE
- log 2 "Permanent failure"
- RDRS=0 # this is not a redirect
- case "$code" in
- 51) log 5 "- Not found" ;;
- 52) log 5 "- No longer available" ;;
- 53) log 5 "- Proxy request refused" ;;
- 59) log 5 "- Bad request" ;;
- *) log 2 "- Unknown response code: '$code'." ;;
- esac
- exit "$code"
+ 5*)
+ REDIRECTS=0
+ die "$((100 + code))" "$code"
;;
- 6*) # CLIENT CERT REQUIRED
- log 2 "Client certificate required"
- RDRS=0 # this is not a redirect
- case "$code" in
- 61) log 5 "- Transient cert requested" ;;
- 62) log 5 "- Authorized cert required" ;;
- 63) log 5 "- Cert not accepted" ;;
- 64) log 5 "- Future cert rejected" ;;
- 65) log 5 "- Expired cert rejected" ;;
- *) log 2 "- Unknown response code: '$code'." ;;
- esac
- exit "$code"
+ 6*)
+ REDIRECTS=0
+ die "$((100 + code))" "$code"
;;
- *) # ???
- die "$code" "Unknown response code: '$code'."
+ *)
+ [[ -z "${code-}" ]] && die 100 "Empty response code."
+ die "$((100 + code)) Unknown response code: $code."
;;
esac
}
-# display the page
-display() { # display MIMETYPE < DOCUMENT
- mimetype="$1"
- case "$mimetype" in
+display() {
+ # split header line
+ local -a hdr
+ local i
+ IFS=$'\n' read -d "" -ra hdr <<<"${1//;/$'\n'}"
+
+ mime="$(trim "${hdr[0],,}")"
+ for ((i = 1; i <= "${#hdr[@]}"; i++)); do
+ h="$(trim "${hdr[$i]}")"
+ case "$h" in
+ charset=*) charset="${h#charset=}" ;;
+ esac
+ done
+
+ [[ -z "$mime" ]] && mime="text/gemini"
+ if [[ -z "$charset" ]]; then
+ charset="utf-8"
+ fi
+
+ log debug "mime='$mime'; charset='$charset'"
+
+ case "$mime" in
text/*)
- # normalize line endings to "\n"
- # gawk 'BEGIN{RS=""}{gsub(/\r\n?/,"\n");print}'
- cat
- # TODO: use less with linking and stuff
- # less -R -p'^=>' +g
- # lesskey:
- # l /=>\n # highlight links
- # o pipe \n open_url # open the link on the top line
- # u shell select_url % # shows a selection prompt for all urls (on screen? file?)
- # Q exit 1 # for one of these, show a selection prompt for urls
- # q exit 0 # for the other, just quit
- ###
- # also look into the prompt, the filename, and input preprocessor
- # ($LESSOPEN, $LESSCLOSE)
- ;;
- *)
- download "$URL"
+ set_title "$BOLLUX_URL"
+ 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 |
+ iconv -f "${charset^^}" -t "UTF-8" |
+ tee "$BOLLUX_PAGESRC" |
+ run "typeset_$submime" |
+ run "${less_cmd[@]}" && bollux_quit
+ } || run handle_keypress "$?"
+ else
+ log "cat"
+ {
+ normalize_crlf |
+ iconv -f "${charset^^}" -t "UTF-8" |
+ tee "$BOLLUX_PAGESRC" |
+ run "${less_cmd[@]}" && bollux_quit
+ } || run handle_keypress "$?"
+ fi
;;
+ *) run download "$BOLLUX_URL" ;;
esac
}
-download() { # download URL < FILE
- tn="$(mktemp)"
- dd status=progress >"$tn"
- fn="$DLDR/${URL##*/}"
- if [[ -f "$fn" ]]; then
- log - "Saved '$tn'."
- else
- if mv "$tn" "$fn"; then
- log - "Saved '$fn'."
- else
- log 0 "Error saving '$fn'."
- log - "Saved '$tn'."
+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
+ # other keybinds
+ \40 forw-screen-force
+ END
+}
+
+normalize_crlf() {
+ shopt -s extglob
+ while IFS= read -r; do
+ printf '%s\n' "${REPLY//$'\r'?($'\n')/}"
+ done
+ shopt -u extglob
+}
+
+typeset_gemini() {
+ local pre=false
+ local ln=0 # link number
+
+ if ((T_WIDTH == 0)); then
+ shopt -s checkwinsize
+ (
+ :
+ :
+ ) # XXX this doesn't work!?
+ log d "LINES=$LINES; COLUMNS=$COLUMNS"
+ T_WIDTH=$COLUMNS
+ fi
+ WIDTH=$((T_WIDTH - T_MARGIN))
+ ((WIDTH < 0)) && WIDTH=80 # default if dumb
+ S_MARGIN=$((T_MARGIN - 1)) # spacing
+
+ log d "T_WIDTH=$T_WIDTH"
+ log d "WIDTH=$WIDTH"
+
+ while IFS= read -r; do
+ case "$REPLY" in
+ '```'*)
+ if $pre; then
+ pre=false
+ else
+ pre=true
+ fi
+ 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_text "$REPLY" $pre ;;
+ esac
+ done
+}
+
+gemini_link() {
+ local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)"
+ local s t a l # sigil, text, annotation(url), line
+ if ! ${2-false} && [[ "$1" =~ $re ]]; then
+ s="${BASH_REMATCH[1]}"
+ a="${BASH_REMATCH[2]}"
+ t="${BASH_REMATCH[3]}"
+ if [[ -z "$t" ]]; then
+ t="$a"
+ a=
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"
+ else
+ gemini_pre "$1"
fi
}
-### main entry point ###
-bollux() {
- OPTIND=0
- process_cmdline "$@"
- shift $((OPTIND - 1))
+gemini_header() {
+ local re="^(#+)[[:blank:]]*(.*)"
+ local s t a l # sigil, text, annotation(lvl), line
+ if ! ${2-false} && [[ "$1" =~ $re ]]; then
+ s="${BASH_REMATCH[1]}"
+ a="${#BASH_REMATCH[1]}"
+ t="${BASH_REMATCH[2]}"
+ local hdrfmt
+ 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"
+ else
+ gemini_pre "$1"
+ fi
+}
- if (($# == 1)); then
- URL="$1"
+gemini_list() {
+ local re="^(\*)[[:blank:]]*(.*)"
+ local s t a l # sigil, text, annotation(n/a), line
+ 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"
+ else
+ gemini_pre "$1"
+ fi
+}
+
+gemini_text() {
+ if ! ${2-false}; then
+ printf "%${S_MARGIN}s " ' '
+ fold_line "$WIDTH" "$1"
else
- ask GO URL
+ gemini_pre "$1"
fi
+}
- log 5 "URL : $URL"
+gemini_pre() {
+ printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```'
+ printf "\e[${C_PRE}m%s${C_RESET}\n" "$1"
+}
- request "$URL" | handle "$URL"
+fold_line() { # fold_line WIDTH TEXT
+ local width="$1"
+ local margin="${2%%[![:space:]]*}"
+ if [[ "$margin" ]]; then
+ margin="${#margin}"
+ else
+ margin="$T_MARGIN"
+ fi
+ local ll=0 wl plain
+ # shellcheck disable=2086
+ set -- $2 # TODO: is this the best way?
+
+ for word; do
+ plain="${word//$'\x1b'\[*([0-9;])m/}"
+ wl=$((${#plain} + 1))
+ if (((ll + wl) >= width)); then
+ printf "\n%${margin}s" ' '
+ ll=$wl
+ else
+ ll=$((ll + wl))
+ fi
+ printf '%s ' "$word"
+ done
+ printf '\n'
}
-bollux_setup() {
- mkfifo .resource
- trap bollux_cleanup INT QUIT TERM EXIT
+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 || {
+ sleep 0.5
+ run blastoff "$BOLLUX_URL"
+ }
+ ;;
+ 51) # ] - forward in the history
+ run history_forward || {
+ sleep 0.5
+ run blastoff "$BOLLUX_URL"
+ }
+ ;;
+ 52) # r - re-request the current resource
+ run blastoff "$BOLLUX_URL"
+ ;;
+ *) # 53-57 -- still available for binding
+ ;;
+ esac
}
-bollux_cleanup() {
- echo
- rm -f .resource
+select_url() {
+ run mapfile -t < <(extract_links <"$1")
+ select u in "${MAPFILE[@]}"; do
+ case "$REPLY" in
+ q) bollux_quit ;;
+ esac
+ run blastoff "$(gawk '{print $1}' <<<"$u")" && break
+ done </dev/tty
+}
+
+extract_links() {
+ local url alt
+ while read -r line; do
+ if [[ "$line" =~ ^=\>[[:space:]]*([^[:space:]]+)([[:space:]]+(.*))?$ ]]; then
+ url="${BASH_REMATCH[1]}"
+ alt="${BASH_REMATCH[3]}"
+
+ if [[ "$alt" ]]; then
+ printf '%s \e[34m(%s)\e[0m\n' "$url" "$alt"
+ else
+ printf '%s\n' "$url"
+ fi
+ fi
+ done
+}
+
+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_init() {
+ declare -a HISTORY # history is kept in an array
+ HN=0 # position of history in the array
+ run mkdir -p "${BOLLUX_HISTFILE%/*}"
+}
+
+history_append() { # history_append URL TITLE
+ BOLLUX_URL="$1"
+ # date/time, url, title (best guess)
+ run printf '%(%FT%T)T\t%s\t%s\n' -1 "$1" "$2" >>"$BOLLUX_HISTFILE"
+ HISTORY[$HN]="$BOLLUX_URL"
+ ((HN += 1))
+}
+
+history_back() {
+ log d "HN=$HN"
+ ((HN -= 2))
+ if ((HN < 0)); then
+ HN=0
+ log e "Beginning of history."
+ return 1
+ fi
+ run blastoff "${HISTORY[$HN]}"
+}
+history_forward() {
+ log d "HN=$HN"
+ if ((HN >= ${#HISTORY[@]})); then
+ HN="${#HISTORY[@]}"
+ log e "End of history."
+ return 1
+ fi
+ run blastoff "${HISTORY[$HN]}"
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
- set -euo pipefail # strict mode
- # requirements here -- so they're only checked once
- require gawk
- require dd
- require mv
- require openssl
- require sed
-
- bollux "$@"
- echo
+ run bollux "$@"
+else
+ BOLLUX_LOGLEVEL=DEBUG
fi