Update documentation
[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() {
28         log debug "$@"
29         "$@"
30 }
31
32 die() {
33         local ec="$1"
34         shift
35         log error "$*"
36         exit "$ec"
37 }
38
39 # pure bash bible trim_string
40 trim() {
41         : "${1#"${1%%[![:space:]]*}"}"
42         : "${_%"${_##*[![:space:]]}"}"
43         printf '%s\n' "$_"
44 }
45
46 log() {
47         [[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
48         local fmt
49
50         case "$1" in
51         [dD]*) # debug
52                 [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
53                 fmt=34
54                 ;;
55         [eE]*) # error
56                 fmt=31
57                 ;;
58         *) fmt=1 ;;
59         esac
60         shift
61
62         printf >&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*"
63 }
64
65 # main entry point
66 bollux() {
67         run bollux_config    # TODO: figure out better config method
68         run bollux_args "$@" # and argument parsing
69         run bollux_init
70
71         if [[ ! "${BOLLUX_URL:+x}" ]]; then
72                 run prompt GO BOLLUX_URL
73         fi
74
75         run blastoff "$BOLLUX_URL"
76 }
77
78 bollux_args() {
79         while getopts :hvq OPT; do
80                 case "$OPT" in
81                 h)
82                         bollux_usage
83                         exit
84                         ;;
85                 v) BOLLUX_LOGLEVEL=DEBUG ;;
86                 q) BOLLUX_LOGLEVEL=QUIET ;;
87                 :) die 1 "Option -$OPTARG requires an argument" ;;
88                 *) die 1 "Unknown option: -$OPTARG" ;;
89                 esac
90         done
91         shift $((OPTIND - 1))
92         if (($# == 1)); then
93                 BOLLUX_URL="$1"
94         fi
95 }
96
97 bollux_config() {
98         : "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/bollux.conf}"
99
100         if [ -f "$BOLLUX_CONFIG" ]; then
101                 # shellcheck disable=1090
102                 . "$BOLLUX_CONFIG"
103         else
104                 log debug "Can't load config file '$BOLLUX_CONFIG'."
105         fi
106
107         ## behavior
108         : "${BOLLUX_TIMEOUT:=30}"                     # connection timeout
109         : "${BOLLUX_MAXREDIR:=5}"                     # max redirects
110         : "${BOLLUX_PORT:=1965}"                      # port number
111         : "${BOLLUX_PROTO:=gemini}"                   # default protocol
112         : "${BOLLUX_URL:=}"                           # start url
113         : "${BOLLUX_BYEMSG:=See You Space Cowboy...}" # bye message
114         ## files
115         : "${BOLLUX_DATADIR:=${XDG_DATA_DIR:-$HOME/.local/share}/bollux}"
116         : "${BOLLUX_DOWNDIR:=.}"                       # where to save downloads
117         : "${BOLLUX_LESSKEY:=$BOLLUX_DATADIR/lesskey}" # where to store binds
118         : "${BOLLUX_PAGESRC:=$BOLLUX_DATADIR/pagesrc}" # where to save the source
119         BOLLUX_HISTFILE="$BOLLUX_DATADIR/history"      # where to save the history
120         ## typesetting
121         : "${T_MARGIN:=4}"      # left and right margin
122         : "${T_WIDTH:=0}"       # width of the viewport -- 0 = get term width
123         # colors -- these will be wrapped in \e[ __ m
124         C_RESET='\e[0m'         # reset
125         : "${C_SIGIL:=35}"      # sigil (=>, #, ##, ###, *, ```)
126         : "${C_LINK_NUMBER:=1}" # link number
127         : "${C_LINK_TITLE:=4}"  # link title
128         : "${C_LINK_URL:=36}"   # link URL
129         : "${C_HEADER1:=1;4}"   # header 1 formatting
130         : "${C_HEADER2:=1}"     # header 2 formatting
131         : "${C_HEADER3:=3}"     # header 3 formatting
132         : "${C_LIST:=0}"        # list formatting
133         : "${C_PRE:=0}"         # preformatted text formatting
134 }
135
136 bollux_quit() {
137         log x "$BOLLUX_BYEMSG"
138         exit
139 }
140
141 set_title() {
142         printf '\e]2;%s - bollux\007' "$*"
143 }
144
145 prompt() { # prompt [-u] PROMPT [READ_ARGS...]
146         local read_cmd=(read -e -r)
147         if [[ "$1" == "-u" ]]; then
148                 read_cmd+=(-i "$BOLLUX_URL")
149                 shift
150         fi
151         local prompt="$1"
152         shift
153         read_cmd+=(-p "$prompt> ")
154         "${read_cmd[@]}" </dev/tty "$@"
155 }
156
157 blastoff() { # load a url
158         local well_formed=true
159         local proto url
160         if [[ "$1" == "-u" ]]; then
161                 well_formed=false
162                 shift
163         fi
164         url="$1"
165
166         if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
167                 url="$(run transform_resource "$BOLLUX_URL" "$1")"
168         fi
169         [[ "$url" != *://* ]] && url="$BOLLUX_PROTO://$url"
170         url="$(trim "$url")"
171         proto="${url%://*}"
172
173         log d "PROTO='$proto' URL='$url'"
174
175         {
176                 if declare -Fp "${proto}_request" >/dev/null; then
177                         run "${proto}_request" "$url"
178                 else
179                         log d "No request handler for '$proto'; trying gemini"
180                         run gemini_request "$url"
181                 fi
182         } | run normalize |
183                 {
184                         if declare -Fp "${proto}_response" >/dev/null; then
185                                 run "${proto}_response" "$url"
186                         else
187                                 log d "No response handler for '$proto'; handling raw response"
188                                 raw_response
189                         fi
190                 }
191 }
192
193 transform_resource() { # transform_resource BASE_URL REFERENCE_URL
194         local -A R B T # reference, base url, target
195         eval "$(run parse_url B "$1")"
196         eval "$(run parse_url R "$2")"
197         # A non-strict parser may ignore a scheme in the reference
198         # if it is identical to the base URI's scheme.
199         if ! "${STRICT:-true}" && [[ "${R[scheme]}" == "${B[scheme]}" ]]; then
200                 unset "${R[scheme]}"
201         fi
202
203         # basically pseudo-code from spec ported to bash
204         if isdefined "R[scheme]"; then
205                 T[scheme]="${R[scheme]}"
206                 isdefined "R[authority]" && T[authority]="${R[authority]}"
207                 isdefined R[path] &&
208                         T[path]="$(run remove_dot_segments "${R[path]}")"
209                 isdefined "R[query]" && T[query]="${R[query]}"
210         else
211                 if isdefined "R[authority]"; then
212                         T[authority]="${R[authority]}"
213                         isdefined "R[authority]" &&
214                                 T[path]="$(remove_dot_segments "${R[path]}")"
215                         isdefined R[query] && T[query]="${R[query]}"
216                 else
217                         if isempty "R[path]"; then
218                                 T[path]="${B[path]}"
219                                 if isdefined R[query]; then
220                                         T[query]="${R[query]}"
221                                 else
222                                         T[query]="${B[query]}"
223                                 fi
224                         else
225                                 if [[ "${R[path]}" == /* ]]; then
226                                         T[path]="$(remove_dot_segments "${R[path]}")"
227                                 else
228                                         T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")"
229                                         T[path]="$(remove_dot_segments "${T[path]}")"
230                                 fi
231                                 isdefined R[query] && T[query]="${R[query]}"
232                         fi
233                         T[authority]="${B[authority]}"
234                 fi
235                 T[scheme]="${B[scheme]}"
236         fi
237         isdefined R[fragment] && T[fragment]="${R[fragment]}"
238         # cf. 5.3 -- recomposition
239         local r
240         isdefined "T[scheme]" && r="$r${T[scheme]}:"
241         # remove the port from the authority
242         isdefined "T[authority]" && r="$r//${T[authority]%:*}"
243         r="$r${T[path]}"
244         isdefined T[query] && r="$r?${T[query]}"
245         isdefined T[fragment] && r="$r#${T[fragment]}"
246         printf '%s\n' "$r"
247 }
248
249 merge_paths() { # 5.2.3
250         # shellcheck disable=2034
251         local B_authority="$1"
252         local B_path="$2"
253         local R_path="$3"
254         # if R_path is empty, get rid of // in B_path
255         if [[ -z "$R_path" ]]; then
256                 printf '%s\n' "${B_path//\/\//\//}"
257                 return
258         fi
259
260         if isdefined "B_authority" && isempty "B_path"; then
261                 printf '/%s\n' "${R_path//\/\//\//}"
262         else
263                 if [[ "$B_path" == */* ]]; then
264                         B_path="${B_path%/*}/"
265                 else
266                         B_path=""
267                 fi
268                 printf '%s/%s\n' "${B_path%/}" "${R_path#/}"
269         fi
270 }
271
272 remove_dot_segments() { # 5.2.4
273         local input="$1"
274         local output
275         while [[ "$input" ]]; do
276                 if [[ "$input" =~ ^\.\.?/ ]]; then
277                         input="${input#${BASH_REMATCH[0]}}"
278                 elif [[ "$input" =~ ^/\.(/|$) ]]; then
279                         input="/${input#${BASH_REMATCH[0]}}"
280                 elif [[ "$input" =~ ^/\.\.(/|$) ]]; then
281                         input="/${input#${BASH_REMATCH[0]}}"
282                         [[ "$output" =~ /?[^/]+$ ]]
283                         output="${output%${BASH_REMATCH[0]}}"
284                 elif [[ "$input" == . || "$input" == .. ]]; then
285                         input=
286                 else
287                         [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || log debug NOMATCH
288                         output="$output${BASH_REMATCH[1]}"
289                         input="${BASH_REMATCH[2]}"
290                 fi
291         done
292         printf '%s\n' "${output//\/\//\//}"
293 }
294
295 parse_url() { # eval "$(split_url NAME STRING)" => NAME[...]
296         local name="$1"
297         local string="$2"
298         # shopt -u extglob # TODO port re ^ to extglob syntax
299         local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
300         [[ $string =~ $re ]] || return $?
301         # shopt -s extglob
302
303         local scheme="${BASH_REMATCH[2]}"
304         local authority="${BASH_REMATCH[4]}"
305         local path="${BASH_REMATCH[5]}"
306         local query="${BASH_REMATCH[7]}"
307         local fragment="${BASH_REMATCH[9]}"
308
309         for c in scheme authority query fragment; do
310                 [[ "${!c}" ]] &&
311                         run printf '%s[%s]=%q\n' "$name" "$c" "${!c}"
312         done
313         # unclear if the path is always set even if empty but it looks that way
314         run printf '%s[path]=%q\n' "$name" "$path"
315 }
316
317 # is a NAME defined ('set' in bash)?
318 isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME
319 # is a NAME defined AND empty?
320 isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME
321 # split a string -- see pure bash bible
322 split() { # split STRING DELIMITER
323         local -a arr
324         IFS=$'\n' read -d "" -ra arr <<<"${1//$2/$'\n'}"
325         printf '%s\n' "${arr[@]}"
326 }
327
328 # GEMINI
329 # https://gemini.circumlunar.space/docs/spec-spec.txt
330 gemini_request() {
331         local url port server
332         local ssl_cmd
333         url="$1"
334         port=1965
335         server="${url#*://}"
336         server="${server%%/*}"
337
338         ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
339         # disable old TLS/SSL versions
340         ssl_cmd+=(-no_ssl3 -no_tls1 -no_tls1_1)
341
342         run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null
343 }
344
345 gemini_response() {
346         local url code meta
347         local title
348         url="$1"
349
350         # we need a loop here so it waits for the first line
351         while read -t "$BOLLUX_TIMEOUT" -r code meta ||
352                 { (($? > 128)) && die 99 "Timeout."; }; do
353                 break
354         done
355
356         log d "[$code] $meta"
357
358         case "$code" in
359         1*) # input
360                 REDIRECTS=0
361                 run prompt "$meta"
362                 run blastoff "?$REPLY"
363                 ;;
364         2*) # OK
365                 REDIRECTS=0
366                 # read ahead to find a title
367                 local pretitle
368                 while read -r; do
369                         pretitle="$pretitle$REPLY"$'\n'
370                         if [[ "$REPLY" =~ ^#[[:space:]]*(.*) ]]; then
371                                 title="${BASH_REMATCH[1]}"
372                                 break
373                         fi
374                 done
375                 run history_append "$url" "${title:-}"
376                 # read the body out and pipe it to display
377                 {
378                         printf '%s' "$pretitle"
379                         passthru
380                 } | run display "$meta" "${title:-}"
381                 ;;
382         3*) # redirect
383                 ((REDIRECTS += 1))
384                 if ((REDIRECTS > BOLLUX_MAXREDIR)); then
385                         die $((100 + code)) "Too many redirects!"
386                 fi
387                 run blastoff "$meta" # TODO: confirm redirect
388                 ;;
389         4*) # temporary error
390                 REDIRECTS=0
391                 die "$((100 + code))" "Temporary error [$code]: $meta"
392                 ;;
393         5*) # permanent error
394                 REDIRECTS=0
395                 die "$((100 + code))" "Permanent error [$code]: $meta"
396                 ;;
397         6*) # certificate error
398                 REDIRECTS=0
399                 log d "Not implemented: Client certificates"
400                 # TODO: recheck the speck
401                 die "$((100 + code))" "[$code] $meta"
402                 ;;
403         *)
404                 [[ -z "${code-}" ]] && die 100 "Empty response code."
405                 die "$((100 + code))" "Unknown response code: $code."
406                 ;;
407         esac
408 }
409
410 # GOPHER
411 # https://tools.ietf.org/html/rfc1436 protocol
412 # https://tools.ietf.org/html/rfc4266 url
413 gopher_request() {
414         local url server port type path
415         url="$1"
416         port=70
417
418         # RFC 4266
419         [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
420         server="${BASH_REMATCH[1]}"
421         port="${BASH_REMATCH[3]:-70}"
422         type="${BASH_REMATCH[6]:-1}"
423         path="${BASH_REMATCH[7]}"
424
425         log d "URL='$url' SERVER='$server' TYPE='$type' PATH='$path'"
426
427         exec 9<>"/dev/tcp/$server/$port"
428         printf '%s\r\n' "$path" >&9
429         passthru <&9
430 }
431
432 gopher_response() {
433         local url pre type cur_server
434         pre=false
435         url="$1"
436         # RFC 4266
437         [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
438         cur_server="${BASH_REMATCH[1]}"
439         type="${BASH_REMATCH[6]:-1}"
440
441         run history_append "$url" "" # TODO: get the title ??
442
443         log d "TYPE='$type'"
444
445         case "$type" in
446         0) # text
447                 run display text/plain
448                 ;;
449         1) # menu
450                 run gopher_convert | run display text/gemini
451                 ;;
452         3) # failure
453                 die 203 "GOPHER: failed"
454                 ;;
455         7) # search
456                 if [[ "$url" =~ $'\t' ]]; then
457                         run gopher_convert | run display text/gemini
458                 else
459                         run prompt 'SEARCH'
460                         run blastoff "$url      $REPLY"
461                 fi
462                 ;;
463         *) # something else
464                 run download "$url"
465                 ;;
466         esac
467 }
468
469 passthru() {
470         while IFS= read -r; do
471                 printf '%s\n' "$REPLY"
472         done
473 }
474
475 gopher_convert() {
476         local type label path server port regex
477         # cf. https://github.com/jamestomasino/dotfiles-minimal/blob/master/bin/gophermap2gemini.awk
478         while IFS= read -r; do
479                 printf -v regex '(.)([^\t]*)(\t([^\t]*)\t([^\t]*)\t([^\t]*))?'
480                 if [[ "$REPLY" =~ $regex ]]; then
481                         type="${BASH_REMATCH[1]}"
482                         label="${BASH_REMATCH[2]}"
483                         path="${BASH_REMATCH[4]:-/}"
484                         server="${BASH_REMATCH[5]:-$cur_server}"
485                         port="${BASH_REMATCH[6]}"
486                 else
487                         log e "CAN'T PARSE LINE"
488                         printf '%s\n' "$REPLY"
489                         continue
490                 fi
491                 case "$type" in
492                 .) # end of file
493                         printf '.\n'
494                         break
495                         ;;
496                 i) # label
497                         case "$label" in
498                         '#'* | '*'[[:space:]]*)
499                                 if $pre; then
500                                         printf '%s\n' '```'
501                                         pre=false
502                                 fi
503                                 ;;
504                         *)
505                                 if ! $pre; then
506                                         printf '%s\n' '```'
507                                         pre=true
508                                 fi
509                                 ;;
510                         esac
511                         printf '%s\n' "$label"
512                         ;;
513                 h) # html link
514                         if $pre; then
515                                 printf '%s\n' '```'
516                                 pre=false
517                         fi
518                         printf '=> %s %s\n' "${path:4}" "$label"
519                         ;;
520                 T) # telnet link
521                         if $pre; then
522                                 printf '%s\n' '```'
523                                 pre=false
524                         fi
525                         printf '=> telnet://%s:%s/%s%s %s\n' \
526                                 "$server" "$port" "$type" "$path" "$label"
527                         ;;
528                 *) # other type
529                         if $pre; then
530                                 printf '%s\n' '```'
531                                 pre=false
532                         fi
533                         printf '=> gopher://%s:%s/%s%s %s\n' \
534                                 "$server" "$port" "$type" "$path" "$label"
535                         ;;
536                 esac
537         done
538         if $pre; then
539                 printf '%s\n' '```'
540         fi
541         # close the connection
542         exec 9<&-
543         exec 9>&-
544 }
545
546 display() { # display METADATA [TITLE]
547         local -a less_cmd
548         local i mime charset
549         # split header line
550         local -a hdr
551         IFS=';' read -ra hdr <<<"$1"
552         # title is optional but nice looking
553         local title
554         if (($# == 2)); then
555                 title="$2"
556         fi
557
558         mime="$(trim "${hdr[0],,}")"
559         for ((i = 1; i <= "${#hdr[@]}"; i++)); do
560                 h="${hdr[$i]}"
561                 case "$h" in
562                 *charset=*) charset="${h#*=}" ;;
563                 esac
564         done
565
566         [[ -z "$mime" ]] && mime="text/gemini"
567         [[ -z "$charset" ]] && charset="utf-8"
568
569         log debug "mime='$mime'; charset='$charset'"
570
571         case "$mime" in
572         text/*)
573                 set_title "$BOLLUX_URL"
574                 less_cmd=(less -R)
575                 mklesskey "$BOLLUX_LESSKEY" && less_cmd+=(-k "$BOLLUX_LESSKEY")
576                 less_cmd+=(
577                         -Pm"$title${title:+ - }bollux$"
578                         -PM'o\:open, g\:goto, [\:back, ]\:forward, r\:refresh$'
579                         -m
580                 )
581
582                 local typeset
583                 local submime="${mime#*/}"
584                 if declare -Fp "typeset_$submime" >/dev/null; then
585                         typeset="typeset_$submime"
586                 else
587                         typeset="passthru"
588                 fi
589
590                 {
591                         run iconv -f "${charset^^}" -t "UTF-8" |
592                                 run tee "$BOLLUX_PAGESRC" |
593                                 run "$typeset" |
594                                 run "${less_cmd[@]}" && bollux_quit
595                 } || run handle_keypress "$?"
596                 ;;
597         *) run download "$BOLLUX_URL" ;;
598         esac
599 }
600
601 mklesskey() {
602         lesskey -o "$1" - <<-END
603                 #command
604                 o quit 0 # 48 open a link
605                 g quit 1 # 49 goto a url
606                 [ quit 2 # 50 back
607                 ] quit 3 # 51 forward
608                 r quit 4 # 52 re-request / download
609                 G quit 5 # 53 goto a url (pre-filled)
610                 # other keybinds
611                 \\40 forw-screen-force
612         END
613 }
614
615 normalize() {
616         shopt -s extglob
617         while IFS= read -r; do
618                 printf '%s\n' "${REPLY//$'\r'?($'\n')/}"
619         done
620         shopt -u extglob
621 }
622
623 typeset_gemini() {
624         local pre=false
625         local ln=0 # link number
626
627         if ((T_WIDTH == 0)); then
628                 shopt -s checkwinsize
629                 (
630                         :
631                         :
632                 ) # XXX this doesn't work!?
633                 log d "LINES=$LINES; COLUMNS=$COLUMNS"
634                 T_WIDTH=$COLUMNS
635         fi
636         WIDTH=$((T_WIDTH - T_MARGIN))
637         ((WIDTH < 0)) && WIDTH=80  # default if dumb
638         S_MARGIN=$((T_MARGIN - 1)) # spacing
639
640         log d "T_WIDTH=$T_WIDTH"
641         log d "WIDTH=$WIDTH"
642
643         while IFS= read -r; do
644                 case "$REPLY" in
645                 '```'*)
646                         if $pre; then
647                                 pre=false
648                         else
649                                 pre=true
650                         fi
651                         continue
652                         ;;
653                 '=>'*)
654                         : $((ln += 1))
655                         gemini_link "$REPLY" $pre "$ln"
656                         ;;
657                 '#'*) gemini_header "$REPLY" $pre ;;
658                 '*'[[:space:]]*)
659                         gemini_list "$REPLY" $pre
660                         ;;
661                 *) gemini_text "$REPLY" $pre ;;
662                 esac
663         done
664 }
665
666 gemini_link() {
667         local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)"
668         local s t a l # sigil, text, annotation(url), line
669         if ! ${2-false} && [[ "$1" =~ $re ]]; then
670                 s="${BASH_REMATCH[1]}"
671                 a="${BASH_REMATCH[2]}"
672                 t="${BASH_REMATCH[3]}"
673                 if [[ -z "$t" ]]; then
674                         t="$a"
675                         a=
676                 fi
677
678                 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
679                 printf -v l "\e[${C_LINK_NUMBER}m[%d]${C_RESET} \
680                         \e[${C_LINK_TITLE}m%s${C_RESET} \
681                         \e[${C_LINK_URL}m%s${C_RESET}\n" \
682                         "$3" "$t" "$a"
683                 fold_line "$WIDTH" "$l"
684         else
685                 gemini_pre "$1"
686         fi
687 }
688
689 gemini_header() {
690         local re="^(#+)[[:blank:]]*(.*)"
691         local s t a l # sigil, text, annotation(lvl), line
692         if ! ${2-false} && [[ "$1" =~ $re ]]; then
693                 s="${BASH_REMATCH[1]}"
694                 a="${#BASH_REMATCH[1]}"
695                 t="${BASH_REMATCH[2]}"
696                 local hdrfmt
697                 hdrfmt="$(eval echo "\$C_HEADER$a")"
698
699                 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
700                 printf -v l "\e[${hdrfmt}m%s${C_RESET}\n" "$t"
701                 fold_line "$WIDTH" "$l"
702         else
703                 gemini_pre "$1"
704         fi
705 }
706
707 gemini_list() {
708         local re="^(\*)[[:blank:]]*(.*)"
709         local s t a l # sigil, text, annotation(n/a), line
710         if ! ${2-false} && [[ "$1" =~ $re ]]; then
711                 s="${BASH_REMATCH[1]}"
712                 t="${BASH_REMATCH[2]}"
713
714                 printf "\e[${C_SIGIL}m%${S_MARGIN}s " "$s"
715                 printf -v l "\e[${C_LIST}m%s${C_RESET}\n" "$t"
716                 fold_line "$WIDTH" "$l"
717         else
718                 gemini_pre "$1"
719         fi
720 }
721
722 gemini_text() {
723         if ! ${2-false}; then
724                 printf "%${S_MARGIN}s " ' '
725                 fold_line "$WIDTH" "$1"
726         else
727                 gemini_pre "$1"
728         fi
729 }
730
731 gemini_pre() {
732         printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```'
733         printf "\e[${C_PRE}m%s${C_RESET}\n" "$1"
734 }
735
736 fold_line() { # fold_line WIDTH TEXT
737         local width="$1"
738         local margin="${2%%[![:space:]]*}"
739         if [[ "$margin" ]]; then
740                 margin="${#margin}"
741         else
742                 margin="$T_MARGIN"
743         fi
744         local ll=0 wl plain
745         # shellcheck disable=2086
746         set -- $2 # TODO: is this the best way?
747
748         for word; do
749                 plain="${word//$'\x1b'\[*([0-9;])m/}"
750                 wl=$((${#plain} + 1))
751                 if (((ll + wl) >= width)); then
752                         printf "\n%${margin}s" ' '
753                         ll=$wl
754                 else
755                         ll=$((ll + wl))
756                 fi
757                 printf '%s ' "$word"
758         done
759         printf '\n'
760 }
761
762 handle_keypress() {
763         case "$1" in
764         48) # o - open a link -- show a menu of links on the page
765                 run select_url "$BOLLUX_PAGESRC"
766                 ;;
767         49) # g - goto a url -- input a new url
768                 prompt GO
769                 run blastoff -u "$REPLY"
770                 ;;
771         50) # [ - back in the history
772                 run history_back || {
773                         sleep 0.5
774                         run blastoff "$BOLLUX_URL"
775                 }
776                 ;;
777         51) # ] - forward in the history
778                 run history_forward || {
779                         sleep 0.5
780                         run blastoff "$BOLLUX_URL"
781                 }
782                 ;;
783         52) # r - re-request the current resource
784                 run blastoff "$BOLLUX_URL"
785                 ;;
786         53) # G - goto a url (pre-filled with current)
787                 prompt -u GO
788                 run blastoff -u "$REPLY"
789                 ;;
790         *) # 54-57 -- still available for binding
791                 die "$?" "less(1) error"
792                 ;;
793         esac
794 }
795
796 select_url() {
797         run mapfile -t < <(extract_links <"$1")
798         PS3="OPEN> "
799         select u in "${MAPFILE[@]}"; do
800                 case "$REPLY" in
801                 q) bollux_quit ;;
802                 [^0-9]*) run blastoff -u "$REPLY" && break ;;
803                 esac
804                 run blastoff "${u%%[[:space:]]*}" && break
805         done </dev/tty
806 }
807
808 extract_links() {
809         local url alt
810         while read -r; do
811                 if [[ "$REPLY" =~ ^=\>[[:space:]]*([^[:space:]]+)([[:space:]]+(.*))?$ ]]; then
812                         url="${BASH_REMATCH[1]}"
813                         alt="${BASH_REMATCH[3]}"
814
815                         if [[ "$alt" ]]; then
816                                 printf '%s \e[34m(%s)\e[0m\n' "$url" "$alt"
817                         else
818                                 printf '%s\n' "$url"
819                         fi
820                 fi
821         done
822 }
823
824 download() {
825         tn="$(mktemp)"
826         log x "Downloading: '$BOLLUX_URL' => '$tn'..."
827         dd status=progress >"$tn"
828         fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}"
829         if [[ -f "$fn" ]]; then
830                 log x "Saved '$tn'."
831         elif mv "$tn" "$fn"; then
832                 log x "Saved '$fn'."
833         else
834                 log error "Error saving '$fn': downloaded to '$tn'."
835         fi
836 }
837
838 bollux_init() {
839         # Trap cleanup
840         trap bollux_cleanup INT QUIT EXIT
841         # State
842         REDIRECTS=0
843         set -f
844         # History
845         declare -a HISTORY # history is kept in an array
846         HN=0               # position of history in the array
847         run mkdir -p "${BOLLUX_HISTFILE%/*}"
848 }
849
850 bollux_cleanup() {
851         # Stubbed in case of need in future
852         :
853 }
854
855 history_append() { # history_append url TITLE
856         BOLLUX_URL="$1"
857         # date/time, url, title (best guess)
858         run printf '%(%FT%T)T\t%s\t%s\n' -1 "$1" "$2" >>"$BOLLUX_HISTFILE"
859         HISTORY[$HN]="$BOLLUX_URL"
860         ((HN += 1))
861 }
862
863 history_back() {
864         log d "HN=$HN"
865         ((HN -= 2))
866         if ((HN < 0)); then
867                 HN=0
868                 log e "Beginning of history."
869                 return 1
870         fi
871         run blastoff "${HISTORY[$HN]}"
872 }
873
874 history_forward() {
875         log d "HN=$HN"
876         if ((HN >= ${#HISTORY[@]})); then
877                 HN="${#HISTORY[@]}"
878                 log e "End of history."
879                 return 1
880         fi
881         run blastoff "${HISTORY[$HN]}"
882 }
883
884 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
885         run bollux "$@"
886 fi