Change some functions to only Bash
[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.1
6
7 # Program information
8 PRGN="${0##*/}"
9 VRSN=0.1
10 # State
11 REDIRECTS=0
12 # Bash options
13 # shopt -s extglob
14
15 bollux_usage() {
16         cat <<END
17 $PRGN (v. $VRSN): a bash gemini client
18 usage:
19         $PRGN [-h]
20         $PRGN [-q] [-v] [URL]
21 flags:
22         -h      show this help and exit
23         -q      be quiet: log no messages
24         -v      verbose: log more messages
25 parameters:
26         URL     the URL to start in
27                 If not provided, the user will be prompted.
28 END
29 }
30
31 run() {
32         log debug "$@"
33         "$@"
34 }
35
36 die() {
37         ec="$1"
38         shift
39         log error "$*"
40         exit "$ec"
41 }
42
43 # pure bash bible trim_string
44 trim() {
45         : "${1#"${1%%[![:space:]]*}"}"
46         : "${_%"${_##*[![:space:]]}"}"
47         printf '%s\n' "$_"
48 }
49
50 log() {
51         [[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
52         case "$1" in
53         d* | D*) # debug
54                 [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
55                 fmt=34
56                 ;;
57         e* | E*) # error
58                 fmt=31
59                 ;;
60         *) fmt=1 ;;
61         esac
62         shift
63         printf >&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*"
64 }
65
66 # main entry point
67 bollux() {
68         run bollux_args "$@"
69         run bollux_config
70
71         if [[ ! "${BOLLUX_URL:+isset}" ]]; 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/config}"
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         : "${BOLLUX_DOWNDIR:=.}"                   # where to save downloads
108         : "${BOLLUX_LOGLEVEL:=3}"                  # log level
109         : "${BOLLUX_MAXREDIR:=5}"                  # max redirects
110         : "${BOLLUX_PORT:=1965}"                   # port number
111         : "${BOLLUX_PROTO:=gemini}"                # default protocol
112         : "${BOLLUX_LESSKEY:=/tmp/bollux-lesskey}" # where to store binds
113         : "${BOLLUX_PAGESRC:=/tmp/bollux-src}"     # where to save the page source
114         : "${BOLLUX_URL:=}"                        # start url
115 }
116
117 prompt() {
118         prompt="$1"
119         shift
120         read </dev/tty -e -r -p "$prompt> " "$@"
121 }
122
123 blastoff() { # load a url
124         local well_formed=true
125         if [[ "$1" == "-u" ]]; then
126                 well_formed=false
127                 shift
128         fi
129         URL="$1"
130
131         if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
132                 URL="$(run transform_resource "$BOLLUX_URL" "$1")"
133         fi
134         [[ "$URL" != *://* ]] && URL="$BOLLUX_PROTO://$URL"
135         URL="$(trim "$URL")"
136
137         server="${URL#*://}"
138         server="${server%%/*}"
139
140         log d "URL='$URL' server='$server'"
141
142         run request_url "$server" "$BOLLUX_PORT" "$URL" |
143                 run handle_response "$URL"
144 }
145
146 transform_resource() { # transform_resource BASE_URL REFERENCE_URL
147         declare -A R B T # reference, base url, target
148         eval "$(run parse_url B "$1")"
149         eval "$(run parse_url R "$2")"
150         # A non-strict parser may ignore a scheme in the reference
151         # if it is identical to the base URI's scheme.
152         if ! "${STRICT:-true}" && [[ "${R[scheme]}" == "${B[scheme]}" ]]; then
153                 unset "${R[scheme]}"
154         fi
155
156         # basically pseudo-code from spec ported to bash
157         if isdefined "R[scheme]"; then
158                 T[scheme]="${R[scheme]}"
159                 isdefined "R[authority]" && T[authority]="${R[authority]}"
160                 isdefined R[path] &&
161                         T[path]="$(run remove_dot_segments "${R[path]}")"
162                 isdefined "R[query]" && T[query]="${R[query]}"
163         else
164                 if isdefined "R[authority]"; then
165                         T[authority]="${R[authority]}"
166                         isdefined "R[authority]" &&
167                                 T[path]="$(remove_dot_segments "${R[path]}")"
168                         isdefined R[query] && T[query]="${R[query]}"
169                 else
170                         if isempty "R[path]"; then
171                                 T[path]="${B[path]}"
172                                 if isdefined R[query]; then
173                                         T[query]="${R[query]}"
174                                 else
175                                         T[query]="${B[query]}"
176                                 fi
177                         else
178                                 if [[ "${R[path]}" == /* ]]; then
179                                         T[path]="$(remove_dot_segments "${R[path]}")"
180                                 else
181                                         T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")"
182                                         T[path]="$(remove_dot_segments "${T[path]}")"
183                                 fi
184                                 isdefined R[query] && T[query]="${R[query]}"
185                         fi
186                         T[authority]="${B[authority]}"
187                 fi
188                 T[scheme]="${B[scheme]}"
189         fi
190         isdefined R[fragment] && T[fragment]="${R[fragment]}"
191         # cf. 5.3 -- recomposition
192         local r=""
193         isdefined "T[scheme]" && r="$r${T[scheme]}:"
194         isdefined "T[authority]" && r="$r//${T[authority]}"
195         r="$r${T[path]}"
196         isdefined T[query] && r="$r?${T[query]}"
197         isdefined T[fragment] && r="$r#${T[fragment]}"
198         printf '%s\n' "$r"
199 }
200
201 merge_paths() { # 5.2.3
202         # shellcheck disable=2034
203         B_authority="$1"
204         B_path="$2"
205         R_path="$3"
206         # if R_path is empty, get rid of // in B_path
207         if [[ -z "$R_path" ]]; then
208                 printf '%s\n' "${B_path//\/\//\//}"
209                 return
210         fi
211
212         if isdefined "B_authority" && isempty "B_path"; then
213                 printf '/%s\n' "${R_path//\/\//\//}"
214         else
215                 if [[ "$B_path" == */* ]]; then
216                         B_path="${B_path%/*}/"
217                 else
218                         B_path=""
219                 fi
220                 printf '%s/%s\n' "${B_path%/}" "${R_path#/}"
221         fi
222 }
223
224 remove_dot_segments() { # 5.2.4
225         local input="$1"
226         local output=
227         # ^/\.(/|$) - BASH_REMATCH[0]
228         while [[ "$input" ]]; do
229                 if [[ "$input" =~ ^\.\.?/ ]]; then
230                         input="${input#${BASH_REMATCH[0]}}"
231                 elif [[ "$input" =~ ^/\.(/|$) ]]; then
232                         input="/${input#${BASH_REMATCH[0]}}"
233                 elif [[ "$input" =~ ^/\.\.(/|$) ]]; then
234                         input="/${input#${BASH_REMATCH[0]}}"
235                         [[ "$output" =~ /?[^/]+$ ]]
236                         output="${output%${BASH_REMATCH[0]}}"
237                 elif [[ "$input" == . || "$input" == .. ]]; then
238                         input=
239                 else
240                         [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || echo NOMATCH >&2
241                         output="$output${BASH_REMATCH[1]}"
242                         input="${BASH_REMATCH[2]}"
243                 fi
244         done
245         printf '%s\n' "${output//\/\//\//}"
246 }
247
248 parse_url() { # eval "$(split_url NAME STRING)" => NAME[...]
249         local name="$1"
250         local string="$2"
251         # shopt -u extglob # TODO port re ^ to extglob syntax
252         local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
253         [[ $string =~ $re ]] || return $?
254         # shopt -s extglob
255
256         local scheme="${BASH_REMATCH[2]}"
257         local authority="${BASH_REMATCH[4]}"
258         local path="${BASH_REMATCH[5]}"
259         local query="${BASH_REMATCH[7]}"
260         local fragment="${BASH_REMATCH[9]}"
261
262         for c in scheme authority query fragment; do
263                 [[ "${!c}" ]] &&
264                         run printf '%s[%s]=%q\n' "$name" "$c" "${!c}"
265         done
266         # unclear if the path is always set even if empty but it looks that way
267         run printf '%s[path]=%q\n' "$name" "$path"
268 }
269
270 # is a NAME defined ('set' in bash)?
271 isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME
272 # is a NAME defined AND empty?
273 isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME
274
275 request_url() {
276         local server="$1"
277         local port="$2"
278         local url="$3"
279
280         ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
281         ssl_cmd+=(-servername "$server") # SNI
282         run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null
283 }
284
285 handle_response() {
286         local url="$1" code meta
287
288         while read -r -d $'\r' hdr; do
289                 code="$(gawk '{print $1}' <<<"$hdr")"
290                 meta="$(
291                         gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$hdr"
292                 )"
293                 break
294         done
295
296         log x "[$code] $meta"
297
298         case "$code" in
299         1*)
300                 REDIRECTS=0
301                 BOLLUX_URL="$URL"
302                 run prompt "$meta" QUERY
303                 # shellcheck disable=2153
304                 run blastoff "?$QUERY"
305                 ;;
306         2*)
307                 REDIRECTS=0
308                 BOLLUX_URL="$URL"
309                 run display "$meta"
310                 ;;
311         3*)
312                 ((REDIRECTS += 1))
313                 if ((REDIRECTS > BOLLUX_MAXREDIR)); then
314                         die $((100 + code)) "Too many redirects!"
315                 fi
316                 BOLLUX_URL="$URL"
317                 run blastoff "$meta"
318                 ;;
319         4*)
320                 REDIRECTS=0
321                 die "$((100 + code))" "$code"
322                 ;;
323         5*)
324                 REDIRECTS=0
325                 die "$((100 + code))" "$code"
326                 ;;
327         6*)
328                 REDIRECTS=0
329                 die "$((100 + code))" "$code"
330                 ;;
331         *) die "$((100 + code)) Unknown response code: $code." ;;
332         esac
333 }
334
335 display() {
336         case "$1" in
337         *\;*)
338                 mime="${1%;*}"
339                 charset="${1#*;}"
340                 trim "$mime"
341                 trim "$charset"
342                 log d "$mime $charset"
343                 ;;
344         *) mime="$(trim "$1")" ;;
345         esac
346
347         [[ -z "$mime" ]] && mime="text/gemini"
348         if [[ -z "$charset" ]]; then
349                 charset="utf-8"
350         else
351                 charset="${charset#charset=}"
352         fi
353
354         log debug "mime=$mime; charset=$charset"
355
356         case "$mime" in
357         text/*)
358                 less_cmd=(less -R)
359                 {
360                         [[ -r "$BOLLUX_LESSKEY" ]] || mklesskey "$BOLLUX_LESSKEY"
361                 } && less_cmd+=(-k "$BOLLUX_LESSKEY")
362                 less_cmd+=(
363                         -Pm'bollux$'
364                         -PM'o\:open, g\:goto, r\:refresh$'
365                         -M
366                 )
367
368                 submime="${mime#*/}"
369                 if declare -F | grep -q "$submime"; then
370                         log d "typeset_$submime"
371                         {
372                                 normalize_crlf |
373                                         tee "$BOLLUX_PAGESRC" |
374                                         run "typeset_$submime" |
375                                         run "${less_cmd[@]}"
376                         } || run handle_keypress "$?"
377                 else
378                         log "cat"
379                         {
380                                 normalize_crlf |
381                                         tee "$BOLLUX_PAGESRC" |
382                                         run "${less_cmd[@]}"
383                         } || run handle_keypress "$?"
384                 fi
385                 ;;
386         *) run download "$BOLLUX_URL" ;;
387         esac
388 }
389
390 mklesskey() {
391         lesskey -o "$1" - <<-END
392                 #command
393                 o quit 0 # 48 open a link
394                 g quit 1 # 49 goto a url
395                 [ quit 2 # 50 back
396                 ] quit 3 # 51 forward
397                 r quit 4 # 52 re-request / download
398         END
399 }
400
401 normalize_crlf() {
402         while read -r line; do
403                 printf '%s\n' "${line//$'\r'?($'\n')/}"
404         done
405 }
406
407 typeset_gemini() {
408         gawk '
409         BEGIN {
410                 pre = 0
411                 margin = margin ? margin : 4
412                 txs = ""        
413                 lns = "\033[1m" 
414                 lus = "\033[36m"        
415                 lts = "\033[4m" 
416                 pfs = ""        
417                 h1s = "\033[1;4m"       
418                 h2s = "\033[1m" 
419                 h3s = "\033[3m" 
420                 lis = ""        
421                 res = "\033[0m" 
422                 ms = "\033[35m"
423         }
424         /```/ {
425                 pre = ! pre
426                 next
427         }
428         pre {
429                 mark = "```"
430                 fmt = pfs "%s" res
431                 text = $0
432         }
433         /^#/ {
434                 match($0, /#+/)
435                 mark = substr($0, RSTART, RLENGTH)
436                 sub(/#+[[:space:]]*/, "", $0)
437                 level = length(mark)
438                 if (level == 1) {
439                         fmt = h1s "%s" res
440                 } else if (level == 2) {
441                         fmt = h2s "%s" res
442                 } else {
443                         fmt = h3s "%s" res
444                 }
445         }
446         /^=>/ {
447                 mark = "=>"
448                 sub(/=>[[:space:]]*/, "", $0)
449                 desc = $1
450                 text = ""
451                 for (w = 2; w <= NF; w++) {
452                         text = text (text ? " " : "") $w
453                 }
454                 fmt = lns "[" (++ln) "]" res " " lts "%s" res "\t" lus "%s" res
455         }
456         /^\*[[:space:]]/ {
457                 mark = "*"
458                 sub(/\*[[:space:]]*/, "", $0)
459                 fmt = lis "%s" res
460         }
461         {
462                 mark = mark ? mark : mark
463                 fmt = fmt ? fmt : "%s"
464                 text = text ? text : $0
465                 desc = desc ? desc : ""
466                 printf ms "%" (margin-1) "s " res fmt "\n", mark, text, desc
467                 mark = fmt = text = desc = ""
468         }
469         '
470 }
471
472 handle_keypress() {
473         case "$1" in
474         48) # o - open a link -- show a menu of links on the page
475                 run select_url "$BOLLUX_PAGESRC"
476                 ;;
477         49) # g - goto a url -- input a new url
478                 prompt GO URL
479                 run blastoff -u "$URL"
480                 ;;
481         50) # [ - back in the history
482                 run history_back
483                 ;;
484         51) # ] - forward in the history
485                 run history_forward
486                 ;;
487         52) # r - re-request the current resource
488                 run blastoff "$BOLLUX_URL"
489                 ;;
490         *) # 53-57 -- still available for binding
491                 ;;
492         esac
493 }
494
495 select_url() {
496         run mapfile -t < <(extract_links <"$1")
497         select u in "${MAPFILE[@]}"; do
498                 run blastoff "$(gawk '{print $1}' <<<"$u")" && break
499         done </dev/tty
500 }
501
502 extract_links() {
503         gawk -F$'\t' '
504         /^=>/ {
505                 sub(/=>[[:space:]]*/,"")
506                 if ($2) 
507                         printf "%s (\033[34m%s\033[0m)\n", $1, $2
508                 else
509                         printf "%s\n", $1
510         }'
511 }
512
513 download() {
514         tn="$(mktemp)"
515         log x "Downloading: '$BOLLUX_URL' => '$tn'..."
516         dd status=progress >"$tn"
517         fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}"
518         if [[ -f "$fn" ]]; then
519                 log x "Saved '$tn'."
520         elif mv "$tn" "$fn"; then
521                 log x "Saved '$fn'."
522         else
523                 log error "Error saving '$fn': downloaded to '$tn'."
524         fi
525 }
526
527 history_back() { log error "Not implemented."; }
528 history_forward() { log error "Not implemented."; }
529
530 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
531         run bollux "$@"
532 else
533         BOLLUX_LOGLEVEL=DEBUG
534 fi