Handling of non-local exits and load aborting.
[elpher.git] / elopher.el
1 ;;; elopher.el --- gopher client
2
3 ;;; Commentary:
4
5 ;; Simple gopher client in elisp.
6
7 ;;; Code:
8
9 ;;; Global constants
10 ;;
11
12 (defconst elopher-version "1.0.0"
13   "Current version of elopher.")
14
15 (defconst elopher-margin-width 6
16   "Width of left-hand margin used when rendering indicies.")
17
18 (defconst elopher-start-index
19   (mapconcat
20    'identity
21    (list "i\tfake\tfake\t1"
22          "i--------------------------------------------\tfake\tfake\t1"
23          "i          Elopher Gopher Client             \tfake\tfake\t1"
24          (format "i              version %s\tfake\tfake\t1" elopher-version)
25          "i--------------------------------------------\tfake\tfake\t1"
26          "i\tfake\tfake\t1"
27          "iBasic usage:\tfake\tfake\t1"
28          "i\tfake\tfake\t1"
29          "i - tab/shift-tab: next/prev directory entry on current page\tfake\tfake\t1"
30          "i - RET/mouse-1: open directory entry under cursor\tfake\tfake\t1"
31          "i - u: return to parent directory entry\tfake\tfake\t1"
32          "i - g: go to a particular page\tfake\tfake\t1"
33          "i - r: reload current page\tfake\tfake\t1"
34          "i - d: download directory entry under cursor\tfake\tfake\t1"
35          "i - w: display the raw server response for the current page\tfake\tfake\t1"
36          "i\tfake\tfake\t1"
37          "iPlaces to start exploring Gopherspace:\tfake\tfake\t1"
38          "i\tfake\tfake\t1"
39          "1Floodgap Systems Gopher Server\t\tgopher.floodgap.com\t70"
40          "i\tfake\tfake\t1"
41          "iAlternatively, select the following item and enter some\tfake\tfake\t1"
42          "isearch terms:\tfake\tfake\t1"
43          "i\tfake\tfake\t1"
44          "7Veronica-2 Gopher Search Engine\t/v2/vs\tgopher.floodgap.com\t70"
45          ".")
46    "\r\n"))
47
48
49 ;;; Customization group
50 ;;
51
52 (defgroup elopher nil
53   "A simple gopher client."
54   :group 'applications)
55
56 (defcustom elopher-index-face '(foreground-color . "cyan")
57   "Face used for index records."
58   :type '(face))
59
60 (defcustom elopher-text-face '(foreground-color . "white")
61   "Face used for text records."
62   :type '(face))
63
64 (defcustom elopher-info-face '(foreground-color . "gray")
65   "Face used for info records."
66   :type '(face))
67
68 (defcustom elopher-image-face '(foreground-color . "green")
69   "Face used for image records."
70   :type '(face))
71
72 (defcustom elopher-search-face '(foreground-color . "orange")
73   "Face used for image records."
74   :type '(face))
75
76 (defcustom elopher-http-face '(foreground-color . "yellow")
77   "Face used for image records."
78   :type '(face))
79
80 (defcustom elopher-unknown-face '(foreground-color . "red")
81   "Face used for unknown record types."
82   :type '(face))
83
84 (defcustom elopher-open-urls-with-eww nil
85   "If non-nil, open URL selectors using eww.
86 Otherwise, use the system browser via the BROWSE-URL function."
87   :type '(boolean))
88
89 ;;; Model
90 ;;
91
92 ;; Address
93
94 (defun elopher-make-address (selector host port)
95   (list selector host port))
96
97 (defun elopher-address-selector (address)
98   (car address))
99
100 (defun elopher-address-host (address)
101   (cadr address))
102
103 (defun elopher-address-port (address)
104   (caddr address))
105
106 ;; Node
107
108 (defun elopher-make-node (parent address getter &optional content pos)
109   (list parent address getter content pos))
110
111 (defun elopher-node-parent (node)
112   (elt node 0))
113
114 (defun elopher-node-address (node)
115   (elt node 1))
116
117 (defun elopher-node-getter (node)
118   (elt node 2))
119
120 (defun elopher-node-content (node)
121   (elt node 3))
122
123 (defun elopher-node-pos (node)
124   (elt node 4))
125
126 (defun elopher-set-node-content (node content)
127   (setcar (nthcdr 3 node) content))
128
129 (defun elopher-set-node-pos (node pos)
130   (setcar (nthcdr 4 node) pos))
131
132 (defun elopher-save-pos ()
133   (when elopher-current-node
134     (elopher-set-node-pos elopher-current-node (point))))
135
136 (defun elopher-restore-pos ()
137   (let ((pos (elopher-node-pos elopher-current-node)))
138     (if pos
139         (goto-char pos)
140       (goto-char (point-min)))))
141
142 ;; Node graph traversal
143
144 (defvar elopher-current-node)
145
146 (defun elopher-visit-node (node &optional getter)
147   (elopher-save-pos)
148   (elopher-process-cleanup)
149   (setq elopher-current-node node)
150   (if getter
151       (funcall getter)
152     (funcall (elopher-node-getter node))))
153
154 (defun elopher-visit-parent-node ()
155   (let ((parent-node (elopher-node-parent elopher-current-node)))
156     (when parent-node
157       (elopher-visit-node parent-node))))
158       
159 (defun elopher-reload-current-node ()
160   (elopher-set-node-content elopher-current-node nil)
161   (elopher-visit-node elopher-current-node))
162
163 ;;; Buffer preparation
164 ;;
165
166 (defmacro elopher-with-clean-buffer (&rest args)
167   "Evaluate ARGS with a clean *elopher* buffer as current."
168   (list 'progn
169         '(switch-to-buffer "*elopher*")
170         '(elopher-mode)
171         (append (list 'let '((inhibit-read-only t))
172                       '(erase-buffer))
173                 args)))
174
175 ;;; Index rendering
176 ;;
177
178 (defun elopher-insert-index (string)
179   "Insert the index corresponding to STRING into the current buffer."
180   (dolist (line (split-string string "\r\n"))
181     (unless (= (length line) 0)
182       (elopher-insert-index-record line))))
183
184 (defun elopher-insert-margin (&optional type-name)
185   "Insert index margin, optionally containing the TYPE-NAME, into the current buffer."
186   (if type-name
187       (progn
188         (insert (format (concat "%" (number-to-string (- elopher-margin-width 1)) "s")
189                         (concat
190                          (propertize "[" 'face '(foreground-color . "blue"))
191                          (propertize type-name 'face '(foreground-color . "white"))
192                          (propertize "]" 'face '(foreground-color . "blue")))))
193         (insert " "))
194     (insert (make-string elopher-margin-width ?\s))))
195
196 (defun elopher-insert-index-record (line)
197   "Insert the index record corresponding to LINE into the current buffer."
198   (let* ((type (elt line 0))
199          (fields (split-string (substring line 1) "\t"))
200          (display-string (elt fields 0))
201          (selector (elt fields 1))
202          (host (elt fields 2))
203          (port (elt fields 3))
204          (address (elopher-make-address selector host port))
205          (help-string (format "mouse-1, RET: open %s on %s port %s"
206                               selector host port)))
207     (pcase type
208       (?i (elopher-insert-margin)
209           (insert (propertize display-string
210                               'face elopher-info-face)))
211       (?0 (elopher-insert-margin "T")
212           (insert-text-button display-string
213                               'face elopher-text-face
214                               'elopher-node (elopher-make-node elopher-current-node
215                                                                address
216                                                                #'elopher-get-text-node)
217                               'action #'elopher-click-link
218                               'follow-link t
219                               'help-echo help-string))
220       (?1 (elopher-insert-margin "/")
221           (insert-text-button display-string
222                               'face elopher-index-face
223                               'elopher-node (elopher-make-node elopher-current-node
224                                                                address
225                                                                #'elopher-get-index-node)
226                               'action #'elopher-click-link
227                               'follow-link t
228                               'help-echo help-string))
229       ((or ?g ?p ?I) (elopher-insert-margin "im")
230           (insert-text-button display-string
231                               'face elopher-image-face
232                               'elopher-node (elopher-make-node elopher-current-node
233                                                                address
234                                                                #'elopher-get-image-node)
235                               'action #'elopher-click-link
236                               'follow-link t
237                               'help-echo help-string))
238       (?7 (elopher-insert-margin "S")
239           (insert-text-button display-string
240                               'face elopher-search-face
241                               'elopher-node (elopher-make-node elopher-current-node
242                                                               address
243                                                               #'elopher-get-search-node)
244                               'action #'elopher-click-link
245                               'follow-link t
246                               'help-echo help-string))
247       (?h (elopher-insert-margin "W")
248           (let ((url (elt (split-string selector "URL:") 1)))
249             (insert-text-button display-string
250                                 'face elopher-http-face
251                                 'elopher-url url
252                                 'action #'elopher-click-url
253                                 'follow-link t
254                                 'help-echo (format "mouse-1, RET: open url %s" url))))
255       (?.) ; Occurs at end of index, can safely ignore.
256       (tp (elopher-insert-margin (concat (char-to-string tp) "?"))
257           (insert (propertize display-string
258                               'face elopher-unknown-face))))
259     (insert "\n")))
260
261
262 ;;; Selector retrieval (all kinds)
263 ;;
264
265 (defun elopher-process-cleanup ()
266   "Immediately shut down any extant elopher process."
267   (let ((p (get-process "elopher-process")))
268     (if p (delete-process p))))
269
270 (defvar elopher-selector-string)
271
272 (defun elopher-get-selector (address after)
273   "Retrieve selector specified by ADDRESS, then execute AFTER.
274 The result is stored as a string in the variable elopher-selector-string."
275   (setq elopher-selector-string "")
276   (make-network-process
277    :name "elopher-process"
278    :host (elopher-address-host address)
279    :service (elopher-address-port address)
280    :filter (lambda (proc string)
281              (setq elopher-selector-string (concat elopher-selector-string string)))
282    :sentinel after)
283   (process-send-string "elopher-process"
284                        (concat (elopher-address-selector address) "\n")))
285
286 ;; Index retrieval
287
288 (defun elopher-get-index-node ()
289   (let ((content (elopher-node-content elopher-current-node))
290         (address (elopher-node-address elopher-current-node)))
291     (if content
292         (progn
293           (elopher-with-clean-buffer
294            (insert content))
295           (elopher-restore-pos))
296       (if address
297           (progn
298             (elopher-with-clean-buffer
299              (insert "LOADING DIRECTORY..."))
300             (elopher-get-selector address
301                                   (lambda (proc event)
302                                     (unless (string-prefix-p "deleted" event)
303                                       (message event)
304                                       (elopher-with-clean-buffer
305                                        (elopher-insert-index elopher-selector-string))
306                                       (elopher-restore-pos)
307                                       (elopher-set-node-content elopher-current-node
308                                                                 (buffer-string))))))
309         (progn
310           (elopher-with-clean-buffer
311            (elopher-insert-index elopher-start-index))
312           (elopher-restore-pos)
313           (elopher-set-node-content elopher-current-node
314                                     (buffer-string)))))))
315
316 ;; Text retrieval
317
318 (defun elopher-process-text (string)
319   (let ((chopped-str (replace-regexp-in-string "\r\n\.\r\n$" "\r\n" string)))
320     (replace-regexp-in-string "\r" "" chopped-str)))
321
322 (defun elopher-get-text-node ()
323   (let ((content (elopher-node-content elopher-current-node))
324         (address (elopher-node-address elopher-current-node)))
325     (if content
326         (progn
327           (elopher-with-clean-buffer
328            (insert content))
329           (elopher-restore-pos))
330       (progn
331         (elopher-with-clean-buffer
332          (insert "LOADING TEXT..."))
333         (elopher-get-selector address
334                               (lambda (proc event)
335                                 (unless (string-prefix-p "deleted" event)
336                                   (elopher-with-clean-buffer
337                                    (insert (elopher-process-text elopher-selector-string)))
338                                   (elopher-restore-pos)
339                                   (elopher-set-node-content elopher-current-node
340                                                             (buffer-string)))))))))
341
342 ;; Image retrieval
343
344 (defun elopher-get-image-node ()
345   (let ((content (elopher-node-content elopher-current-node))
346         (address (elopher-node-address elopher-current-node)))
347     (if content
348         (progn
349           (elopher-with-clean-buffer
350            (insert-image content))
351           (setq cursor-type nil)
352           (elopher-restore-pos))
353       (progn
354         (elopher-with-clean-buffer
355          (insert "LOADING IMAGE..."))
356         (elopher-get-selector address
357                               (lambda (proc event)
358                                 (unless (string-prefix-p "deleted" event)
359                                   (let ((image (create-image
360                                                 (string-as-unibyte elopher-selector-string)
361                                                 nil t)))
362                                     (elopher-with-clean-buffer
363                                      (insert-image image))
364                                     (setq cursor-type nil)
365                                     (elopher-restore-pos)
366                                     (elopher-set-node-content elopher-current-node
367                                                               image)))))))))
368
369 ;; Search retrieval
370
371 (defun elopher-get-search-node ()
372   (let* ((content (elopher-node-content elopher-current-node))
373          (address (elopher-node-address elopher-current-node)))
374     (if content
375         (progn
376           (elopher-with-clean-buffer
377             (insert content))
378           (elopher-restore-pos)
379           (message "Displaying cached search results.  Reload to perform a new search."))
380       (let* ((query-string (read-string "Query: "))
381              (query-selector (concat (elopher-address-selector address) "\t" query-string))
382              (search-address (elopher-make-address query-selector
383                                                    (elopher-address-host address)
384                                                    (elopher-address-port address))))
385         (elopher-with-clean-buffer
386          (insert "LOADING RESULTS..."))
387         (elopher-get-selector search-address
388                               (lambda (proc event)
389                                 (unless (string-prefix-p "deleted" event)
390                                   (elopher-with-clean-buffer
391                                    (elopher-insert-index elopher-selector-string))
392                                   (goto-char (point-min))
393                                   (elopher-set-node-content elopher-current-node
394                                                             (buffer-string)))))))))
395
396 ;; Raw server response retrieval
397
398 (defun elopher-get-node-raw ()
399   (let* ((content (elopher-node-content elopher-current-node))
400          (address (elopher-node-address elopher-current-node)))
401     (elopher-with-clean-buffer
402      (insert "LOADING RAW SERVER RESPONSE..."))
403     (if address
404         (elopher-get-selector address
405                               (lambda (proc event)
406                                 (unless (string-prefix-p "deleted" event)
407                                   (elopher-with-clean-buffer
408                                    (insert elopher-selector-string))
409                                   (goto-char (point-min)))))
410       (progn
411         (elopher-with-clean-buffer
412          (insert elopher-start-index))
413         (goto-char (point-min)))))
414   (message "Displaying raw server response.  Reload to return to standard view."))
415  
416
417 ;; File export retrieval
418
419 (defvar elopher-download-filename)
420
421 (defun elopher-get-node-download ()
422   (let* ((address (elopher-node-address elopher-current-node))
423          (selector (elopher-address-selector address)))
424     (unwind-protect
425         (let* ((filename-proposal (file-name-nondirectory selector))
426                (filename (read-file-name "Save file as: "
427                                          nil nil nil
428                                          (if (> (length filename-proposal) 0)
429                                              filename-proposal
430                                            "gopher.file"))))
431           (message "Downloading...")
432           (setq elopher-download-filename filename)
433           (elopher-get-selector address
434                                 (lambda (proc event)
435                                   (let ((coding-system-for-write 'binary))
436                                     (with-temp-file elopher-download-filename
437                                       (insert elopher-selector-string)))
438                                   (message (format "Download complate, saved to file %s."
439                                                    elopher-download-filename)))))
440       (elopher-visit-parent-node))))
441         
442
443 ;;; Navigation procedures
444 ;;
445
446 (defun elopher-next-link ()
447   (interactive)
448   (forward-button 1))
449
450 (defun elopher-prev-link ()
451   (interactive)
452   (backward-button 1))
453
454 (defun elopher-click-link (button)
455   (let ((node (button-get button 'elopher-node)))
456     (elopher-visit-node node)))
457
458 (defun elopher-click-url (button)
459   (let ((url (button-get button 'elopher-url)))
460     (if elopher-open-urls-with-eww
461         (browse-web url)
462       (browse-url url))))
463
464 (defun elopher-follow-closest-link ()
465   (interactive)
466   (push-button))
467
468 (defun elopher-go ()
469   "Go to a particular gopher site."
470   (interactive)
471   (let* (
472          (hostname (read-string "Gopher host: "))
473          (selector (read-string "Selector (default none): " nil nil ""))
474          (port (read-string "Port (default 70): " nil nil 70))
475          (address (list selector hostname port)))
476     (elopher-visit-node
477      (elopher-make-node elopher-current-node
478                         address
479                         #'elopher-get-index-node))))
480
481 (defun  elopher-reload ()
482   "Reload current page."
483   (interactive)
484   (elopher-reload-current-node))
485
486 (defun elopher-view-raw ()
487   "View current page as plain text."
488   (interactive)
489   (elopher-visit-node elopher-current-node
490                       #'elopher-get-node-raw))
491
492 (defun elopher-back ()
493   "Go to previous site."
494   (interactive)
495   (if (elopher-node-parent elopher-current-node)
496       (elopher-visit-parent-node)
497     (message "No previous site.")))
498
499 (defun elopher-download ()
500   "Download the link at point."
501   (interactive)
502   (let ((button (button-at (point))))
503     (if button
504         (elopher-visit-node (button-get button 'elopher-node)
505                             #'elopher-get-node-download)
506       (message "No link selected."))))
507
508
509 ;;; Mode and keymap
510 ;;
511
512 (defvar elopher-mode-map
513   (let ((map (make-sparse-keymap)))
514     (define-key map (kbd "<tab>") 'elopher-next-link)
515     (define-key map (kbd "<S-tab>") 'elopher-prev-link)
516     (define-key map (kbd "u") 'elopher-back)
517     (define-key map (kbd "g") 'elopher-go)
518     (define-key map (kbd "r") 'elopher-reload)
519     (define-key map (kbd "w") 'elopher-view-raw)
520     (define-key map (kbd "d") 'elopher-download)
521     (when (fboundp 'evil-define-key)
522       (evil-define-key 'normal map
523         (kbd "C-]") 'elopher-follow-closest-link
524         (kbd "C-t") 'elopher-back
525         (kbd "u") 'elopher-back
526         (kbd "g") 'elopher-go
527         (kbd "r") 'elopher-reload
528         (kbd "w") 'elopher-view-raw
529         (kbd "d") 'elopher-download))
530     map)
531   "Keymap for gopher client.")
532
533 (define-derived-mode elopher-mode special-mode "elopher"
534   "Major mode for elopher, an elisp gopher client.")
535
536
537 ;;; Main start procedure
538 ;;
539
540 (defun elopher ()
541   "Start elopher with default landing page."
542   (interactive)
543   (setq elopher-current-node nil)
544   (let ((start-node (elopher-make-node nil nil #'elopher-get-index-node)))
545     (elopher-visit-node start-node))
546   "Started Elopher.") ; Otherwise (elopher) evaluates to start page string.
547
548 ;;; elopher.el ends here
549