Files
bopher-ng/bopher-ng.sh
T

345 lines
16 KiB
Bash
Executable File

#!/bin/bash
# BopherNG: a bit more advanced Gopher client in pure Bash
# with a few external command dependencies (cat, stty, date)
# Created by Luxferre in 2023, released into public domain
#
# Improvements over the original Bopher:
# - mouse support
# - smoother rendering and scrolling
# - better edge-case stability
# - multi-level navigation history (although you can only go back)
# - status bar with currently opened resource name
# - ability to accept gopher:// URLs from the command line
# - ability to save the URL to the currently viewed resource into the stash text file
# - ability to force-download pages instead of viewing regardless of their type
# - displaying type 8 entries (as per RFC1436) as the telnet:// URI scheme
# save the command line parameters
START_HOST="$1"
START_PORT="$2"
START_SEL="$3"
START_TYPE="$4"
if [[ "$START_HOST" =~ ^gopher:// ]]; then # parse the URL
preurl="${START_HOST#gopher://}" # remove the scheme to ease parsing
hostport="${preurl%%/*}" # extract the host+:port part (where :port is also optional)
selpath="${preurl##$hostport}" # extract the selector+path part
START_HOST="${hostport%%:*}" # extract the hostname
START_PORT="${hostport:(( 1 + ${#START_HOST} ))}" # extract the port
START_TYPE="${selpath:1:2}" # extract the type character
START_SEL="${selpath:2}" # extract the selector
fi
[[ -z "$START_HOST" ]] && echo "Usage: bopher-ng.sh host[ port][ selector][ type] OR bopher-ng.sh gopher://host[:port]/[type][selector]" && exit 1
[[ -z "$START_PORT" ]] && START_PORT=70
[[ -z "$START_SEL" ]] && START_SEL="/"
[[ -z "$START_TYPE" ]] && START_TYPE=1 # default request type is a Gophermap
# define external paths
[[ -z "$BOPHER_LINKSTASH" ]] && BOPHER_LINKSTASH="$HOME/.bopher-links.txt" # file to stash links to
[[ -z "$BOPHER_DLDIR" ]] && BOPHER_DLDIR="$PWD" # downloads directory
# styling used in the TUI
ESC=$'\x1b' # use the ANSI literal syntax to support in read prompts as well
LF=$'\x0a' # let's do this
ERESET="${ESC}[0m" # reset the styling
ERRCOLOR="${ESC}[31;1m" # red bold
LINKCOLOR="${ESC}[36;1m" # cyan bold
FOCUSATTR="${ESC}[7;2m" # swap foreground and background and brightness level
CLS="${ESC}[2J" # clear the entire screen
CURRESET="${ESC}[0;0H" # reset the visual cursor
CURSTART="${ESC}[1G" # move cursor to the start of the line
LINECLR="${ESC}[2K" # clear current line
BELOWCLR="${ESC}[0J" # clear everything below
ALTBUFON="${ESC}[?47h" # use the alternative screen buffer
ALTBUFOFF="${ESC}[?47l" # return to the default screen buffer
# mouse event support switches
MOUSEON="${ESC}[?1000;1006h" # term command to start sending mouse input events as control sequences (semi-long format, no drag)
MOUSEOFF="${ESC}[?1000;1006l" # term command to stop sending mouse input events as control sequences (semi-long format, no drag)
# in the semi-long format without drag, every mouse control sequence starts with "${ESC}[<" and then has three fields:
# button_attr;xcoord;ycoord[Mm]
clear_term() { # clear the terminal on Ctrl+C interrupt
printf '%s' "$MOUSEOFF$ERESET$ALTBUFOFF"
exit 0
}
trap clear_term INT
# fetch any Gopher resource
gophetch() { # args: host, port, selector[, input]
exec 4<>/dev/tcp/$1/$2 # bind the descriptor 4 to a /dev/tcp pseudo-device
if [[ -z "$4" ]]; then
printf '%s\r\n' "$3" >&4 # send the selector string (printf is more reliable)
else
printf '%s\t%s\r\n' "$3" "$4" >&4 # send the selector + tab + input string
fi
cat <&4 # fetch and output the result (we have to use cat as the result may be binary)
exec 4<&- # close the descriptor to make it reusable
}
# parse a single Gophermap line according to the current host and port
# Return format: ACTION[tab]DESCRIPTION[tab]HOST[tab]PORT[tab]SELECTOR[tab]TYPE
# where ACTION can be:
# - E (echo the description - this line is non-navigable)
# - P (print the file)
# - D (download the file)
# - M (render a Gophermap)
# - I (ask for the user input and render a Gophermap)
gmparse() { # args: line, curhost, curport
local line="$1"
[[ ! "$line" == *[$'\t']* ]] && line="i$line" # treat non-standard plain tabless lines as information lines
readarray -d $'\t' -t fields < <(printf '%s' "${line%%$'\r'}") # also remove a trailing CR in case the response is CRLF
local rtype="${fields[0]:0:1}" # resource type
local desc="${fields[0]:1}" # resource description
local sel="${fields[1]}" # resource selector
local rhost="${fields[2]}" # resource hostname
local rport="${fields[3]}" # resource port
if [[ -z "$sel$rhost$rport" ]]; then # if all fields except description are empty
sel="$desc" # then assume our selector is in the description field
fi
[[ -z "$rhost" ]] && rhost="$2" # fill in the missing hostname
[[ -z "$rport" ]] && rport="$3" # fill in the missing port number
local action='D' # all unknown types are set to download
if [[ 'i' == "$rtype" || '3' == "$rtype" || 'h' == "$rtype" || '8' == "$rtype" ]]; then # handle information lines
action='E'
[[ '3' == "$rtype" ]] && desc="$ERRCOLOR$desc$ERESET" # wrap error messages in the coloration terminal commands
# handle external URLs as information lines of a special kind
[[ 'h' == "$rtype" ]] && desc="$desc: ${sel#URL:}"
if [[ '8' == "$rtype" ]]; then # handle 8-type resources as telnet:// URIs
local username="${sel}@"
[[ -z "${sel}" ]] && username=''
desc="$desc: telnet://${username}${rhost}:${rport}/"
fi
fi
[[ '0' == "$rtype" ]] && action='P' # plain text is plain text
[[ '1' == "$rtype" ]] && action='M' # it's a Gophermap
[[ '7' == "$rtype" ]] && action='I' # it's a Gophermap with user input (search or whatever)
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$action" "$desc" "$rhost" "$rport" "$sel" "$rtype" # output the final formatted line
}
# convert AM line back to gopher:// URL
amtogopher() { # args: AM line
readarray -d $'\t' -t fields < <(printf '%s' "$1")
printf 'gopher://%s:%s/%s%s' "${fields[2]}" "${fields[3]}" "${fields[5]}" "${fields[4]}"
}
# AM entry clicker function
amclick() { # args: AM line[, forcedl], output: AM line(s)
readarray -d $'\t' -t fields < <(printf '%s' "$1")
local action="${fields[0]}"
local desc="${fields[1]}"
local rhost="${fields[2]}"
local rport="${fields[3]}"
local sel="${fields[4]}"
local input=''
local puresel="${sel%%\%09*}" # extract the pure selector (w/o possible input)
if [[ "$sel" != "$puresel" ]]; then # check selector for %09, if so, extract the input string
input="${sel##$puresel\%09}"
fi
if [[ 'I' == "$action" && -z "$input" ]]; then
read -p "${LINECLR}${CURSTART}Enter input for $rhost: " input
fi
[[ ! -z "$2" ]] && action='D' # force the download action
if [[ 'D' == "$action" ]]; then # download
local fname="${puresel##*/}" # pure-bash version of basename
[[ -z "$fname" ]] && fname='dl.dat' # just make sure that it's not empty
[[ ! -z "$input" ]] && fname="${fname}%09${input}.search.txt"
gophetch "$rhost" "$rport" "$puresel" "$input" > "$BOPHER_DLDIR/$fname"
printf 'E\tDownloaded %s to %s\n' "$(amtogopher "$1")" "$BOPHER_DLDIR/$fname"
elif [[ 'I' == "$action" || 'M' == "$action" || 'P' == "$action" ]]; then # text content
readarray -t lines < <(gophetch "$rhost" "$rport" "$puresel" "$input")
for line in "${lines[@]}"; do # iterate over every fetched line
if [[ 'P' == "$action" ]]; then # generate a plaintext AM entry
printf 'E\t%s\n' "${line%%$'\r'}" # remove a trailing CR if it's there
else # generate a proper Gophermap AM entry
gmparse "$line" "$rhost" "$rport"
fi
done
fi
}
declare -a SCREENBUF # screen buffer array placeholder
declare -a NAVIGABLES # navigable indexes array placeholder
declare -a AMHISTORY # history array containing the AM lines
CURRENTAM='' # the AM line corresponding to the currently loaded resource
CURLINKINDEX=0 # this is an index in the NAVIGABLES array, not SCREENBUF
FOCUSLINEINDEX=-1 # this is an index in the SCREENBUF array
CURSCROLLPOS=0 # current scrolling position
read -r TERMROWS TERMCOLS < <(stty size) # get current terminal size info
# main content rendering method (offscreen rendering)
amrender() {
read -r TERMROWS TERMCOLS < <(stty size) # update current terminal size info
local upperbound=$(( $CURSCROLLPOS + $TERMROWS - 1 )) # leaving out the last line for status
local buflen=${#SCREENBUF[@]} # total length of the screen buffer
(( $upperbound > $buflen )) && upperbound=$buflen
local output="$CLS$CURRESET" # start output by clearing the screen and resetting the cursor
for ((idx=CURSCROLLPOS;idx<upperbound;idx++)); do # only iterate over eligible buffer lines by index
local amline="${SCREENBUF[$idx]}" # current AM line
readarray -d $'\t' -t fields < <(printf '%s' "$amline")
local action="${fields[0]}" # to display an AM entry, we only need two of its fields
local desc="${fields[1]}"
if [[ 'E' == "$action" ]]; then # plain text
output="${output}${desc}${LF}"
else # not plain text
if [[ "$idx" == "$FOCUSLINEINDEX" ]]; then # if the current link is focused, then...
output="${output}${FOCUSATTR}" # print the focus attribute (the link contains ERESET anyway)
fi
output="${output}${LINKCOLOR}=> ${desc}${ERESET}${LF}"
fi
done
# final preparations:
# - clear everything that remains and reset the cursor to the last line
# - output current resource to the status line
# - reset the cursor once again
output="${output}$BELOWCLR${ESC}[$TERMROWS;0H$FOCUSATTR$(amtogopher "$CURRENTAM")$ERESET${ESC}[$TERMROWS;0H"
printf '%s' "$output" # finally, print the entire output with a single call
}
trap 'amrender' WINCH # rerender on terminal size change
scroll() { # args: delta (usually 1 or -1)
local buflen=${#SCREENBUF[@]} # total length of the screen buffer
local newpos=$(( $CURSCROLLPOS + $1 )) # add the delta
local maxscroll=$(( $buflen - $TERMROWS + 1 ))
(( $newpos >= $maxscroll )) && newpos=$maxscroll
(( $newpos < 0 )) && newpos=0
if (( $newpos != $CURSCROLLPOS )); then
CURSCROLLPOS=$newpos
(( $CURSCROLLPOS < 0 )) && CURSCROLLPOS=0
amrender # rerender the contents only if the position really changed
fi
}
jumplink() { # args: delta (usually 1 or -1)
local navlen=${#NAVIGABLES[@]} # total length of the nav array
if (( $navlen > 1 )); then # do something only if there is at least two navigables
local newindex=$(( $CURLINKINDEX + $1 )) # add the delta
(( $newindex < 0 )) && newindex=$(( $navlen - 1 )) # wrap around at the beginning
(( $newindex >= $navlen )) && newindex=0 # wrap around at the end
CURLINKINDEX=$newindex # update the global navigable index
FOCUSLINEINDEX=${NAVIGABLES[$CURLINKINDEX]}
CURSCROLLPOS=$(( $FOCUSLINEINDEX - $(( $TERMROWS / 2 )) )) # attempt to center scrolling position at the currently focused link
(( $CURSCROLLPOS < 0 )) && CURSCROLLPOS=0
scroll 0 # auto-adjust scrolling if the link is at the end
amrender # trigger the rendering on update
fi
}
amload() { # args: AM line[, forcedl]
CURRENTAM="$1" # update current AM value
printf '%s' "${LINECLR}Loading..."
readarray -t SCREENBUF < <(amclick "$CURRENTAM" "$2") # populate screen buffer by loading the resource from the AM entry
NAVIGABLES=() # reset the navigable indexes
CURLINKINDEX=0 # reset the link number
CURSCROLLPOS=0 # reset the scrolling position
for idx in "${!SCREENBUF[@]}"; do # iterate over every fetched line by index
local amline="${SCREENBUF[$idx]}" # current AM line
local action="${amline:0:1}" # action character
if [[ ! 'E' == "$action" ]]; then # for all link-related entries...
NAVIGABLES+=("$idx") # append this index as navigable
fi
done
FOCUSLINEINDEX=${NAVIGABLES[0]} # set the focused line index to the first available one
amrender # render the contents
jumplink 0 # focus on the current link
}
clicklink() { # click the currently focused link
if [[ ! -z "$FOCUSLINEINDEX" ]] && (( $FOCUSLINEINDEX > -1 )); then
AMHISTORY+=("$CURRENTAM") # update the history chain
amload "${SCREENBUF[$FOCUSLINEINDEX]}" "$1"
fi
}
goback() { # remove the last AM line from history and go there
local histlen=${#AMHISTORY[@]}
if (( $histlen > 1 )); then # at least one entry must be present
local lastidx=$(( $histlen - 1 ))
local histline="${AMHISTORY[$lastidx]}"
AMHISTORY=("${AMHISTORY[@]:0:$lastidx}")
amload "$histline"
fi
}
stashlink() { # stash the link to the currently viewed resource
local gopherlink="$(amtogopher "$CURRENTAM")"
printf '[%s] %s\n' "$(date -u '+%F %T')" "$gopherlink" >> $BOPHER_LINKSTASH
printf 'Stashed %s%s' "$gopherlink" "$ERESET${ESC}[$TERMROWS;0H" # just reset the cursor to the last line
}
# Focus and click a link on a Gophermap
mouseclick() { # args: x, y[, forcedl] (start with index 1)
local curcol=$1
local currow=$2
local lineindex="$(( $currow + $CURSCROLLPOS - 1 ))" # determine the AM line index we're pointing to in the screen buffer
local targetline="${SCREENBUF[$lineindex]}" # load the line we're testing
local action="${targetline:0:1}" # get its action
if [[ ! 'E' == "$action" ]]; then # only proceed if it's a link-related AM line
readarray -d $'\t' -t fields < <(printf '%s' "$targetline")
local desc="${fields[1]}"
local dlen=$(( ${#desc} + 3 )) # consider the => link prefix
if (( $curcol <= $dlen )); then # only load if the click was within the line
FOCUSLINEINDEX=$lineindex
amrender
AMHISTORY+=("$CURRENTAM") # update the history chain
amload "$targetline" "$3"
fi
fi
}
readmouseinput() { # correctly read the remaining mouse input in semi-long mode
read -r -s -d ';' battr # the button attribute is before the first semicolon
read -r -s -d ';' mx # the X coordinate is between the semicolons
local my='' # allocate a buffer to fetch Y coordinate
local c='' # cache character
while [[ "$c" != 'm' && "$c" != 'M' ]]; do
my="$my$c"
read -s -n 1 c
done # now, my contains our Y coordinate and c contains the press/release specifier
printf '%d %d %d %c\n' "$battr" "$mx" "$my" "$c"
}
# entry point code starts here
printf '%s' "$ALTBUFON$CLS" # enter ALTBUF mode and clear the screen
# now, load the resource provided from the command line and start the main loop
START_GMLINE="$(printf '%s\t%s\t%s\t%s' "$START_TYPE" "$START_SEL" "$START_HOST" "$START_PORT")"
START_AM="$(gmparse "$START_GMLINE")"
AMHISTORY+=("$START_AM") # start the history chain
amload "$START_AM"
cmdbuf='' # allocate key command buffer and mouse coordinate variables
mouseX=''
mouseY=''
mouseStatus='M'
while true; do
mousecmd='' # mouse command buffer
printf '%s' "$MOUSEON" # enable mouse events before reading
read -n 1 -s cmdbuf # read the character, tildas will be ignored
if [[ "$cmdbuf" == $'\x1b' ]]; then # here's the control character
read -n 2 -s cmdbuf # read 2 additional characters
if [[ "$cmdbuf" == '[<' ]]; then # we have a mouse event, read the button attribute and the coordinates
read -r -s mousecmd mouseX mouseY mouseStatus < <(readmouseinput)
fi
fi
printf '%s' "$MOUSEOFF" # don't generate mouse events outside the input sequence
[[ "$mousecmd" == '2' && "$mouseStatus" == 'M' ]] && cmdbuf='b' # right click is automatically assigned to back command
[[ "$cmdbuf" == '[A' || "$cmdbuf" == 'k' || "$mousecmd" == '64' ]] && scroll -1
[[ "$cmdbuf" == '[B' || "$cmdbuf" == 'j' || "$mousecmd" == '65' ]] && scroll 1
[[ "$cmdbuf" == '[5' || "$cmdbuf" == 'h' ]] && scroll $(( 1 - $TERMROWS )) # PgUp
[[ "$cmdbuf" == '[6' || "$cmdbuf" == 'l' ]] && scroll $(( $TERMROWS - 1 )) # PgDn
[[ "$cmdbuf" == 's' ]] && jumplink 1
[[ "$cmdbuf" == 'w' ]] && jumplink -1
[[ "$cmdbuf" == 'q' ]] && printf '%s' "$ERESET$ALTBUFOFF" && exit 0
[[ "$cmdbuf" == '' ]] && clicklink # space or enter or tab
[[ "$cmdbuf" == 'd' ]] && clicklink 1 # same as click but with force-download flag
[[ "$cmdbuf" == 'b' ]] && goback
[[ "$cmdbuf" == 'S' ]] && stashlink
[[ "$mousecmd" == '0' && "$mouseStatus" == 'M' ]] && mouseclick $mouseX $mouseY # we only process left click with coordinates here
[[ "$mousecmd" == '1' && "$mouseStatus" == 'M' ]] && mouseclick $mouseX $mouseY 1 # we only process middle click with coordinates here
done