Added to readme wishlist.
[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   (setq elopher-current-node node)
149   (if getter
150       (funcall getter)
151     (funcall (elopher-node-getter node))))
152
153 (defun elopher-visit-parent-node ()
154   (let ((parent-node (elopher-node-parent elopher-current-node)))
155     (when parent-node
156       (elopher-visit-node parent-node))))
157       
158 (defun elopher-reload-current-node ()
159   (elopher-set-node-content elopher-current-node nil)
160   (elopher-visit-node elopher-current-node))
161
162 ;;; Buffer preparation
163 ;;
164
165 (defmacro elopher-with-clean-buffer (&rest args)
166   (list 'progn
167         '(switch-to-buffer "*elopher*")
168         '(elopher-mode)
169         (append (list 'let '((inhibit-read-only t))
170                       '(erase-buffer))
171                 args)))
172
173 ;;; Index rendering
174 ;;
175
176 (defun elopher-insert-index (string)
177   "Insert the index corresponding to STRING into the current buffer."
178   (dolist (line (split-string string "\r\n"))
179     (unless (= (length line) 0)
180       (elopher-insert-index-record line))))
181
182 (defun elopher-insert-margin (&optional type-name)
183   "Insert index margin, optionally containing the TYPE-NAME, into the current buffer."
184   (if type-name
185       (progn
186         (insert (format (concat "%" (number-to-string (- elopher-margin-width 1)) "s")
187                         (concat
188                          (propertize "[" 'face '(foreground-color . "blue"))
189                          (propertize type-name 'face '(foreground-color . "white"))
190                          (propertize "]" 'face '(foreground-color . "blue")))))
191         (insert " "))
192     (insert (make-string elopher-margin-width ?\s))))
193
194 (defun elopher-insert-index-record (line)
195   "Insert the index record corresponding to LINE into the current buffer."
196   (let* ((type (elt line 0))
197          (fields (split-string (substring line 1) "\t"))
198          (display-string (elt fields 0))
199          (selector (elt fields 1))
200          (host (elt fields 2))
201          (port (elt fields 3))
202          (address (elopher-make-address selector host port))
203          (help-string (format "mouse-1, RET: open %s on %s port %s"
204                               selector host port)))
205     (pcase type
206       (?i (elopher-insert-margin)
207           (insert (propertize display-string
208                               'face elopher-info-face)))
209       (?0 (elopher-insert-margin "T")
210           (insert-text-button display-string
211                               'face elopher-text-face
212                               'elopher-node (elopher-make-node elopher-current-node
213                                                                address
214                                                                #'elopher-get-text-node)
215                               'action #'elopher-click-link
216                               'follow-link t
217                               'help-echo help-string))
218       (?1 (elopher-insert-margin "/")
219           (insert-text-button display-string
220                               'face elopher-index-face
221                               'elopher-node (elopher-make-node elopher-current-node
222                                                                address
223                                                                #'elopher-get-index-node)
224                               'action #'elopher-click-link
225                               'follow-link t
226                               'help-echo help-string))
227       ((or ?g ?p ?I) (elopher-insert-margin "im")
228           (insert-text-button display-string
229                               'face elopher-image-face
230                               'elopher-node (elopher-make-node elopher-current-node
231                                                                address
232                                                                #'elopher-get-image-node)
233                               'action #'elopher-click-link
234                               'follow-link t
235                               'help-echo help-string))
236       (?7 (elopher-insert-margin "S")
237           (insert-text-button display-string
238                               'face elopher-search-face
239                               'elopher-node (elopher-make-node elopher-current-node
240                                                               address
241                                                               #'elopher-get-search-node)
242                               'action #'elopher-click-link
243                               'follow-link t
244                               'help-echo help-string))
245       (?h (elopher-insert-margin "W")
246           (let ((url (elt (split-string selector "URL:") 1)))
247             (insert-text-button display-string
248                                 'face elopher-http-face
249                                 'elopher-url url
250                                 'action #'elopher-click-url
251                                 'follow-link t
252                                 'help-echo (format "mouse-1, RET: open url %s" url))))
253       (?.) ; Occurs at end of index, can safely ignore.
254       (tp (elopher-insert-margin (concat (char-to-string tp) "?"))
255           (insert (propertize display-string
256                               'face elopher-unknown-face))))
257     (insert "\n")))
258
259
260 ;;; Selector retrieval (all kinds)
261 ;;
262
263 (defvar elopher-selector-string)
264
265 (defun elopher-get-selector (address after)
266   "Retrieve selector specified by ADDRESS, then execute AFTER.
267 The result is stored as a string in the variable elopher-selector-string."
268   (setq elopher-selector-string "")
269   (let ((p (get-process "elopher-process")))
270     (if p (delete-process p)))
271   (make-network-process
272    :name "elopher-process"
273    :host (elopher-address-host address)
274    :service (elopher-address-port address)
275    :filter (lambda (proc string)
276              (setq elopher-selector-string (concat elopher-selector-string string)))
277    :sentinel after)
278   (process-send-string "elopher-process"
279                        (concat (elopher-address-selector address) "\n")))
280
281 ;; Index retrieval
282
283 (defun elopher-get-index-node ()
284   (let ((content (elopher-node-content elopher-current-node))
285         (address (elopher-node-address elopher-current-node)))
286     (if content
287         (progn
288           (elopher-with-clean-buffer
289             (insert content))
290           (elopher-restore-pos))
291       (if address
292           (progn
293             (elopher-with-clean-buffer
294              (insert "LOADING DIRECTORY..."))
295             (elopher-get-selector address
296                                   (lambda (proc event)
297                                     (elopher-with-clean-buffer
298                                       (elopher-insert-index elopher-selector-string))
299                                     (elopher-restore-pos)
300                                     (elopher-set-node-content elopher-current-node
301                                                               (buffer-string)))))
302         (progn
303           (elopher-with-clean-buffer
304             (elopher-insert-index elopher-start-index))
305           (elopher-restore-pos)
306           (elopher-set-node-content elopher-current-node
307                                     (buffer-string)))))))
308
309 ;; Text retrieval
310
311 (defun elopher-process-text (string)
312   (let ((chopped-str (replace-regexp-in-string "\r\n\.\r\n$" "\r\n" string)))
313     (replace-regexp-in-string "\r" "" chopped-str)))
314
315 (defun elopher-get-text-node ()
316   (let ((content (elopher-node-content elopher-current-node))
317         (address (elopher-node-address elopher-current-node)))
318     (if content
319         (progn
320           (elopher-with-clean-buffer
321             (insert content))
322           (elopher-restore-pos))
323       (progn
324         (elopher-with-clean-buffer
325           (insert "LOADING TEXT..."))
326         (elopher-get-selector address
327                               (lambda (proc event)
328                                 (elopher-with-clean-buffer
329                                   (insert (elopher-process-text elopher-selector-string)))
330                                 (elopher-restore-pos)
331                                 (elopher-set-node-content elopher-current-node
332                                                           (buffer-string))))))))
333
334 ;; Image retrieval
335
336 (defun elopher-get-image-node ()
337   (let ((content (elopher-node-content elopher-current-node))
338         (address (elopher-node-address elopher-current-node)))
339     (if content
340         (progn
341           (elopher-with-clean-buffer
342             (insert-image content))
343           (setq cursor-type nil)
344           (elopher-restore-pos))
345       (progn
346         (elopher-with-clean-buffer
347           (insert "LOADING IMAGE..."))
348         (elopher-get-selector address
349                               (lambda (proc event)
350                                 (let ((image (create-image
351                                               (string-as-unibyte elopher-selector-string)
352                                               nil t)))
353                                   (elopher-with-clean-buffer
354                                    (insert-image image))
355                                   (setq cursor-type nil)
356                                   (elopher-restore-pos)
357                                   (elopher-set-node-content elopher-current-node image))))))))
358
359 ;; Search retrieval
360
361 (defun elopher-get-search-node ()
362   (let* ((content (elopher-node-content elopher-current-node))
363          (address (elopher-node-address elopher-current-node)))
364     (if content
365         (progn
366           (elopher-with-clean-buffer
367             (insert content))
368           (elopher-restore-pos)
369           (message "Displaying cached search results.  Reload to perform a new search."))
370       (let* ((query-string (read-string "Query: "))
371              (query-selector (concat (elopher-address-selector address) "\t" query-string))
372              (search-address (elopher-make-address query-selector
373                                                    (elopher-address-host address)
374                                                    (elopher-address-port address))))
375         (elopher-with-clean-buffer
376          (insert "LOADING RESULTS..."))
377         (elopher-get-selector search-address
378                               (lambda (proc event)
379                                 (elopher-with-clean-buffer
380                                   (elopher-insert-index elopher-selector-string))
381                                 (goto-char (point-min))
382                                 (elopher-set-node-content elopher-current-node
383                                                           (buffer-string))))))))
384
385 ;; Raw server response retrieval
386
387 (defun elopher-get-node-raw ()
388   (let* ((content (elopher-node-content elopher-current-node))
389          (address (elopher-node-address elopher-current-node)))
390     (elopher-with-clean-buffer
391      (insert "LOADING RAW SERVER RESPONSE..."))
392     (if address
393         (elopher-get-selector address
394                               (lambda (proc event)
395                                 (elopher-with-clean-buffer
396                                  (insert elopher-selector-string))
397                                 (goto-char (point-min))))
398       (progn
399         (elopher-with-clean-buffer
400          (insert elopher-start-index))
401         (goto-char (point-min)))))
402   (message "Displaying raw server response.  Reload to return to standard view."))
403  
404
405 ;; File export retrieval
406
407 (defvar elopher-download-filename)
408
409 (defun elopher-get-node-download ()
410   (let* ((address (elopher-node-address elopher-current-node))
411          (selector (elopher-address-selector address)))
412     (unwind-protect
413         (let* ((filename-proposal (file-name-nondirectory selector))
414                (filename (read-file-name "Save file as: "
415                                          nil nil nil
416                                          (if (> (length filename-proposal) 0)
417                                              filename-proposal
418                                            "gopher.file"))))
419           (message "Downloading...")
420           (setq elopher-download-filename filename)
421           (elopher-get-selector address
422                                 (lambda (proc event)
423                                   (let ((coding-system-for-write 'binary))
424                                     (with-temp-file elopher-download-filename
425                                       (insert elopher-selector-string)))
426                                   (message (format "Download complate, saved to file %s."
427                                                    elopher-download-filename)))))
428       (elopher-visit-parent-node))))
429         
430
431 ;;; Navigation procedures
432 ;;
433
434 (defun elopher-next-link ()
435   (interactive)
436   (forward-button 1))
437
438 (defun elopher-prev-link ()
439   (interactive)
440   (backward-button 1))
441
442 (defun elopher-click-link (button)
443   (let ((node (button-get button 'elopher-node)))
444     (elopher-visit-node node)))
445
446 (defun elopher-click-url (button)
447   (let ((url (button-get button 'elopher-url)))
448     (if elopher-open-urls-with-eww
449         (browse-web url)
450       (browse-url url))))
451
452 (defun elopher-follow-closest-link ()
453   (interactive)
454   (push-button))
455
456 (defun elopher-go ()
457   "Go to a particular gopher site."
458   (interactive)
459   (let* (
460          (hostname (read-string "Gopher host: "))
461          (selector (read-string "Selector (default none): " nil nil ""))
462          (port (read-string "Port (default 70): " nil nil 70))
463          (address (list selector hostname port)))
464     (elopher-visit-node
465      (elopher-make-node elopher-current-node
466                         address
467                         #'elopher-get-index-node))))
468
469 (defun  elopher-reload ()
470   "Reload current page."
471   (interactive)
472   (elopher-reload-current-node))
473
474 (defun elopher-view-raw ()
475   "View current page as plain text."
476   (interactive)
477   (elopher-visit-node elopher-current-node
478                       #'elopher-get-node-raw))
479
480 (defun elopher-back ()
481   "Go to previous site."
482   (interactive)
483   (if (elopher-node-parent elopher-current-node)
484       (elopher-visit-parent-node)
485     (message "No previous site.")))
486
487 (defun elopher-download ()
488   "Download the link at point."
489   (interactive)
490   (let ((button (button-at (point))))
491     (if button
492         (elopher-visit-node (button-get button 'elopher-node)
493                             #'elopher-get-node-download)
494       (message "No link selected."))))
495
496
497 ;;; Mode and keymap
498 ;;
499
500 (defvar elopher-mode-map
501   (let ((map (make-sparse-keymap)))
502     (define-key map (kbd "<tab>") 'elopher-next-link)
503     (define-key map (kbd "<S-tab>") 'elopher-prev-link)
504     (define-key map (kbd "u") 'elopher-back)
505     (define-key map (kbd "g") 'elopher-go)
506     (define-key map (kbd "r") 'elopher-reload)
507     (define-key map (kbd "w") 'elopher-view-raw)
508     (define-key map (kbd "d") 'elopher-download)
509     (when (fboundp 'evil-define-key)
510       (evil-define-key 'normal map
511         (kbd "C-]") 'elopher-follow-closest-link
512         (kbd "C-t") 'elopher-back
513         (kbd "u") 'elopher-back
514         (kbd "g") 'elopher-go
515         (kbd "r") 'elopher-reload
516         (kbd "w") 'elopher-view-raw
517         (kbd "d") 'elopher-download))
518     map)
519   "Keymap for gopher client.")
520
521 (define-derived-mode elopher-mode special-mode "elopher"
522   "Major mode for elopher, an elisp gopher client.")
523
524
525 ;;; Main start procedure
526 ;;
527
528 (defun elopher ()
529   "Start elopher with default landing page."
530   (interactive)
531   (setq elopher-current-node nil)
532   (let ((start-node (elopher-make-node nil nil #'elopher-get-index-node)))
533     (elopher-visit-node start-node))
534   "Started Elopher.") ; Otherwise (elopher) evaluates to start page string.
535
536 ;;; elopher.el ends here
537