bopher-ng

A better Gopher client in pure Bash
git clone git://git.luxferre.top/bopher-ng.git
Log | Files | Refs | README | LICENSE

bopher-ng.sh (19348B)


      1 #!/bin/bash
      2 # BopherNG: a bit more advanced Gopher client in pure Bash
      3 # with a few external command dependencies (cat, stty, date)
      4 # Created by Luxferre in 2023, released into public domain
      5 #
      6 # Improvements over the original Bopher:
      7 # - mouse support
      8 # - smoother rendering and scrolling
      9 # - better edge-case stability
     10 # - better Gophermap processing according to the RFC1436
     11 # - an actual text reflow when viewing plaintext documents with no hard wrapping
     12 # - multi-level navigation history (although you can only go back)
     13 # - status bar with currently opened resource name
     14 # - ability to accept gopher:// URLs from the command line
     15 # - ability to save the URL to the currently viewed resource into the stash text file
     16 # - optional clipboard support when stashing (only if you set BOPHER_CLIP envvar)
     17 # - ability to force-download pages instead of viewing regardless of their type
     18 # - displaying type 8 entries (as per RFC1436) as the telnet:// URI scheme
     19 
     20 shopt -s extglob # enable extended pattern matching (just to be sure)
     21 
     22 # save the command line parameters
     23 
     24 START_HOST="$1"
     25 START_PORT="$2"
     26 START_SEL="$3"
     27 START_TYPE="$4"
     28 
     29 if [[ "$START_HOST" =~ ^gopher:// ]]; then # parse the URL
     30   preurl="${START_HOST#gopher://}" # remove the scheme to ease parsing
     31   hostport="${preurl%%/*}" # extract the host+:port part (where :port is also optional)
     32   selpath="${preurl##$hostport}" # extract the selector+path part
     33   START_HOST="${hostport%%:*}" # extract the hostname
     34   START_PORT="${hostport:(( 1 + ${#START_HOST} ))}" # extract the port
     35   START_TYPE="${selpath:1:2}" # extract the type character
     36   START_SEL="${selpath:2}" # extract the selector
     37 fi
     38 [[ -z "$START_HOST" ]] && echo "Usage: bopher-ng.sh host[ port][ selector][ type] OR bopher-ng.sh gopher://host[:port]/[type][selector]" && exit 1
     39 [[ -z "$START_PORT" ]] && START_PORT=70
     40 [[ -z "$START_SEL" ]] && START_SEL="/"
     41 [[ -z "$START_TYPE" ]] && START_TYPE=1 # default request type is a Gophermap
     42 
     43 # define external paths
     44 [[ -z "$BOPHER_LINKSTASH" ]] && BOPHER_LINKSTASH="$HOME/.bopher-links.txt" # file to stash links to
     45 [[ -z "$BOPHER_DLDIR" ]] && BOPHER_DLDIR="$PWD"                            # downloads directory
     46 
     47 # styling used in the TUI
     48 ESC=$'\x1b' # use the ANSI literal syntax to support in read prompts as well
     49 LF=$'\x0a' # let's do this
     50 ERESET="${ESC}[0m" # reset the styling
     51 ERRCOLOR="${ESC}[31;1m" # red bold
     52 LINKCOLOR="${ESC}[36;1m" # cyan bold
     53 FOCUSATTR="${ESC}[7;2m" # swap foreground and background and brightness level
     54 CLS="${ESC}[2J" # clear the entire screen
     55 CURRESET="${ESC}[0;0H" # reset the visual cursor
     56 CURSTART="${ESC}[1G" # move cursor to the start of the line
     57 LINECLR="${ESC}[2K" # clear current line
     58 BELOWCLR="${ESC}[0J" # clear everything below
     59 ALTBUFON="${ESC}[?47h" # use the alternative screen buffer
     60 ALTBUFOFF="${ESC}[?47l" # return to the default screen buffer
     61 
     62 # mouse event support switches
     63 MOUSE_ENABLED=1
     64 MOUSEON="${ESC}[?1000;1006h"  # term command to start sending mouse input events as control sequences (semi-long format, no drag)
     65 MOUSEOFF="${ESC}[?1000;1006l" # term command to stop sending mouse input events as control sequences (semi-long format, no drag)
     66 # in the semi-long format without drag, every mouse control sequence starts with "${ESC}[<" and then has three fields:
     67 # button_attr;xcoord;ycoord[Mm]
     68 
     69 clear_term() { # clear the terminal on Ctrl+C interrupt
     70   printf '%s' "$MOUSEOFF$ERESET$ALTBUFOFF"
     71   exit 0
     72 }
     73 trap clear_term INT
     74 
     75 # fetch any Gopher resource
     76 gophetch() { # args: host, port, selector[, input]
     77   exec 4<>/dev/tcp/$1/$2   # bind the descriptor 4 to a /dev/tcp pseudo-device
     78   if [[ -z "$4" ]]; then
     79     printf '%s\r\n' "$3" >&4 # send the selector string (printf is more reliable)
     80   else
     81     printf '%s\t%s\r\n' "$3" "$4" >&4 # send the selector + tab + input string
     82   fi
     83   cat <&4                  # fetch and output the result (we have to use cat as the result may be binary)
     84   exec 4<&-                # close the descriptor to make it reusable
     85 }
     86 
     87 # parse a single Gophermap line according to the current host and port
     88 # Return format: ACTION[tab]DESCRIPTION[tab]HOST[tab]PORT[tab]SELECTOR[tab]TYPE
     89 # where ACTION can be:
     90 # - E (echo the description - this line is non-navigable)
     91 # - P (print the file)
     92 # - D (download the file)
     93 # - M (render a Gophermap)
     94 # - I (ask for the user input and render a Gophermap)
     95 gmparse() { # args: line, curhost, curport 
     96   local line="$1"
     97   [[ ! "$line" == *[$'\t']* ]] && line="i$line" # treat non-standard plain tabless lines as information lines
     98   readarray -d $'\t' -t fields < <(printf '%s' "$line") 
     99   local rtype="${fields[0]:0:1}" # resource type
    100   local desc="${fields[0]:1}"    # resource description
    101   local sel="${fields[1]}"       # resource selector 
    102   local rhost="${fields[2]}"     # resource hostname
    103   local rport="${fields[3]}"     # resource port
    104   if [[ -z "$sel$rhost$rport" ]]; then # if all fields except description are empty
    105     sel="$desc" # then assume our selector is in the description field
    106   fi
    107   [[ -z "$rhost" ]] && rhost="$2" # fill in the missing hostname
    108   [[ -z "$rport" ]] && rport="$3" # fill in the missing port number
    109   local action='D' # all unknown types are set to download
    110   if [[ 'i' == "$rtype" || '3' == "$rtype" || 'h' == "$rtype" || '8' == "$rtype" ]]; then # handle information lines
    111     action='E'
    112     [[ '3' == "$rtype" ]] && desc="$ERRCOLOR$desc$ERESET" # wrap error messages in the coloration terminal commands
    113     # handle external URLs as information lines of a special kind
    114     [[ 'h' == "$rtype" ]] && desc="$desc: ${sel#URL:}"
    115     if [[ '8' == "$rtype" ]]; then # handle 8-type resources as telnet:// URIs
    116       local username="${sel}@"
    117       [[ -z "${sel}" ]] && username=''
    118       desc="$desc: telnet://${username}${rhost}:${rport}"
    119     fi
    120   fi
    121   [[ '0' == "$rtype" ]] && action='P' # plain text is plain text
    122   [[ '1' == "$rtype" ]] && action='M' # it's a Gophermap
    123   [[ '7' == "$rtype" ]] && action='I' # it's a Gophermap with user input (search or whatever)
    124   printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$action" "$desc" "$rhost" "$rport" "$sel" "$rtype" # output the final formatted line
    125 }
    126 
    127 phlow_lite() { # a single-parameter line reflow algorithm
    128   local line="$1"
    129   local TARGET_WIDTH="$2"
    130   local reflowfmt="%-${TARGET_WIDTH}s\n"
    131   local llen="${#line}" # get effective line length
    132   if (( 0 == TARGET_WIDTH || llen < TARGET_WIDTH )); then # no need to run the logic for smaller lines or if TARGET_WIDTH is 0
    133     printf "$reflowfmt" "$line"
    134     return
    135   fi
    136   local lastws=0 # variable to track last whitespace
    137   local cpos=0 # variable to track current position within the page line
    138   local pagepos=0 # variable to track the position of new line start
    139   local outbuf='' # temporary output buffer
    140   local c='' # temporary character buffer
    141   for ((i=0;i<llen;i++,cpos++)); do # start iterating over characters
    142     if (( cpos >= TARGET_WIDTH )); then # we already exceeded the page width
    143       (( lastws == 0 )) && lastws=$TARGET_WIDTH # no whitespace encountered here
    144       printf "$reflowfmt" "${outbuf:0:$lastws}" # truncate the buffer
    145       outbuf=''
    146       pagepos=$(( pagepos + lastws ))
    147       cpos=0
    148       lastws=0
    149       i=$pagepos # update current iteration index from the last valid whitespace
    150     else # save the whitespace position if found
    151       c="${line:i:1}" # get the current character
    152       [[ "$c" == $'\x20' ]] && lastws="$cpos"
    153       outbuf="${outbuf}${c}" # save the character itself
    154     fi
    155   done
    156   [[ ! -z "$outbuf" ]] && printf "$reflowfmt" "$outbuf" # output the last unprocessed chunk
    157 }
    158 
    159 # convert AM line back to gopher:// URL
    160 amtogopher() { # args: AM line
    161   readarray -d $'\t' -t fields < <(printf '%s' "$1")
    162   printf 'gopher://%s:%s/%s%s' "${fields[2]}" "${fields[3]}" "${fields[5]}" "${fields[4]}"
    163 }
    164 
    165 # AM entry clicker function
    166 amclick() { # args: AM line[, forcedl], output: AM line(s)
    167   readarray -d $'\t' -t fields < <(printf '%s' "$1")
    168   local action="${fields[0]}"
    169   local desc="${fields[1]}"
    170   local rhost="${fields[2]}"
    171   local rport="${fields[3]}"
    172   local sel="${fields[4]}"
    173   local input=''
    174   local puresel="${sel%%\%09*}" # extract the pure selector (w/o possible input)
    175   if [[ "$sel" != "$puresel" ]]; then # check selector for %09, if so, extract the input string
    176     input="${sel##$puresel\%09}"
    177   fi
    178   if [[ 'I' == "$action" && -z "$input" ]]; then
    179     read -p "${LINECLR}${CURSTART}Enter input for $rhost: " input
    180   fi
    181   [[ ! -z "$2" ]] && action='D' # force the download action
    182   if [[ 'D' == "$action" ]]; then # download
    183     local fname="${puresel##*/}" # pure-bash version of basename 
    184     [[ -z "$fname" ]] && fname='dl.dat' # just make sure that it's not empty
    185     [[ ! -z "$input" ]] && fname="${fname}%09${input}.search.txt"
    186     gophetch "$rhost" "$rport" "$puresel" "$input" > "$BOPHER_DLDIR/$fname"
    187     printf 'E\tDownloaded %s to %s\n' "$(amtogopher "$1")" "$BOPHER_DLDIR/$fname"
    188   elif [[ 'I' == "$action" || 'M' == "$action" ]]; then # Gophermaps must be processed with CRLF delimiter only
    189     readarray -t lines -d $'\r' < <(gophetch "$rhost" "$rport" "$puresel" "$input") # split on CR, not LF!
    190     for line in "${lines[@]}"; do # iterate over every fetched line
    191       line="${line##$'\n'}" # remove a starting LF if it's there 
    192       line="${line%%$'\r'}" # remove a trailing CR if it's there 
    193       [[ "$line" == '.' ]] && break # stop processing Gophermaps on .
    194       gmparse "$line" "$rhost" "$rport"
    195     done
    196   elif [[ 'P' == "$action" ]]; then # plain text content (can be delimited with both CRLF or LF)
    197     read -r TERMROWS TERMCOLS < <(stty size) # get current terminal size info
    198     readarray -t lines -d $'\n' < <(gophetch "$rhost" "$rport" "$puresel" "$input") # split on LF
    199     for line in "${lines[@]}"; do # iterate over every fetched line
    200       readarray -t reflow_lines -d $'\n' < <(phlow_lite "${line%%$'\r'}" "$TERMCOLS") # remove a trailing CR if it's there 
    201       for rline in "${reflow_lines[@]}"; do
    202         printf 'E\t%s\n' "$rline"
    203       done
    204     done
    205   fi
    206 }
    207 
    208 declare -a SCREENBUF # screen buffer array placeholder
    209 declare -a NAVIGABLES # navigable indexes array placeholder
    210 declare -a AMHISTORY # history array containing the AM lines
    211 CURRENTAM='' # the AM line corresponding to the currently loaded resource
    212 CURLINKINDEX=0 # this is an index in the NAVIGABLES array, not SCREENBUF
    213 FOCUSLINEINDEX=-1 # this is an index in the SCREENBUF array
    214 CURSCROLLPOS=0 # current scrolling position
    215 read -r TERMROWS TERMCOLS < <(stty size) # get current terminal size info
    216 
    217 # main content rendering method (offscreen rendering)
    218 amrender() {
    219   read -r TERMROWS TERMCOLS < <(stty size) # update current terminal size info
    220   local upperbound=$(( $CURSCROLLPOS + $TERMROWS - 1 )) # leaving out the last line for status
    221   local buflen=${#SCREENBUF[@]} # total length of the screen buffer
    222   (( $upperbound > $buflen )) && upperbound=$buflen
    223   local output="$CLS$CURRESET" # start output by clearing the screen and resetting the cursor
    224   for ((idx=CURSCROLLPOS;idx<upperbound;idx++)); do # only iterate over eligible buffer lines by index
    225     local amline="${SCREENBUF[$idx]}" # current AM line
    226     readarray -d $'\t' -t fields < <(printf '%s' "$amline")
    227     local action="${fields[0]}" # to display an AM entry, we only need two of its fields
    228     local desc="${fields[1]}"
    229     if [[ 'E' == "$action" ]]; then # plain text
    230       output="${output}${desc}${LF}"
    231     else # not plain text
    232       if [[ "$idx" == "$FOCUSLINEINDEX" ]]; then # if the current link is focused, then...
    233         output="${output}${FOCUSATTR}" # print the focus attribute (the link contains ERESET anyway)
    234       fi
    235       output="${output}${LINKCOLOR}=> ${desc}${ERESET}${LF}"
    236     fi
    237   done
    238   # final preparations:
    239   # - clear everything that remains and reset the cursor to the last line
    240   # - output current resource to the status line
    241   # - reset the cursor once again
    242   output="${output}$BELOWCLR${ESC}[$TERMROWS;0H$FOCUSATTR$(amtogopher "$CURRENTAM")$ERESET${ESC}[$TERMROWS;0H" 
    243   printf '%s' "$output" # finally, print the entire output with a single call
    244 }
    245 trap 'amrender' WINCH # rerender on terminal size change
    246 
    247 scroll() { # args: delta (usually 1 or -1)
    248   local buflen=${#SCREENBUF[@]} # total length of the screen buffer
    249   local newpos=$(( $CURSCROLLPOS + $1 ))  # add the delta
    250   local maxscroll=$(( $buflen - $TERMROWS + 1 ))
    251   (( $newpos >= $maxscroll )) && newpos=$maxscroll
    252   (( $newpos < 0 )) && newpos=0
    253   if (( $newpos != $CURSCROLLPOS )); then
    254     CURSCROLLPOS=$newpos
    255     (( $CURSCROLLPOS < 0 )) && CURSCROLLPOS=0
    256     amrender # rerender the contents only if the position really changed
    257   fi
    258 }
    259 
    260 jumplink() { # args: delta (usually 1 or -1)
    261   local navlen=${#NAVIGABLES[@]} # total length of the nav array
    262   if (( $navlen > 1 )); then # do something only if there is at least two navigables
    263     local newindex=$(( $CURLINKINDEX + $1 ))  # add the delta
    264     (( $newindex < 0 )) && newindex=$(( $navlen - 1 )) # wrap around at the beginning
    265     (( $newindex >= $navlen )) && newindex=0           # wrap around at the end
    266     CURLINKINDEX=$newindex # update the global navigable index
    267     FOCUSLINEINDEX=${NAVIGABLES[$CURLINKINDEX]}
    268     CURSCROLLPOS=$(( $FOCUSLINEINDEX - $(( $TERMROWS / 2 )) )) # attempt to center scrolling position at the currently focused link
    269     (( $CURSCROLLPOS < 0 )) && CURSCROLLPOS=0
    270     scroll 0 # auto-adjust scrolling if the link is at the end
    271     amrender # trigger the rendering on update
    272   fi
    273 }
    274 
    275 amload() { # args: AM line[, forcedl]
    276   CURRENTAM="$1" # update current AM value
    277   printf '%s' "${LINECLR}Loading..."
    278   readarray -t SCREENBUF < <(amclick "$CURRENTAM" "$2") # populate screen buffer by loading the resource from the AM entry
    279   NAVIGABLES=() # reset the navigable indexes
    280   CURLINKINDEX=0 # reset the link number
    281   CURSCROLLPOS=0 # reset the scrolling position
    282   for idx in "${!SCREENBUF[@]}"; do # iterate over every fetched line by index
    283     local amline="${SCREENBUF[$idx]}" # current AM line
    284     local action="${amline:0:1}" # action character
    285     if [[ ! 'E' == "$action" ]]; then # for all link-related entries...
    286       NAVIGABLES+=("$idx") # append this index as navigable
    287     fi
    288   done
    289   FOCUSLINEINDEX=${NAVIGABLES[0]} # set the focused line index to the first available one
    290   amrender # render the contents
    291 }
    292 
    293 clicklink() { # click the currently focused link
    294   if [[ ! -z "$FOCUSLINEINDEX" ]] && (( $FOCUSLINEINDEX > -1 )); then
    295     AMHISTORY+=("$CURRENTAM") # update the history chain
    296     amload "${SCREENBUF[$FOCUSLINEINDEX]}" "$1"
    297   fi
    298 }
    299 
    300 goback() { # remove the last AM line from history and go there
    301   local histlen=${#AMHISTORY[@]}
    302   if (( $histlen > 1 )); then # at least one entry must be present
    303     local lastidx=$(( $histlen - 1 ))
    304     local histline="${AMHISTORY[$lastidx]}"
    305     AMHISTORY=("${AMHISTORY[@]:0:$lastidx}")
    306     amload "$histline"
    307   fi
    308 }
    309 
    310 stashlink() { # stash the link to the currently viewed resource
    311   local gopherlink="$(amtogopher "$CURRENTAM")" 
    312   readarray -d $'\t' -t fields < <(printf '%s' "$CURRENTAM") # extract individual fields
    313   local desc="${fields[1]}"
    314   [[ -z "$desc" ]] && desc='Unnamed'
    315   printf '%s [%s] %s - %s\t%s\t%s\t%s\r\n' "${fields[5]}" "$(date -u '+%F %T')" "$desc" "$gopherlink" "${fields[4]}" "${fields[2]}" "${fields[3]}" >> $BOPHER_LINKSTASH
    316   printf 'Stashed %s - %s%s' "$desc" "$gopherlink" "$ERESET${ESC}[$TERMROWS;0H" # just reset the cursor to the last line
    317   [[ ! -z "$BOPHER_CLIP" ]] && (printf '%s' "$gopherlink" | $BOPHER_CLIP) # run an external clipboard command if it's explicitly specified
    318 }
    319 
    320 # Focus and click a link on a Gophermap
    321 mouseclick() { # args: x, y[, forcedl] (start with index 1)
    322   local curcol=$1
    323   local currow=$2
    324   local lineindex="$(( $currow + $CURSCROLLPOS - 1 ))" # determine the AM line index we're pointing to in the screen buffer
    325   local targetline="${SCREENBUF[$lineindex]}" # load the line we're testing
    326   local action="${targetline:0:1}" # get its action
    327   if [[ ! 'E' == "$action" ]]; then # only proceed if it's a link-related AM line
    328     readarray -d $'\t' -t fields < <(printf '%s' "$targetline")
    329     local desc="${fields[1]}"
    330     local dlen=$(( ${#desc} + 3 )) # consider the => link prefix
    331     if (( $curcol <= $dlen )); then # only load if the click was within the line
    332       FOCUSLINEINDEX=$lineindex
    333       amrender
    334       AMHISTORY+=("$CURRENTAM") # update the history chain
    335       amload "$targetline" "$3"
    336     fi
    337   fi
    338 }
    339 
    340 readmouseinput() { # correctly read the remaining mouse input in semi-long mode
    341   read -r -s -d ';' battr # the button attribute is before the first semicolon
    342   read -r -s -d ';' mx   # the X coordinate is between the semicolons
    343   local my=''  # allocate a buffer to fetch Y coordinate
    344   local c='' # cache character
    345   while [[ "$c" != 'm' && "$c" != 'M' ]]; do
    346     my="$my$c"
    347     read -s -n 1 c
    348   done # now, my contains our Y coordinate and c contains the press/release specifier
    349   printf '%d %d %d %c\n' "$battr" "$mx" "$my" "$c"
    350 }
    351 
    352 # entry point code starts here
    353 
    354 printf '%s' "$ALTBUFON$CLS" # enter ALTBUF mode and clear the screen
    355 
    356 # now, load the resource provided from the command line and start the main loop
    357 
    358 START_GMLINE="$(printf '%s\t%s\t%s\t%s' "$START_TYPE" "$START_SEL" "$START_HOST" "$START_PORT")"
    359 START_AM="$(gmparse "$START_GMLINE")"
    360 AMHISTORY+=("$START_AM") # start the history chain
    361 amload "$START_AM"
    362 
    363 cmdbuf='' # allocate key command buffer and mouse coordinate variables
    364 mouseX=''
    365 mouseY=''
    366 mouseStatus='M'  
    367 while true; do
    368   mousecmd='' # mouse command buffer
    369   [[ "$MOUSE_ENABLED" == '1' ]] && printf '%s' "$MOUSEON" # enable mouse events before reading
    370   read -n 1 -s cmdbuf # read the character, tildas will be ignored
    371   if [[ "$cmdbuf" == $'\x1b' ]]; then # here's the control character
    372     read -n 2 -s cmdbuf # read 2 additional characters
    373     if [[ "$cmdbuf" == '[<' ]]; then # we have a mouse event, read the button attribute and the coordinates
    374       read -r -s mousecmd mouseX mouseY mouseStatus < <(readmouseinput)
    375     fi
    376   fi
    377   [[ "$MOUSE_ENABLED" == '1' ]] && printf '%s' "$MOUSEOFF" # don't generate mouse events outside the input sequence
    378   [[ "$mousecmd" == '2' && "$mouseStatus" == 'M' ]] && cmdbuf='b' # right click is automatically assigned to back command
    379   [[ "$cmdbuf" == '[A' || "$cmdbuf" == 'k'  || "$mousecmd" == '64' ]] && scroll -1
    380   [[ "$cmdbuf" == '[B' || "$cmdbuf" == 'j' || "$mousecmd" == '65' ]] && scroll 1
    381   [[ "$cmdbuf" == '[5' || "$cmdbuf" == 'h' ]] && scroll $(( 1 - $TERMROWS )) # PgUp
    382   [[ "$cmdbuf" == '[6' || "$cmdbuf" == 'l' ]] && scroll $(( $TERMROWS - 1 )) # PgDn
    383   [[ "$cmdbuf" == 'r' ]] && amload "$CURRENTAM" # refresh the page without updating history
    384   [[ "$cmdbuf" == 's' ]] && jumplink 1
    385   [[ "$cmdbuf" == 'w' ]] && jumplink -1
    386   [[ "$cmdbuf" == 'q' ]] && printf '%s' "$ERESET$ALTBUFOFF" && exit 0
    387   [[ "$cmdbuf" == '' ]] && clicklink # space or enter or tab
    388   [[ "$cmdbuf" == 'd' ]] && clicklink 1 # same as click but with force-download flag
    389   [[ "$cmdbuf" == 'b' ]] && goback
    390   [[ "$cmdbuf" == 'S' ]] && stashlink
    391   [[ "$cmdbuf" == 'm' ]] && MOUSE_ENABLED=$(( 1 - MOUSE_ENABLED ))
    392   [[ "$MOUSE_ENABLED" == '1' && "$mousecmd" == '0' && "$mouseStatus" == 'M' ]] && mouseclick $mouseX $mouseY # we only process left click with coordinates here
    393   [[ "$MOUSE_ENABLED" == '1' && "$mousecmd" == '1' && "$mouseStatus" == 'M' ]] && mouseclick $mouseX $mouseY 1 # we only process middle click with coordinates here
    394 done