8985bfee9d642033b103c8a446090e8957535635
[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                                       (elopher-with-clean-buffer
304                                        (elopher-insert-index elopher-selector-string))
305                                       (elopher-restore-pos)
306                                       (elopher-set-node-content elopher-current-node
307                                                                 (buffer-string))))))
308         (progn
309           (elopher-with-clean-buffer
310            (elopher-insert-index elopher-start-index))
311           (elopher-restore-pos)
312           (elopher-set-node-content elopher-current-node
313                                     (buffer-string)))))))
314
315 ;; Text retrieval
316
317 (defun elopher-process-text (string)
318   (let ((chopped-str (replace-regexp-in-string "\r\n\.\r\n$" "\r\n" string)))
319     (replace-regexp-in-string "\r" "" chopped-str)))
320
321 (defun elopher-get-text-node ()
322   (let ((content (elopher-node-content elopher-current-node))
323         (address (elopher-node-address elopher-current-node)))
324     (if content
325         (progn
326           (elopher-with-clean-buffer
327            (insert content))
328           (elopher-restore-pos))
329       (progn
330         (elopher-with-clean-buffer
331          (insert "LOADING TEXT..."))
332         (elopher-get-selector address
333                               (lambda (proc event)
334                                 (unless (string-prefix-p "deleted" event)
335                                   (elopher-with-clean-buffer
336                                    (insert (elopher-process-text elopher-selector-string)))
337                                   (elopher-restore-pos)
338                                   (elopher-set-node-content elopher-current-node
339                                                             (buffer-string)))))))))
340
341 ;; Image retrieval
342
343 (defun elopher-get-image-node ()
344   (let ((content (elopher-node-content elopher-current-node))
345         (address (elopher-node-address elopher-current-node)))
346     (if content
347         (progn
348           (elopher-with-clean-buffer
349            (insert-image content))
350           (setq cursor-type nil)
351           (elopher-restore-pos))
352       (progn
353         (elopher-with-clean-buffer
354          (insert "LOADING IMAGE..."))
355         (elopher-get-selector address
356                               (lambda (proc event)
357                                 (unless (string-prefix-p "deleted" event)
358                                   (let ((image (create-image
359                                                 (string-as-unibyte elopher-selector-string)
360                                                 nil t)))
361                                     (elopher-with-clean-buffer
362                                      (insert-image image))
363                                     (setq cursor-type nil)
364                                     (elopher-restore-pos)
365                                     (elopher-set-node-content elopher-current-node
366                                                               image)))))))))
367
368 ;; Search retrieval
369
370 (defun elopher-get-search-node ()
371   (let* ((content (elopher-node-content elopher-current-node))
372          (address (elopher-node-address elopher-current-node)))
373     (if content
374         (progn
375           (elopher-with-clean-buffer
376             (insert content))
377           (elopher-restore-pos)
378           (message "Displaying cached search results.  Reload to perform a new search."))
379       (let* ((query-string (read-string "Query: "))
380              (query-selector (concat (elopher-address-selector address) "\t" query-string))
381              (search-address (elopher-make-address query-selector
382                                                    (elopher-address-host address)
383                                                    (elopher-address-port address))))
384         (elopher-with-clean-buffer
385          (insert "LOADING RESULTS..."))
386         (elopher-get-selector search-address
387                               (lambda (proc event)
388                                 (unless (string-prefix-p "deleted" event)
389                                   (elopher-with-clean-buffer
390                                    (elopher-insert-index elopher-selector-string))
391                                   (goto-char (point-min))
392                                   (elopher-set-node-content elopher-current-node
393                                                             (buffer-string)))))))))
394
395 ;; Raw server response retrieval
396
397 (defun elopher-get-node-raw ()
398   (let* ((content (elopher-node-content elopher-current-node))
399          (address (elopher-node-address elopher-current-node)))
400     (elopher-with-clean-buffer
401      (insert "LOADING RAW SERVER RESPONSE..."))
402     (if address
403         (elopher-get-selector address
404                               (lambda (proc event)
405                                 (unless (string-prefix-p "deleted" event)
406                                   (elopher-with-clean-buffer
407                                    (insert elopher-selector-string))
408                                   (goto-char (point-min)))))
409       (progn
410         (elopher-with-clean-buffer
411          (insert elopher-start-index))
412         (goto-char (point-min)))))
413   (message "Displaying raw server response.  Reload to return to standard view."))
414  
415
416 ;; File export retrieval
417
418 (defvar elopher-download-filename)
419
420 (defun elopher-get-node-download ()
421   (let* ((address (elopher-node-address elopher-current-node))
422          (selector (elopher-address-selector address)))
423     (unwind-protect
424         (let* ((filename-proposal (file-name-nondirectory selector))
425                (filename (read-file-name "Save file as: "
426                                          nil nil nil
427                                          (if (> (length filename-proposal) 0)
428                                              filename-proposal
429                                            "gopher.file"))))
430           (message "Downloading...")
431           (setq elopher-download-filename filename)
432           (elopher-get-selector address
433                                 (lambda (proc event)
434                                   (let ((coding-system-for-write 'binary))
435                                     (with-temp-file elopher-download-filename
436                                       (insert elopher-selector-string)))
437                                   (message (format "Download complate, saved to file %s."
438                                                    elopher-download-filename)))))
439       (elopher-visit-parent-node))))
440         
441
442 ;;; Navigation procedures
443 ;;
444
445 (defun elopher-next-link ()
446   (interactive)
447   (forward-button 1))
448
449 (defun elopher-prev-link ()
450   (interactive)
451   (backward-button 1))
452
453 (defun elopher-click-link (button)
454   (let ((node (button-get button 'elopher-node)))
455     (elopher-visit-node node)))
456
457 (defun elopher-click-url (button)
458   (let ((url (button-get button 'elopher-url)))
459     (if elopher-open-urls-with-eww
460         (browse-web url)
461       (browse-url url))))
462
463 (defun elopher-follow-closest-link ()
464   (interactive)
465   (push-button))
466
467 (defun elopher-go ()
468   "Go to a particular gopher site."
469   (interactive)
470   (let* (
471          (hostname (read-string "Gopher host: "))
472          (selector (read-string "Selector (default none): " nil nil ""))
473          (port (read-string "Port (default 70): " nil nil 70))
474          (address (list selector hostname port)))
475     (elopher-visit-node
476      (elopher-make-node elopher-current-node
477                         address
478                         #'elopher-get-index-node))))
479
480 (defun  elopher-reload ()
481   "Reload current page."
482   (interactive)
483   (elopher-reload-current-node))
484
485 (defun elopher-view-raw ()
486   "View current page as plain text."
487   (interactive)
488   (elopher-visit-node elopher-current-node
489                       #'elopher-get-node-raw))
490
491 (defun elopher-back ()
492   "Go to previous site."
493   (interactive)
494   (if (elopher-node-parent elopher-current-node)
495       (elopher-visit-parent-node)
496     (message "No previous site.")))
497
498 (defun elopher-download ()
499   "Download the link at point."
500   (interactive)
501   (let ((button (button-at (point))))
502     (if button
503         (elopher-visit-node (button-get button 'elopher-node)
504                             #'elopher-get-node-download)
505       (message "No link selected."))))
506
507
508 ;;; Mode and keymap
509 ;;
510
511 (defvar elopher-mode-map
512   (let ((map (make-sparse-keymap)))
513     (define-key map (kbd "<tab>") 'elopher-next-link)
514     (define-key map (kbd "<S-tab>") 'elopher-prev-link)
515     (define-key map (kbd "u") 'elopher-back)
516     (define-key map (kbd "g") 'elopher-go)
517     (define-key map (kbd "r") 'elopher-reload)
518     (define-key map (kbd "w") 'elopher-view-raw)
519     (define-key map (kbd "d") 'elopher-download)
520     (when (fboundp 'evil-define-key)
521       (evil-define-key 'normal map
522         (kbd "C-]") 'elopher-follow-closest-link
523         (kbd "C-t") 'elopher-back
524         (kbd "u") 'elopher-back
525         (kbd "g") 'elopher-go
526         (kbd "r") 'elopher-reload
527         (kbd "w") 'elopher-view-raw
528         (kbd "d") 'elopher-download))
529     map)
530   "Keymap for gopher client.")
531
532 (define-derived-mode elopher-mode special-mode "elopher"
533   "Major mode for elopher, an elisp gopher client.")
534
535
536 ;;; Main start procedure
537 ;;
538
539 (defun elopher ()
540   "Start elopher with default landing page."
541   (interactive)
542   (setq elopher-current-node nil)
543   (let ((start-node (elopher-make-node nil nil #'elopher-get-index-node)))
544     (elopher-visit-node start-node))
545   "Started Elopher.") ; Otherwise (elopher) evaluates to start page string.
546
547 ;;; elopher.el ends here
548