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