/ bollux
bollux
1 #!/usr/bin/env bash 2 # bollux: a bash gemini client 3 # Author: Case Duckworth 4 # License: MIT 5 # Version: 0.4.0 6 7 # Program information 8 PRGN="${0##*/}" 9 VRSN=0.4.0 10 11 bollux_usage() { 12 cat <<END 13 $PRGN (v. $VRSN): a bash gemini client 14 usage: 15 $PRGN [-h] 16 $PRGN [-q] [-v] [URL] 17 flags: 18 -h show this help and exit 19 -q be quiet: log no messages 20 -v verbose: log more messages 21 parameters: 22 URL the URL to start in 23 If not provided, the user will be prompted. 24 END 25 } 26 27 run() { # run COMMAND... 28 log debug "$*" 29 "$@" 30 } 31 32 die() { # die EXIT_CODE MESSAGE 33 local ec="$1" 34 shift 35 log error "$*" 36 exit "$ec" 37 } 38 39 # builtin replacement for `sleep` 40 # https://github.com/dylanaraps/pure-bash-bible#use-read-as-an-alternative-to-the-sleep-command 41 sleep() { # sleep SECONDS 42 read -rt "$1" <> <(:) || : 43 } 44 45 # https://github.com/dylanaraps/pure-bash-bible/ 46 trim_string() { # trim_string STRING 47 : "${1#"${1%%[![:space:]]*}"}" 48 : "${_%"${_##*[![:space:]]}"}" 49 printf '%s\n' "$_" 50 } 51 52 log() { # log LEVEL MESSAGE 53 [[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return 54 local fmt 55 56 case "$1" in 57 [dD]*) # debug 58 [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return 59 fmt=34 60 ;; 61 [eE]*) # error 62 fmt=31 63 ;; 64 *) fmt=1 ;; 65 esac 66 shift 67 68 printf >&2 '\e[%sm%s:%s:\e[0m\t%s\n' "$fmt" "$PRGN" "${FUNCNAME[1]}" "$*" 69 } 70 71 # main entry point 72 bollux() { 73 run bollux_config # TODO: figure out better config method 74 run bollux_args "$@" # and argument parsing 75 run bollux_init 76 77 if [[ ! "${BOLLUX_URL:+x}" ]]; then 78 run prompt GO BOLLUX_URL 79 fi 80 81 log d "BOLLUX_URL='$BOLLUX_URL'" 82 83 run blastoff -u "$BOLLUX_URL" 84 } 85 86 # process command-line arguments 87 bollux_args() { 88 while getopts :hvq OPT; do 89 case "$OPT" in 90 h) 91 bollux_usage 92 exit 93 ;; 94 v) BOLLUX_LOGLEVEL=DEBUG ;; 95 q) BOLLUX_LOGLEVEL=QUIET ;; 96 :) die 1 "Option -$OPTARG requires an argument" ;; 97 *) die 1 "Unknown option: -$OPTARG" ;; 98 esac 99 done 100 shift $((OPTIND - 1)) 101 if (($# == 1)); then 102 BOLLUX_URL="$1" 103 fi 104 } 105 106 # process config file and set variables 107 bollux_config() { 108 : "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/bollux.conf}" 109 110 if [ -f "$BOLLUX_CONFIG" ]; then 111 # shellcheck disable=1090 112 . "$BOLLUX_CONFIG" 113 else 114 log debug "Can't load config file '$BOLLUX_CONFIG'." 115 fi 116 117 ## behavior 118 : "${BOLLUX_TIMEOUT:=30}" # connection timeout 119 : "${BOLLUX_MAXREDIR:=5}" # max redirects 120 : "${BOLLUX_PORT:=1965}" # port number 121 : "${BOLLUX_PROTO:=gemini}" # default protocol 122 : "${BOLLUX_URL:=}" # start url 123 : "${BOLLUX_BYEMSG:=See You Space Cowboy ...}" # bye message 124 ## files 125 : "${BOLLUX_DATADIR:=${XDG_DATA_DIR:-$HOME/.local/share}/bollux}" 126 : "${BOLLUX_DOWNDIR:=.}" # where to save downloads 127 : "${BOLLUX_LESSKEY:=$BOLLUX_DATADIR/lesskey}" # where to store binds 128 : "${BOLLUX_PAGESRC:=$BOLLUX_DATADIR/pagesrc}" # where to save the source 129 BOLLUX_HISTFILE="$BOLLUX_DATADIR/history" # where to save the history 130 ## typesetting 131 : "${T_MARGIN:=4}" # left and right margin 132 : "${T_WIDTH:=0}" # width of the viewport -- 0 = get term width 133 # colors -- these will be wrapped in \e[ __ m 134 C_RESET='\e[0m' # reset 135 : "${C_SIGIL:=35}" # sigil (=>, #, ##, ###, *, ```) 136 : "${C_LINK_NUMBER:=1}" # link number 137 : "${C_LINK_TITLE:=4}" # link title 138 : "${C_LINK_URL:=36}" # link URL 139 : "${C_HEADER1:=1;4}" # header 1 formatting 140 : "${C_HEADER2:=1}" # header 2 formatting 141 : "${C_HEADER3:=3}" # header 3 formatting 142 : "${C_LIST:=0}" # list formatting 143 : "${C_QUOTE:=3}" # quote formatting 144 : "${C_PRE:=0}" # preformatted text formatting 145 ## state 146 UC_BLANK=':?:' 147 } 148 149 # quit happily 150 bollux_quit() { 151 printf '\e[1m%s\e[0m:\t\e[3m%s\e[0m\n' "$PRGN" "$BOLLUX_BYEMSG" 152 exit 153 } 154 155 # set the terminal title 156 set_title() { # set_title STRING 157 printf '\e]2;%s\007' "$*" 158 } 159 160 # prompt for input 161 prompt() { # prompt [-u] PROMPT [READ_ARGS...] 162 local read_cmd=(read -e -r) 163 if [[ "$1" == "-u" ]]; then 164 read_cmd+=(-i "$BOLLUX_URL") 165 shift 166 fi 167 local prompt="$1" 168 shift 169 read_cmd+=(-p "$prompt> ") 170 "${read_cmd[@]}" </dev/tty "$@" 171 } 172 173 # load a URL 174 blastoff() { # blastoff [-u] URL 175 local u 176 177 if [[ "$1" == "-u" ]]; then 178 u="$(run uwellform "$2")" 179 else 180 u="$1" 181 fi 182 183 local -a url 184 run utransform url "$BOLLUX_URL" "$u" 185 if ! ucdef url[1]; then 186 run ucset url[1] "$BOLLUX_PROTO" 187 fi 188 189 { 190 if declare -Fp "${url[1]}_request" >/dev/null 2>&1; then 191 run "${url[1]}_request" "$url" 192 else 193 die 99 "No request handler for '${url[1]}'" 194 fi 195 } | run normalize | { 196 if declare -Fp "${url[1]}_response" >/dev/null 2>&1; then 197 run "${url[1]}_response" "$url" 198 else 199 log d "No response handler for '${url[1]}', passing thru" 200 passthru 201 fi 202 } 203 } 204 205 # URLS 206 ## https://tools.ietf.org/html/rfc3986 207 uwellform() { 208 local u="$1" 209 210 if [[ "$u" != *://* ]]; then 211 u="$BOLLUX_PROTO://$u" 212 fi 213 214 u="$(trim_string "$u")" 215 216 printf '%s\n' "$u" 217 } 218 219 usplit() { # usplit NAME:ARRAY URL:STRING 220 local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?' 221 [[ $2 =~ $re ]] || return $? 222 223 local scheme="${BASH_REMATCH[2]}" 224 local authority="${BASH_REMATCH[4]}" 225 local path="${BASH_REMATCH[5]}" 226 local query="${BASH_REMATCH[7]}" 227 local fragment="${BASH_REMATCH[9]}" 228 229 # 0=url 1=scheme 2=authority 3=path 4=query 5=fragment 230 local i=1 c 231 for c in scheme authority path query fragment; do 232 if [[ "${!c}" || "$c" == path ]]; then 233 printf -v "$1[$i]" '%s' "${!c}" 234 else 235 printf -v "$1[$i]" "$UC_BLANK" 236 fi 237 ((i+=1)) 238 done 239 printf -v "$1[0]" "$(ujoin "$1")" # inefficient I'm sure 240 } 241 242 ujoin() { # ujoin NAME:ARRAY 243 local -n U="$1" 244 245 if ucdef U[1]; then 246 printf -v U[0] "%s:" "${U[1]}" 247 fi 248 249 if ucdef U[2]; then 250 printf -v U[0] "${U[0]}//%s" "${U[2]}" 251 fi 252 253 printf -v U[0] "${U[0]}%s" "${U[3]}" 254 255 if ucdef U[4]; then 256 printf -v U[0] "${U[0]}?%s" "${U[4]}" 257 fi 258 259 if ucdef U[5]; then 260 printf -v U[0] "${U[0]}#%s" "${U[5]}" 261 fi 262 263 log d "${U[0]}" 264 } 265 266 ucdef() { [[ "${!1}" != "$UC_BLANK" ]]; } # ucdef NAME 267 ucblank() { [[ -z "${!1}" ]]; } # ucblank NAME 268 ucset() { # ucset NAME VALUE 269 run eval "${1}='$2'" 270 run ujoin "${1/\[*\]}" 271 } 272 273 utransform() { # utransform TARGET:ARRAY BASE:STRING REFERENCE:STRING 274 local -a B R # base, reference 275 local -n T="$1" # target 276 usplit B "$2" 277 usplit R "$3" 278 279 # initialize T 280 for ((i=1;i<=5;i++)); do 281 T[$i]="$UC_BLANK" 282 done 283 284 # 0=url 1=scheme 2=authority 3=path 4=query 5=fragment 285 if ucdef R[1]; then 286 T[1]="${R[1]}" 287 if ucdef R[2]; then 288 T[2]="${R[2]}" 289 fi 290 if ucdef R[3]; then 291 T[3]="$(pundot "${R[3]}")" 292 fi 293 if ucdef R[4]; then 294 T[4]="${R[4]}" 295 fi 296 else 297 if ucdef R[2]; then 298 T[2]="${R[2]}" 299 if ucdef R[2]; then 300 T[3]="$(pundot "${R[3]}")" 301 fi 302 if ucdef R[4]; then 303 T[4]="${R[4]}" 304 fi 305 else 306 if ucblank R[3]; then 307 T[3]="${B[3]}" 308 if ucdef R[4]; then 309 T[4]="${R[4]}" 310 else 311 T[4]="${B[4]}" 312 fi 313 else 314 if [[ "${R[3]}" == /* ]]; then 315 T[3]="$(pundot "${R[3]}")" 316 else 317 T[3]="$(pmerge B R)" 318 T[3]="$(pundot "${T[3]}")" 319 fi 320 if ucdef R[4]; then 321 T[4]="${R[4]}" 322 fi 323 fi 324 T[2]="${B[2]}" 325 fi 326 T[1]="${B[1]}" 327 fi 328 if ucdef R[5]; then 329 T[5]="${R[5]}" 330 fi 331 332 ujoin T 333 } 334 335 pundot() { # pundot PATH:STRING 336 local input="$1" 337 local output 338 while [[ "$input" ]]; do 339 if [[ "$input" =~ ^\.\.?/ ]]; then 340 input="${input#${BASH_REMATCH[0]}}" 341 elif [[ "$input" =~ ^/\.(/|$) ]]; then 342 input="/${input#${BASH_REMATCH[0]}}" 343 elif [[ "$input" =~ ^/\.\.(/|$) ]]; then 344 input="/${input#${BASH_REMATCH[0]}}" 345 [[ "$output" =~ /?[^/]+$ ]] 346 output="${output%${BASH_REMATCH[0]}}" 347 elif [[ "$input" == . || "$input" == .. ]]; then 348 input= 349 else 350 [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || return 1 351 output="$output${BASH_REMATCH[1]}" 352 input="${BASH_REMATCH[2]}" 353 fi 354 done 355 printf '%s\n' "${output//\/\//\//}" 356 } 357 358 pmerge() { 359 local -n b="$1" 360 local -n r="$2" 361 362 if ucblank r[3]; then 363 printf '%s\n' "${b[3]//\/\//\//}" 364 return 365 fi 366 367 if ucdef b[2] && ucblank b[3]; then 368 printf '/%s\n' "${r[3]//\/\//\//}" 369 else 370 local bp="" 371 if [[ "${b[3]}" == */* ]]; then 372 bp="${b[3]%/*}" 373 fi 374 printf '%s/%s\n' "${bp%/}" "${r[3]#/}" 375 fi 376 } 377 378 # https://github.com/dylanaraps/pure-bash-bible/ 379 uencode() { # uencode URL:STRING 380 local LC_ALL=C 381 for ((i = 0; i < ${#1}; i++)); do 382 : "${1:i:1}" 383 case "$_" in 384 [a-zA-Z0-9.~_-]) 385 printf '%s' "$_" 386 ;; 387 *) 388 printf '%%%02X' "'$_" 389 ;; 390 esac 391 done 392 printf '\n' 393 } 394 395 # https://github.com/dylanaraps/pure-bash-bible/ 396 udecode() { # udecode URL:STRING 397 : "${1//+/ }" 398 printf '%b\n' "${_//%/\\x}" 399 } 400 401 # GEMINI 402 # https://gemini.circumlunar.space/docs/specification.html 403 gemini_request() { # gemini_request URL 404 local -a url 405 usplit url "$1" 406 407 # get rid of userinfo 408 ucset url[2] "${url[2]#*@}" 409 410 local port 411 if [[ "${url[2]}" == *:* ]]; then 412 port="${url[2]#*:}" 413 ucset url[2] "${url[2]%:*}" 414 else 415 port=1965 # TODO variablize 416 fi 417 418 local ssl_cmd=( 419 openssl s_client 420 -crlf -quiet -connect "${url[2]}:$port" 421 -servername "${url[2]}" # SNI 422 -no_ssl3 -no_tls1 -no_tls1_1 # disable old TLS/SSL versions 423 ) 424 425 run "${ssl_cmd[@]}" <<<"$url" 426 } 427 428 gemini_response() { # gemini_response URL 429 local url code meta 430 local title 431 url="$1" 432 433 # we need a loop here so it waits for the first line 434 while read -t "$BOLLUX_TIMEOUT" -r code meta || 435 { (($? > 128)) && die 99 "Timeout."; }; do 436 break 437 done 438 439 log d "[$code] $meta" 440 441 case "$code" in 442 1*) # input 443 REDIRECTS=0 444 BOLLUX_URL="$url" 445 case "$code" in 446 10) run prompt "$meta" ;; 447 11) run prompt "$meta" -s ;; # password input 448 esac 449 run blastoff "?$(uencode "$REPLY")" 450 ;; 451 2*) # OK 452 REDIRECTS=0 453 BOLLUX_URL="$url" 454 # read ahead to find a title 455 local pretitle 456 while read -r; do 457 pretitle="$pretitle$REPLY"$'\n' 458 if [[ "$REPLY" =~ ^#[[:space:]]*(.*) ]]; then 459 title="${BASH_REMATCH[1]}" 460 break 461 fi 462 done 463 run history_append "$url" "${title:-}" 464 # read the body out and pipe it to display 465 { 466 printf '%s' "$pretitle" 467 passthru 468 } | run display "$meta" "${title:-}" 469 ;; 470 3*) # redirect 471 ((REDIRECTS += 1)) 472 if ((REDIRECTS > BOLLUX_MAXREDIR)); then 473 die $((100 + code)) "Too many redirects!" 474 fi 475 BOLLUX_URL="$url" 476 run blastoff "$meta" # TODO: confirm redirect 477 ;; 478 4*) # temporary error 479 REDIRECTS=0 480 die "$((100 + code))" "Temporary error [$code]: $meta" 481 ;; 482 5*) # permanent error 483 REDIRECTS=0 484 die "$((100 + code))" "Permanent error [$code]: $meta" 485 ;; 486 6*) # certificate error 487 REDIRECTS=0 488 log d "Not implemented: Client certificates" 489 # TODO: recheck the speck 490 die "$((100 + code))" "[$code] $meta" 491 ;; 492 *) 493 [[ -z "${code-}" ]] && die 100 "Empty response code." 494 die "$((100 + code))" "Unknown response code: $code." 495 ;; 496 esac 497 } 498 499 # GOPHER 500 # https://tools.ietf.org/html/rfc1436 protocol 501 # https://tools.ietf.org/html/rfc4266 url 502 gopher_request() { # gopher_request URL 503 local url server port type path 504 url="$1" 505 port=70 506 507 # RFC 4266 508 [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]] 509 server="${BASH_REMATCH[1]}" 510 port="${BASH_REMATCH[3]:-70}" 511 type="${BASH_REMATCH[6]:-1}" 512 path="${BASH_REMATCH[7]}" 513 514 log d "URL='$url' SERVER='$server' TYPE='$type' PATH='$path'" 515 516 exec 9<>"/dev/tcp/$server/$port" 517 printf '%s\r\n' "$path" >&9 518 passthru <&9 519 } 520 521 gopher_response() { # gopher_response URL 522 local url pre type cur_server 523 pre=false 524 url="$1" 525 # RFC 4266 526 [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]] 527 cur_server="${BASH_REMATCH[1]}" 528 type="${BASH_REMATCH[6]:-1}" 529 530 run history_append "$url" "" # gopher doesn't really have titles, huh 531 532 log d "TYPE='$type'" 533 534 case "$type" in 535 0) # text 536 run display text/plain 537 ;; 538 1) # menu 539 run gopher_convert | run display text/gemini 540 ;; 541 3) # failure 542 die 203 "GOPHER: failed" 543 ;; 544 7) # search 545 if [[ "$url" =~ $'\t' ]]; then 546 run gopher_convert | run display text/gemini 547 else 548 run prompt 'SEARCH' 549 run blastoff "$url $REPLY" 550 fi 551 ;; 552 *) # something else 553 run download "$url" 554 ;; 555 esac 556 } 557 558 # 'cat' but in pure bash 559 passthru() { 560 while IFS= read -r; do 561 printf '%s\n' "$REPLY" 562 done 563 } 564 565 # convert gophermap to text/gemini (probably naive) 566 gopher_convert() { 567 local type label path server port regex 568 # cf. https://github.com/jamestomasino/dotfiles-minimal/blob/master/bin/gophermap2gemini.awk 569 while IFS= read -r; do 570 printf -v regex '(.)([^\t]*)(\t([^\t]*)\t([^\t]*)\t([^\t]*))?' 571 if [[ "$REPLY" =~ $regex ]]; then 572 type="${BASH_REMATCH[1]}" 573 label="${BASH_REMATCH[2]}" 574 path="${BASH_REMATCH[4]:-/}" 575 server="${BASH_REMATCH[5]:-$cur_server}" 576 port="${BASH_REMATCH[6]}" 577 else 578 log e "CAN'T PARSE LINE" 579 printf '%s\n' "$REPLY" 580 continue 581 fi 582 case "$type" in 583 .) # end of file 584 printf '.\n' 585 break 586 ;; 587 i) # label 588 case "$label" in 589 '#'* | '*'[[:space:]]*) 590 if $pre; then 591 printf '%s\n' '```' 592 pre=false 593 fi 594 ;; 595 *) 596 if ! $pre; then 597 printf '%s\n' '```' 598 pre=true 599 fi 600 ;; 601 esac 602 printf '%s\n' "$label" 603 ;; 604 h) # html link 605 if $pre; then 606 printf '%s\n' '```' 607 pre=false 608 fi 609 printf '=> %s %s\n' "${path:4}" "$label" 610 ;; 611 T) # telnet link 612 if $pre; then 613 printf '%s\n' '```' 614 pre=false 615 fi 616 printf '=> telnet://%s:%s/%s%s %s\n' \ 617 "$server" "$port" "$type" "$path" "$label" 618 ;; 619 *) # other type 620 if $pre; then 621 printf '%s\n' '```' 622 pre=false 623 fi 624 printf '=> gopher://%s:%s/%s%s %s\n' \ 625 "$server" "$port" "$type" "$path" "$label" 626 ;; 627 esac 628 done 629 if $pre; then 630 printf '%s\n' '```' 631 fi 632 # close the connection 633 exec 9<&- 634 exec 9>&- 635 } 636 637 # display the fetched content 638 display() { # display METADATA [TITLE] 639 local -a less_cmd 640 local i mime charset 641 # split header line 642 local -a hdr 643 IFS=';' read -ra hdr <<<"$1" 644 # title is optional but nice looking 645 local title 646 if (($# == 2)); then 647 title="$2" 648 fi 649 650 mime="$(trim_string "${hdr[0],,}")" 651 for ((i = 1; i <= "${#hdr[@]}"; i++)); do 652 h="${hdr[$i]}" 653 case "$h" in 654 *charset=*) charset="${h#*=}" ;; 655 esac 656 done 657 658 [[ -z "$mime" ]] && mime="text/gemini" 659 [[ -z "$charset" ]] && charset="utf-8" 660 661 log debug "mime='$mime'; charset='$charset'" 662 663 case "$mime" in 664 text/*) 665 set_title "$title${title:+ - }bollux" 666 less_cmd=(less -R) # render ANSI color escapes 667 mklesskey "$BOLLUX_LESSKEY" && less_cmd+=(-k "$BOLLUX_LESSKEY") 668 local helpline="o:open, g/G:goto, [:back, ]:forward, r:refresh" 669 less_cmd+=( 670 -Pm"$(less_prompt_escape "$BOLLUX_URL") - bollux$" # 'status'line 671 -P="$(less_prompt_escape "$helpline")$" # helpline 672 -m # start with statusline 673 +k # float content to the top 674 ) 675 676 local typeset 677 local submime="${mime#*/}" 678 if declare -Fp "typeset_$submime" &>/dev/null; then 679 typeset="typeset_$submime" 680 else 681 typeset="passthru" 682 fi 683 684 { 685 run iconv -f "${charset^^}" -t "UTF-8" | 686 run tee "$BOLLUX_PAGESRC" | 687 run "$typeset" | #cat 688 run "${less_cmd[@]}" && bollux_quit 689 } || run handle_keypress "$?" 690 ;; 691 *) run download "$BOLLUX_URL" ;; 692 esac 693 } 694 695 # escape strings for the less prompt 696 less_prompt_escape() { # less_prompt_escape STRING 697 local i 698 for ((i = 0; i < ${#1}; i++)); do 699 : "${1:i:1}" 700 case "$_" in 701 [\?:\.%\\]) printf '\%s' "$_" ;; 702 *) printf '%s' "$_" ;; 703 esac 704 done 705 printf '\n' 706 } 707 708 # generate a lesskey(1) file for custom keybinds 709 mklesskey() { # mklesskey FILENAME 710 lesskey -o "$1" - <<-END 711 #command 712 o quit 0 # 48 open a link 713 g quit 1 # 49 goto a url 714 [ quit 2 # 50 back 715 ] quit 3 # 51 forward 716 r quit 4 # 52 re-request / download 717 G quit 5 # 53 goto a url (pre-filled) 718 # other keybinds 719 \\40 forw-screen-force 720 h left-scroll 721 l right-scroll 722 ? status # 'status' will show a little help thing. 723 = noaction 724 END 725 } 726 727 # normalize files 728 normalize() { 729 shopt -s extglob 730 while IFS= read -r; do 731 # normalize line endings 732 printf '%s\n' "${REPLY//$'\r'?($'\n')/}" 733 done 734 shopt -u extglob 735 } 736 737 # typeset a text/gemini document 738 typeset_gemini() { 739 local pre=false 740 local ln=0 # link number 741 742 if ((T_WIDTH == 0)); then 743 shopt -s checkwinsize 744 ( 745 : 746 : 747 ) # dumb formatting brought to you by shfmt 748 log d "LINES=$LINES; COLUMNS=$COLUMNS" 749 T_WIDTH=$COLUMNS 750 fi 751 WIDTH=$((T_WIDTH - T_MARGIN)) 752 ((WIDTH < 0)) && WIDTH=80 # default if dumb 753 S_MARGIN=$((T_MARGIN - 1)) # spacing 754 755 log d "T_WIDTH=$T_WIDTH" 756 log d "WIDTH=$WIDTH" 757 758 while IFS= read -r; do 759 case "$REPLY" in 760 '```'*) 761 if $pre; then 762 pre=false 763 else 764 pre=true 765 fi 766 continue 767 ;; 768 '=>'*) 769 : $((ln += 1)) 770 gemini_link "$REPLY" $pre "$ln" 771 ;; 772 '#'*) gemini_header "$REPLY" $pre ;; 773 '*'[[:space:]]*) 774 gemini_list "$REPLY" $pre 775 ;; 776 '>'*) 777 gemini_quote "$REPLY" $pre 778 ;; 779 *) gemini_text "$REPLY" $pre ;; 780 esac 781 done 782 } 783 784 gemini_link() { 785 local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)" 786 local s t a # sigil, text, annotation(url) 787 local ln="$3" 788 if ! ${2-false} && [[ "$1" =~ $re ]]; then 789 s="${BASH_REMATCH[1]}" 790 a="${BASH_REMATCH[2]}" 791 t="${BASH_REMATCH[3]}" 792 if [[ -z "$t" ]]; then 793 t="$a" 794 a= 795 fi 796 797 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s" 798 printf "\e[${C_LINK_NUMBER}m[%d]${C_RESET} " "$ln" 799 fold_line -n -B "\e[${C_LINK_TITLE}m" -A "${C_RESET}" \ 800 -l "$((${#ln} + 3))" -m "${T_MARGIN}" \ 801 "$WIDTH" "$(trim_string "$t")" 802 fold_line -B " \e[${C_LINK_URL}m" -A "${C_RESET}" \ 803 -l "$((${#ln} + 3 + ${#t}))" -m "$((T_MARGIN + ${#ln} + 2))" \ 804 "$WIDTH" "$a" 805 else 806 gemini_pre "$1" 807 fi 808 } 809 810 gemini_header() { 811 local re="^(#+)[[:blank:]]*(.*)" 812 local s t a # sigil, text, annotation(lvl) 813 if ! ${2-false} && [[ "$1" =~ $re ]]; then 814 s="${BASH_REMATCH[1]}" 815 a="${#BASH_REMATCH[1]}" 816 t="${BASH_REMATCH[2]}" 817 local hdrfmt 818 hdrfmt="$(eval echo "\$C_HEADER$a")" 819 820 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s" 821 fold_line -B "\e[${hdrfmt}m" -A "${C_RESET}" -m "${T_MARGIN}" \ 822 "$WIDTH" "$t" 823 else 824 gemini_pre "$1" 825 fi 826 } 827 828 gemini_list() { 829 local re="^(\*)[[:blank:]]*(.*)" 830 local s t # sigil, text 831 if ! ${2-false} && [[ "$1" =~ $re ]]; then 832 s="${BASH_REMATCH[1]}" 833 t="${BASH_REMATCH[2]}" 834 835 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s" 836 fold_line -B "\e[${C_LIST}m" -A "${C_RESET}" -m "$T_MARGIN" \ 837 "$WIDTH" "$t" 838 else 839 gemini_pre "$1" 840 fi 841 } 842 843 gemini_quote() { 844 local re="^(>)[[:blank:]]*(.*)" 845 local s t # sigil, text 846 if ! ${2-false} && [[ "$1" =~ $re ]]; then 847 s="${BASH_REMATCH[1]}" 848 t="${BASH_REMATCH[2]}" 849 850 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s" 851 fold_line -B "\e[${C_QUOTE}m" -A "${C_RESET}" -m "$T_MARGIN" \ 852 "$WIDTH" "$t" 853 else 854 gemini_pre "$1" 855 fi 856 } 857 858 gemini_text() { 859 if ! ${2-false}; then 860 printf "%${S_MARGIN}s " ' ' 861 fold_line -m "$T_MARGIN" \ 862 "$WIDTH" "$1" 863 else 864 gemini_pre "$1" 865 fi 866 } 867 868 gemini_pre() { 869 printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```' 870 printf "\e[${C_PRE}m%s${C_RESET}\n" "$1" 871 } 872 873 # wrap lines on words to WIDTH 874 fold_line() { 875 # fold_line [-n] [-m MARGIN] [-f MARGIN] [-l LENGTH] [-B BEFORE] [-A AFTER] WIDTH TEXT 876 local newline=true 877 local -i margin_all=0 margin_first=0 width ll=0 wl=0 wn=0 878 local before="" after="" 879 OPTIND=0 880 while getopts nm:f:l:B:A: OPT; do 881 case "$OPT" in 882 n) # -n = no trailing newline 883 newline=false 884 ;; 885 m) # -m MARGIN = margin for all lines 886 margin_all="$OPTARG" 887 ;; 888 f) # -f MARGIN = margin for first line 889 margin_first="$OPTARG" 890 ;; 891 l) # -l LENGTH = length of line before starting fold 892 ll="$OPTARG" 893 ;; 894 B) # -B BEFORE = text to insert before each line 895 before="$OPTARG" 896 ;; 897 A) # -A AFTER = text to insert after each line 898 after="$OPTARG" 899 ;; 900 *) return 1 ;; 901 esac 902 done 903 shift "$((OPTIND - 1))" 904 width="$1" 905 ll=$((ll % width)) 906 #shellcheck disable=2086 907 set -- $2 908 909 local plain="" 910 if ((margin_first > 0 && ll == 0)); then 911 printf "%${margin_first}s" " " 912 fi 913 if [[ -n "$before" ]]; then 914 printf '%b' "$before" 915 fi 916 for word; do 917 ((wn += 1)) 918 shopt -s extglob 919 plain="${word//$'\x1b'\[*([0-9;])m/}" 920 shopt -u extglob 921 wl=$((${#plain} + 1)) 922 if (((ll + wl) >= width)); then 923 printf "${after:-}\n%${margin_all}s${before:-}" ' ' 924 ll=$wl 925 else 926 ((ll += wl)) 927 fi 928 printf '%s' "$word" 929 ((wn != $#)) && printf ' ' 930 done 931 [[ -n "$after" ]] && printf '%b' "$after" 932 $newline && printf '\n' 933 } 934 935 # use the exit code from less (see mklesskey) to do things 936 handle_keypress() { # handle_keypress CODE 937 case "$1" in 938 48) # o - open a link -- show a menu of links on the page 939 run select_url "$BOLLUX_PAGESRC" 940 ;; 941 49) # g - goto a url -- input a new url 942 prompt GO 943 run blastoff -u "$REPLY" 944 ;; 945 50) # [ - back in the history 946 run history_back || { 947 sleep 0.5 948 run blastoff "$BOLLUX_URL" 949 } 950 ;; 951 51) # ] - forward in the history 952 run history_forward || { 953 sleep 0.5 954 run blastoff "$BOLLUX_URL" 955 } 956 ;; 957 52) # r - re-request the current resource 958 run blastoff "$BOLLUX_URL" 959 ;; 960 53) # G - goto a url (pre-filled with current) 961 run prompt -u GO 962 run blastoff -u "$REPLY" 963 ;; 964 *) # 54-57 -- still available for binding 965 die "$?" "less(1) error" 966 ;; 967 esac 968 } 969 970 # select a URL from a text/gemini file 971 select_url() { # select_url FILE 972 run mapfile -t < <(extract_links <"$1") 973 if ((${#MAPFILE[@]} == 0)); then 974 log e "No links on this page!" 975 sleep 0.5 976 run blastoff "$BOLLUX_URL" 977 fi 978 PS3="OPEN> " 979 select u in "${MAPFILE[@]}"; do 980 case "$REPLY" in 981 q) bollux_quit ;; 982 [^0-9]*) run blastoff -u "$REPLY" && break ;; 983 esac 984 run blastoff "${u%%[[:space:]]*}" && break 985 done </dev/tty 986 } 987 988 # extract the links from a text/gemini file 989 extract_links() { 990 local url alt 991 while read -r; do 992 if [[ "$REPLY" =~ ^=\>[[:space:]]*([^[:space:]]+)([[:space:]]+(.*))?$ ]]; then 993 url="${BASH_REMATCH[1]}" 994 alt="${BASH_REMATCH[3]}" 995 996 if [[ "$alt" ]]; then 997 printf '%s \e[34m(%s)\e[0m\n' "$url" "$alt" 998 else 999 printf '%s\n' "$url" 1000 fi 1001 fi 1002 done 1003 } 1004 1005 # download $BOLLUX_URL 1006 download() { 1007 tn="$(mktemp)" 1008 log x "Downloading: '$BOLLUX_URL' => '$tn'..." 1009 dd status=progress >"$tn" 1010 fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}" 1011 if [[ -f "$fn" ]]; then 1012 log x "Saved '$tn'." 1013 elif mv "$tn" "$fn"; then 1014 log x "Saved '$fn'." 1015 else 1016 log error "Error saving '$fn': downloaded to '$tn'." 1017 fi 1018 } 1019 1020 # initialize bollux 1021 bollux_init() { 1022 # Trap cleanup 1023 trap bollux_cleanup INT QUIT EXIT 1024 # State 1025 REDIRECTS=0 1026 set -f 1027 # History 1028 declare -a HISTORY # history is kept in an array 1029 HN=0 # position of history in the array 1030 run mkdir -p "${BOLLUX_HISTFILE%/*}" 1031 } 1032 1033 # clean up on exit 1034 bollux_cleanup() { 1035 # Stubbed in case of need in future 1036 : 1037 } 1038 1039 # append a URL to history 1040 history_append() { # history_append URL TITLE 1041 BOLLUX_URL="$1" 1042 # date/time, url, title (best guess) 1043 run printf '%(%FT%T)T\t%s\t%s\n' -1 "$1" "$2" >>"$BOLLUX_HISTFILE" 1044 HISTORY[$HN]="$BOLLUX_URL" 1045 ((HN += 1)) 1046 } 1047 1048 # move back in history (session) 1049 history_back() { 1050 log d "HN=$HN" 1051 ((HN -= 2)) 1052 if ((HN < 0)); then 1053 HN=0 1054 log e "Beginning of history." 1055 return 1 1056 fi 1057 run blastoff "${HISTORY[$HN]}" 1058 } 1059 1060 # move forward in history (session) 1061 history_forward() { 1062 log d "HN=$HN" 1063 if ((HN >= ${#HISTORY[@]})); then 1064 HN="${#HISTORY[@]}" 1065 log e "End of history." 1066 return 1 1067 fi 1068 run blastoff "${HISTORY[$HN]}" 1069 } 1070 1071 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then 1072 run bollux "$@" 1073 fi