Fix dumb mistake
[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
13 bollux_usage() {
14         cat <<END
15 $PRGN (v. $VRSN): a bash gemini client
16 usage:
17         $PRGN [-h]
18         $PRGN [-q] [-v] [URL]
19 flags:
20         -h      show this help and exit
21         -q      be quiet: log no messages
22         -v      verbose: log more messages
23 parameters:
24         URL     the URL to start in
25                 If not provided, the user will be prompted.
26 END
27 }
28
29 run() {
30         log debug "$@"
31         "$@"
32 }
33
34 die() {
35         ec="$1"
36         shift
37         log error "$*"
38         exit "$ec"
39 }
40
41 trim() { sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'; }
42
43 log() {
44         [[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
45         case "$1" in
46         d* | D*) # debug
47                 [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
48                 fmt=34
49                 ;;
50         e* | E*) # error
51                 fmt=31
52                 ;;
53         *) fmt=1 ;;
54         esac
55         shift
56         printf >&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*"
57 }
58
59 # main entry point
60 bollux() {
61         run bollux_args "$@"
62         run bollux_config
63
64         if [[ ! "${BOLLUX_URL:+isset}" ]]; then
65                 run prompt GO BOLLUX_URL
66         fi
67
68         run blastoff "$BOLLUX_URL"
69 }
70
71 bollux_args() {
72         while getopts :hvq OPT; do
73                 case "$OPT" in
74                 h)
75                         bollux_usage
76                         exit
77                         ;;
78                 v) BOLLUX_LOGLEVEL=DEBUG ;;
79                 q) BOLLUX_LOGLEVEL=QUIET ;;
80                 :) die 1 "Option -$OPTARG requires an argument" ;;
81                 *) die 1 "Unknown option: -$OPTARG" ;;
82                 esac
83         done
84         shift $((OPTIND - 1))
85         if (($# == 1)); then
86                 BOLLUX_URL="$1"
87         fi
88 }
89
90 bollux_config() {
91         : "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/config}"
92
93         if [ -f "$BOLLUX_CONFIG" ]; then
94                 # shellcheck disable=1090
95                 . "$BOLLUX_CONFIG"
96         else
97                 log debug "Can't load config file '$BOLLUX_CONFIG'."
98         fi
99
100         : "${BOLLUX_DOWNDIR:=.}"                   # where to save downloads
101         : "${BOLLUX_LOGLEVEL:=3}"                  # log level
102         : "${BOLLUX_MAXREDIR:=5}"                  # max redirects
103         : "${BOLLUX_PORT:=1965}"                   # port number
104         : "${BOLLUX_PROTO:=gemini}"                # default protocol
105         : "${BOLLUX_LESSKEY:=/tmp/bollux-lesskey}" # where to store binds
106         : "${BOLLUX_PAGESRC:=/tmp/bollux-src}"     # where to save the page source
107         : "${BOLLUX_URL:=}"                        # start url
108 }
109
110 prompt() {
111         prompt="$1"
112         shift
113         read </dev/tty -e -r -p "$prompt> " "$@"
114 }
115
116 blastoff() { # load a url
117         local well_formed=true
118         if [[ "$1" == "-u" ]]; then
119                 well_formed=false
120                 shift
121         fi
122         URL="$1"
123
124         if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
125                 URL="$(run munge_url "$1" "$BOLLUX_URL")"
126         fi
127         [[ "$URL" != *://* ]] && URL="$BOLLUX_PROTO://$URL"
128         URL="$(trim <<<"$URL")"
129
130         server="${URL#*://}"
131         server="${server%%/*}"
132
133         run request_url "$server" "$BOLLUX_PORT" "$URL" |
134                 run handle_response "$URL"
135 }
136
137 munge_url() {
138         local -A new old u
139         eval "$(split_url new <<<"$1")"
140         for k in "${!new[@]}"; do log d "new[$k]=${new[$k]}"; done
141         eval "$(split_url old <<<"$2")"
142         for k in "${!old[@]}"; do log d "old[$k]=${old[$k]}"; done
143
144         u['scheme']="${new['scheme']:-${old['scheme']:-}}"
145         u['authority']="${new['authority']:-${old['authority']:-}}"
146         # XXX this whole path thing is wack
147         if [[ "${new['path']+isset}" ]]; then
148                 log d 'new path set'
149                 if [[ "${new['path']}" == /* ]]; then
150                         log d 'new path == /*'
151                         u['path']="${new['path']}"
152                 elif [[ "${new['authority']}" == "${old['authority']}" || ! "${new['authority']+isset}" ]]; then
153                         p="${old['path']:-}/${new['path']}"
154                         log d "$p ( $(normalize_path <<<"$p") )"
155                         u['path']="$(normalize_path <<<"$p")"
156                 else
157                         log d 'u path = new path'
158                         u['path']="${new['path']}"
159                 fi
160         elif [[ "${new['query']+isset}" || "${new['fragment']+isset}" ]]; then
161                 log d 'u path = old path'
162                 u['path']="${old['path']}"
163         else
164                 u['path']="/"
165         fi
166         u['query']="${new['query']:-}"
167         u['fragment']="${new['fragment']:-}"
168         for k in "${!u[@]}"; do log d "u[$k]=${u[$k]}"; done
169
170         run printf '%s%s%s%s%s\n' \
171                 "${u['scheme']}" "${u['authority']}" "${u['path']}" \
172                 "${u['query']}" "${u['fragment']}"
173 }
174
175 normalize_path() {
176         gawk '{
177         split($0, path, /\//)
178         for (c in path) {
179                 if (path[c] == "" || path[c] == ".") {
180                         continue
181                 }
182                 if (path[c] == "..") {
183                         sub(/[^\/]+$/, "", ret)
184                         continue
185                 }
186                 if (! ret || match(ret, /\/$/)) {
187                         slash = ""
188                 } else {
189                         slash = "/"
190                 }
191                 ret = ret slash path[c]
192         }
193         print (ret ~ /^\// ? "" : "/") ret
194         }'
195 }
196
197 split_url() {
198         gawk -vvar="$1" '{
199         if (match($0, /^[A-Za-z]+:/)) {
200                 arr["scheme"] = substr($0, RSTART, RLENGTH)
201                 $0 = substr($0, RLENGTH + 1)
202         }
203         if (match($0, /^\/\/[^\/?#]+?/) || (match($0, /^[^\/?#]+?/) && scheme)) {
204         arr["authority"] = substr($0, RSTART, RLENGTH)
205                 $0 = substr($0, RLENGTH + 1)
206         }
207         if (match($0, /^\/?[^?#]+/)) {
208                 arr["path"] = substr($0, RSTART, RLENGTH)
209                 $0 = substr($0, RLENGTH + 1)
210         }
211         if (match($0, /^\?[^#]+/)) {
212                 arr["query"] = substr($0, RSTART, RLENGTH)
213                 $0 = substr($0, RLENGTH + 1)
214         }
215         if (match($0, /^#.*/)) {
216                 arr["fragment"] = substr($0, RSTART, RLENGTH)
217                 $0 = substr($0, RLENGTH + 1)
218         }
219         for (part in arr) {
220                 sub(/[[:space:]]+$/, "", arr[part])
221                 printf var "[\"%s\"]=\"%s\"\n", part, arr[part]
222         }
223         }'
224 }
225
226 request_url() {
227         local server="$1"
228         local port="$2"
229         local url="$3"
230
231         ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
232         ssl_cmd+=(-servername "$server") # SNI
233         run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null
234 }
235
236 handle_response() {
237         local url="$1" code meta
238
239         while read -r -d $'\r' hdr; do
240                 code="$(gawk '{print $1}' <<<"$hdr")"
241                 meta="$(
242                         gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$hdr"
243                 )"
244                 break
245         done
246
247         log x "[$code] $meta"
248
249         case "$code" in
250         1*)
251                 REDIRECTS=0
252                 BOLLUX_URL="$URL"
253                 run prompt "$meta" QUERY
254                 run blastoff "?$QUERY"
255                 ;;
256         2*)
257                 REDIRECTS=0
258                 BOLLUX_URL="$URL"
259                 run display "$meta"
260                 ;;
261         3*)
262                 ((REDIRECTS += 1))
263                 if ((REDIRECTS > BOLLUX_MAXREDIR)); then
264                         die $((100 + code)) "Too many redirects!"
265                 fi
266                 BOLLUX_URL="$URL"
267                 run blastoff "$meta"
268                 ;;
269         4*)
270                 REDIRECTS=0
271                 die "$((100 + code))" "$code"
272                 ;;
273         5*)
274                 REDIRECTS=0
275                 die "$((100 + code))" "$code"
276                 ;;
277         6*)
278                 REDIRECTS=0
279                 die "$((100 + code))" "$code"
280                 ;;
281         *) die "$((100 + code)) Unknown response code: $code." ;;
282         esac
283 }
284
285 display() {
286         case "$1" in
287         *\;*)
288                 mime="$(cut -d\; -f1 <<<"$1" | trim)"
289                 charset="$(cut -d\; -f2 <<<"$1" | trim)"
290                 ;;
291         *) mime="$(trim <<<"$1")" ;;
292         esac
293
294         [[ -z "$mime" ]] && mime="text/gemini"
295         if [[ -z "$charset" ]]; then
296                 charset="utf-8"
297         else
298                 charset="${charset#charset=}"
299         fi
300
301         log debug "mime=$mime; charset=$charset"
302
303         case "$mime" in
304         text/*)
305                 less_cmd=(less -R)
306                 {
307                         [[ -r "$BOLLUX_LESSKEY" ]] || mklesskey "$BOLLUX_LESSKEY"
308                 } && less_cmd+=(-k "$BOLLUX_LESSKEY")
309                 less_cmd+=(
310                         -Pm'bollux$'
311                         -PM'o\:open, g\:goto, r\:refresh$'
312                         -M
313                 )
314
315                 submime="${mime#*/}"
316                 if declare -F | grep -q "$submime"; then
317                         log d "typeset_$submime"
318                         {
319                                 normalize_crlf |
320                                         run "typeset_$submime" |
321                                         tee "$BOLLUX_PAGESRC" |
322                                         run "${less_cmd[@]}"
323                         } || run handle_keypress "$?"
324                 else
325                         log "cat"
326                         {
327                                 normalize_crlf |
328                                         tee "$BOLLUX_PAGESRC" |
329                                         run "${less_cmd[@]}"
330                         } || run handle_keypress "$?"
331                 fi
332                 ;;
333         *) run download "$BOLLUX_URL" ;;
334         esac
335 }
336
337 mklesskey() {
338         lesskey -o "$1" - <<-END
339                 #command
340                 o quit 0 # 48 open a link
341                 g quit 1 # 49 goto a url
342                 [ quit 2 # 50 back
343                 ] quit 3 # 51 forward
344                 r quit 4 # 52 re-request / download
345         END
346 }
347
348 normalize_crlf() {
349         gawk 'BEGIN{RS="\n\n"}{gsub(/\r\n?/,"\n");print;print ""}'
350 }
351
352 typeset_gemini() {
353         gawk '
354         BEGIN { pre = 0 }
355         /^###/ { sub(/^#+[[:space:]]*/, ""); 
356                 printf "### \033[3m%s\033[0m\n", $0
357         next }
358         /^##/  { sub(/^#+[[:space:]]*/, ""); 
359                 printf "##  \033[1m%s\033[0m\n", $0
360         next }
361         /^#/   { sub(/^#+[[:space:]]*/, ""); 
362                 printf "#   \033[1;4m%s\033[0m\n", $0
363         next }
364         /^=>/  { 
365                 sub(/=>[[:space:]]*/, "")
366                 url = $1; desc = ""
367                 for (w=2;w<=NF;w++) 
368                         desc = desc (desc?" ":"") $w
369                 printf "=>  \033[1m[%02d]\033[0m \033[4m%s\033[0m\t\033[36m%s\033[0m\n", 
370                         (++ln), desc, url
371         next }
372         /```/  { pre = !pre; next }
373         pre { printf "``` %s\n", $0; next }
374         # /^\*/  { sub(/\*[[:space:]]*/, ""); }
375         { sub(/^/, " "); print }
376         '
377 }
378
379 handle_keypress() {
380         case "$1" in
381         48) # o - open a link -- show a menu of links on the page
382                 run select_url "$BOLLUX_PAGESRC"
383                 ;;
384         49) # g - goto a url -- input a new url
385                 prompt GO URL
386                 run blastoff -u "$URL"
387                 ;;
388         50) # [ - back in the history
389                 run history_back
390                 ;;
391         51) # ] - forward in the history
392                 run history_forward
393                 ;;
394         52) # r - re-request the current resource
395                 run blastoff "$BOLLUX_URL"
396                 ;;
397         *) # 53-57 -- still available for binding
398                 ;;
399         esac
400 }
401
402 select_url() {
403         run mapfile -t < <(extract_links <"$1")
404         select u in "${MAPFILE[@]}"; do
405                 run blastoff "$(gawk '{print $1}' <<<"$u")" && break
406         done </dev/tty
407 }
408
409 extract_links() {
410         gawk -F$'\t' '/^=>/ {
411                 gsub("\033\\[[^m]*m", "")
412                 sub(/=>[[:space:]]*\[[0-9]+\][[:space:]]*/,"")
413                 if ($2) 
414                         printf "%s (\033[34m%s\033[0m)\n", $2, $1
415                 else
416                         printf "%s\n", $1
417         }'
418 }
419
420 download() {
421         tn="$(mktemp)"
422         log x "Downloading: '$BOLLUX_URL' => '$tn'..."
423         dd status=progress >"$tn"
424         fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}"
425         if [[ -f "$fn" ]]; then
426                 log x "Saved '$tn'."
427         elif mv "$tn" "$fn"; then
428                 log x "Saved '$fn'."
429         else
430                 log error "Error saving '$fn': downloaded to '$tn'."
431         fi
432 }
433
434 history_back() { log error "Not implemented."; }
435 history_forward() { log error "Not implemented."; }
436
437 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
438         run bollux "$@"
439 else
440         BOLLUX_LOGLEVEL=DEBUG
441 fi