#!/usr/bin/env bash # bollux: a bash gemini client # Author: Case Duckworth # License: MIT # Version: 0.4.0 # Program information PRGN="${0##*/}" VRSN=0.4.0 bollux_usage() { cat < <(:) || : } # https://github.com/dylanaraps/pure-bash-bible/ trim_string() { # trim_string STRING : "${1#"${1%%[![:space:]]*}"}" : "${_%"${_##*[![:space:]]}"}" printf '%s\n' "$_" } # 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 [dD]*) # debug [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return fmt=34 ;; [eE]*) # error fmt=31 ;; *) fmt=1 ;; esac shift printf >&2 '\e[%sm%s:%s:\e[0m\t%s\n' "$fmt" "$PRGN" "${FUNCNAME[1]}" "$*" } # main entry point bollux() { run bollux_config # TODO: figure out better config method run bollux_args "$@" # and argument parsing run bollux_init if [[ ! "${BOLLUX_URL:+x}" ]]; then run prompt GO BOLLUX_URL fi 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 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 } # process config file and set variables 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_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_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 # 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_QUOTE:=3}" # quote formatting : "${C_PRE:=0}" # preformatted text formatting ## state UC_BLANK=':?:' } # quit happily bollux_quit() { 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 the terminal title set_title() { # set_title STRING printf '\e]2;%s\007' "$*" } # 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_cmd+=(-p "$prompt> ") "${read_cmd[@]}" /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 } } # URLS ## https://tools.ietf.org/html/rfc3986 uwellform() { local u="$1" if [[ "$u" != *://* ]]; then u="$BOLLUX_PROTO://$u" fi u="$(trim_string "$u")" printf '%s\n' "$u" } 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 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 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 ucblank R[3]; then T[3]="${B[3]}" if ucdef R[4]; then T[4]="${R[4]}" else T[4]="${B[4]}" fi else if [[ "${R[3]}" == /* ]]; then T[3]="$(pundot "${R[3]}")" else T[3]="$(pmerge B R)" T[3]="$(pundot "${T[3]}")" fi if ucdef R[4]; then T[4]="${R[4]}" fi fi T[2]="${B[2]}" fi T[1]="${B[1]}" fi if ucdef R[5]; then T[5]="${R[5]}" fi ujoin T } pundot() { # pundot PATH:STRING local input="$1" local output 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 =~ ^(/?[^/]*)(/?.*)$ ]] || return 1 output="$output${BASH_REMATCH[1]}" input="${BASH_REMATCH[2]}" fi done printf '%s\n' "${output//\/\//\//}" } 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 printf '\n' } # https://github.com/dylanaraps/pure-bash-bible/ udecode() { # udecode URL:STRING : "${1//+/ }" printf '%b\n' "${_//%/\\x}" } # GEMINI # https://gemini.circumlunar.space/docs/specification.html gemini_request() { # gemini_request URL local -a url usplit url "$1" # get rid of userinfo ucset url[2] "${url[2]#*@}" 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" } gemini_response() { # gemini_response URL local url code meta local title url="$1" # 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 d "[$code] $meta" case "$code" in 1*) # input REDIRECTS=0 BOLLUX_URL="$url" case "$code" in 10) run prompt "$meta" ;; 11) run prompt "$meta" -s ;; # password input esac run blastoff "?$(uencode "$REPLY")" ;; 2*) # OK REDIRECTS=0 BOLLUX_URL="$url" # read ahead to find a title local 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:-}" # read the body out and pipe it to display { printf '%s' "$pretitle" passthru } | run display "$meta" "${title:-}" ;; 3*) # redirect ((REDIRECTS += 1)) if ((REDIRECTS > BOLLUX_MAXREDIR)); then die $((100 + code)) "Too many redirects!" fi BOLLUX_URL="$url" run blastoff "$meta" # TODO: confirm redirect ;; 4*) # temporary error REDIRECTS=0 die "$((100 + code))" "Temporary error [$code]: $meta" ;; 5*) # permanent error REDIRECTS=0 die "$((100 + code))" "Permanent error [$code]: $meta" ;; 6*) # certificate error REDIRECTS=0 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." ;; esac } # 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 IFS=';' read -ra hdr <<<"$1" # title is optional but nice looking local title if (($# == 2)); then title="$2" fi mime="$(trim_string "${hdr[0],,}")" for ((i = 1; i <= "${#hdr[@]}"; i++)); do h="${hdr[$i]}" case "$h" in *charset=*) charset="${h#*=}" ;; esac done [[ -z "$mime" ]] && mime="text/gemini" [[ -z "$charset" ]] && charset="utf-8" log debug "mime='$mime'; charset='$charset'" case "$mime" in text/*) 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+=( # '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 ) local typeset local submime="${mime#*/}" if declare -Fp "typeset_$submime" &>/dev/null; then typeset="typeset_$submime" else 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 } # 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 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 if ((T_WIDTH == 0)); then shopt -s checkwinsize ( : : ) # dumb formatting brought to you by shfmt 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" 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 ;; '*'[[:space:]]*) gemini_list "$REPLY" $pre ;; '>'*) gemini_quote "$REPLY" $pre ;; *) gemini_text "$REPLY" $pre ;; esac done } gemini_link() { local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)" 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]}" t="${BASH_REMATCH[3]}" if [[ -z "$t" ]]; then t="$a" a= fi printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s" 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 # sigil, text, annotation(lvl) 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" 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 # 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_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 -m "$T_MARGIN" \ "$WIDTH" "$1" else gemini_pre "$1" fi } gemini_pre() { # 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 } # 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 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 "${after:-}\n%${margin_all}s${before:-}" ' ' ll=$wl else ((ll += wl)) fi printf '%s' "$word" ((wn != $#)) && printf ' ' done [[ -n "$after" ]] && printf '%b' "$after" $newline && printf '\n' } # 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 run blastoff -u "$REPLY" ;; 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) # 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 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 "${u%%[[:space:]]*}" && break done '$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 } # 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) run printf '%(%FT%T)T\t%s\t%s\n' -1 "$1" "$2" >>"$BOLLUX_HISTFILE" HISTORY[$HN]="$BOLLUX_URL" ((HN += 1)) } # move back in history (session) 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]}" } # move forward in history (session) 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 run bollux "$@" fi