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