Merge.
authorTim Vaughan <tgvaughan@gmail.com>
Fri, 14 Jun 2019 11:52:13 +0000 (13:52 +0200)
committerTim Vaughan <tgvaughan@gmail.com>
Fri, 14 Jun 2019 11:52:47 +0000 (13:52 +0200)
1  2 
elpher.el

diff --combined elpher.el
+++ b/elpher.el
@@@ -4,7 -4,7 +4,7 @@@
  
  ;; Author: Tim Vaughan <tgvaughan@gmail.com>
  ;; Created: 11 April 2019
 -;; Version: 1.1.1
 +;; Version: 1.2.0
  ;; Keywords: comm gopher
  ;; Homepage: https://github.com/tgvaughan/elpher
  ;; Package-Requires: ((emacs "25"))
@@@ -29,7 -29,7 +29,7 @@@
  ;; Elpher aims to provide a full-featured gopher client for GNU Emacs.
  ;; It supports:
  
 -;; - intuitive keyboard and mouse-driven browsing,
 +;; - intuitive keyboard and mouse-driven interface,
  ;; - caching of visited sites (both content and cursor position),
  ;; - pleasant and configurable colouring of Gopher directories,
  ;; - direct visualisation of image files,
  ;;; Code:
  
  (provide 'elpher)
 +(require 'seq)
 +(require 'pp)
  
  ;;; Global constants
  ;;
  
 -(defconst elpher-version "1.1.1"
 +(defconst elpher-version "1.2.0"
    "Current version of elpher.")
  
  (defconst elpher-margin-width 6
           "i\tfake\tfake\t1"
           "iUsage:\tfake\tfake\t1"
           "i\tfake\tfake\t1"
 -         "i - tab/shift-tab: next/prev directory entry on current page\tfake\tfake\t1"
 -         "i - RET/mouse-1: open directory entry under cursor\tfake\tfake\t1"
 -         "i - m: select a directory entry by name (autocompletes)\tfake\tfake\t1"
 -         "i - u: return to parent directory entry\tfake\tfake\t1"
 -         "i - O: visit the root directory of the current server\tfake\tfake\t1"
 -         "i - g: go to a particular page\tfake\tfake\t1"
 +         "i - tab/shift-tab: next/prev item on current page\tfake\tfake\t1"
 +         "i - RET/mouse-1: open item under cursor\tfake\tfake\t1"
 +         "i - m: select an item on current page by name (autocompletes)\tfake\tfake\t1"
 +         "i - u: return to parent\tfake\tfake\t1"
 +         "i - O: visit the root menu of the current server\tfake\tfake\t1"
 +         "i - g: go to a particular menu or item\tfake\tfake\t1"
 +         "i - i/I: info on item under cursor or current page\tfake\tfake\t1"
 +         "i - c/C: copy URL representation of item under cursor or current page\tfake\tfake\t1"
 +         "i - a/A: bookmark the item under cursor or current page\tfake\tfake\t1"
 +         "i - x/X: remove bookmark for item under cursor or current page\tfake\tfake\t1"
 +         "i - B: visit the bookmarks page\tfake\tfake\t1"
           "i - r: redraw current page (using cached contents if available)\tfake\tfake\t1"
           "i - R: reload current page (regenerates cache)\tfake\tfake\t1"
           "i - d: download directory entry under cursor\tfake\tfake\t1"
           "i - w: display the raw server response for the current page\tfake\tfake\t1"
           "i\tfake\tfake\t1"
 -         "iPlaces to start exploring Gopherspace:\tfake\tfake\t1"
 +         "iWhere to start exploring Gopherspace:\tfake\tfake\t1"
           "i\tfake\tfake\t1"
 -         "1Floodgap Systems Gopher Server\t\tgopher.floodgap.com\t70"
 +         "1Floodgap Systems Gopher Server\t/\tgopher.floodgap.com\t70"
           "i\tfake\tfake\t1"
           "iAlternatively, select the following item and enter some\tfake\tfake\t1"
           "isearch terms:\tfake\tfake\t1"
  (defconst elpher-type-map
    '((?0 elpher-get-text-node "T" elpher-text)
      (?1 elpher-get-index-node "/" elpher-index)
 -    (?g elpher-get-image-node "im" elpher-image)
 -    (?p elpher-get-image-node "im" elpher-image)
 -    (?I elpher-get-image-node "im" elpher-image)
      (?4 elpher-get-node-download "B" elpher-binary)
      (?5 elpher-get-node-download "B" elpher-binary)
 +    (?7 elpher-get-search-node "?" elpher-search)
 +    (?8 elpher-get-telnet-node "?" elpher-telnet)
      (?9 elpher-get-node-download "B" elpher-binary)
 -    (?7 elpher-get-search-node "?" elpher-search))
 +    (?g elpher-get-image-node "im" elpher-image)
 +    (?p elpher-get-image-node "im" elpher-image)
 +    (?I elpher-get-image-node "im" elpher-image)
 +    (?h elpher-get-url-node "W" elpher-url)
 +    (bookmarks elpher-get-bookmarks-node "#" elpher-index)
 +    (start elpher-get-start-node "#" elpher-index))
    "Association list from types to getters, margin codes and index faces.")
  
  
  ;; Face customizations
  
  (defface elpher-index
 -  '((t :inherit org-drawer))
 +  '((t :inherit font-lock-keyword-face))
    "Face used for directory type directory records.")
  
  (defface elpher-text
 -  '((t :inherit org-tag))
 +  '((t :inherit bold))
    "Face used for text type directory records.")
  
  (defface elpher-info
 -  '((t :inherit org-default))
 +  '((t :inherit default))
    "Face used for info type directory records.")
  
  (defface elpher-image
 -  '((t :inherit org-level-4))
 +  '((t :inherit font-lock-string-face))
    "Face used for image type directory records.")
  
  (defface elpher-search
 -  '((t :inherit org-level-5))
 +  '((t :inherit warning))
    "Face used for search type directory records.")
  
  (defface elpher-url
 -  '((t :inherit org-level-6))
 +  '((t :inherit font-lock-comment-face))
    "Face used for url type directory records.")
  
 +(defface elpher-telnet
 +  '((t :inherit font-lock-function-name-face))
 +  "Face used for telnet type directory records.")
 +
  (defface elpher-binary
 -  '((t :inherit org-level-7))
 +  '((t :inherit font-lock-doc-face))
    "Face used for binary type directory records.")
  
  (defface elpher-unknown
 -  '((t :inherit org-warning))
 +  '((t :inherit error))
    "Face used for directory records with unknown/unsupported types.")
  
  (defface elpher-margin-key
 -  '((t :inherit org-tag))
 +  '((t :inherit bold))
    "Face used for directory margin key.")
  
  (defface elpher-margin-brackets
 -  '((t :inherit org-special-keyword))
 +  '((t :inherit shadow))
    "Face used for brackets around directory margin key.")
  
  ;; Other customizations
@@@ -185,80 -170,73 +185,83 @@@ Otherwise, use the system browser via t
    "If non-nil, cache images in memory in the same way as other content."
    :type '(boolean))
  
 -(defcustom elpher-start-address nil
 -  "If nil, the default start directory is shown when Elpher is started.
 -Otherwise, a list containing the selector, host and port of a directory to
 -use as the start page."
 -  :type '(list string string integer))
 +(defcustom elpher-use-header t
 +  "If non-nil, display current node information in buffer header."
 +  :type '(boolean))
  
  ;;; Model
  ;;
  
  ;; Address
  
 -(defun elpher-make-address (selector host port)
 -  "Create an address of a gopher object with SELECTOR, HOST and PORT."
 -  (list selector host port))
 +(defun elpher-make-address (type &optional selector host port)
 +  "Create an address of a gopher object with TYPE, SELECTOR, HOST and PORT.
 +Although selector host and port are optional, they are only omitted for
 +special address types, such as 'start for the start page."
 +  (list type selector host port))
 +
 +(defun elpher-address-type (address)
 +  "Retrieve type from ADDRESS."
 +  (elt address 0))
  
  (defun elpher-address-selector (address)
    "Retrieve selector from ADDRESS."
 -  (elt address 0))
 +  (elt address 1))
  
  (defun elpher-address-host (address)
    "Retrieve host from ADDRESS."
 -  (elt address 1))
 +  (elt address 2))
  
  (defun elpher-address-port (address)
    "Retrieve port from ADDRESS."
 -  (elt address 2))
 +  (elt address 3))
 +
++(defun elpher-address-special-p (address)
++  (not (elpher-address-host address)))
  ;; Node
  
 -(defun elpher-make-node (parent address getter &optional content pos)
 +(defun elpher-make-node (display-string parent address)
    "Create a node in the gopher page hierarchy.
  
 -PARENT specifies the parent of the node, ADDRESS specifies the address of
 -the gopher page, GETTER provides the getter function used to obtain this
 -page.
 +DISPLAY-STRING records the display string used for the page.
 +
 +PARENT specifies the parent of the node, and ADDRESS specifies the
 +address of the gopher page."
 +  (list display-string parent address))
  
 -The optional arguments CONTENT and POS can be used to fill the cached
 -content and cursor position fields of the node."
 -  (list parent address getter content pos))
 +(defun elpher-node-display-string (node)
 +  "Retrieve the display string of NODE."
 +  (elt node 0))
  
  (defun elpher-node-parent (node)
    "Retrieve the parent node of NODE."
 -  (elt node 0))
 +  (elt node 1))
  
  (defun elpher-node-address (node)
    "Retrieve the address of NODE."
 -  (elt node 1))
 -
 -(defun elpher-node-getter (node)
 -  "Retrieve the preferred getter function of NODE."
    (elt node 2))
  
 -(defun elpher-node-content (node)
 -  "Retrieve the cached content of NODE, or nil if none exists."
 -  (elt node 3))
 +;; Cache
 +
 +(defvar elpher-content-cache (make-hash-table :test 'equal))
 +(defvar elpher-pos-cache (make-hash-table :test 'equal))
  
 -(defun elpher-node-pos (node)
 -  "Retrieve the cached cursor position for NODE, or nil if none exists."
 -  (elt node 4))
 +(defun elpher-get-cached-content (address)
 +  "Retrieve the cached content for ADDRESS, or nil if none exists."
 +  (gethash address elpher-content-cache))
  
 -(defun elpher-set-node-content (node content)
 -  "Set the content cache of NODE to CONTENT."
 -  (setcar (nthcdr 3 node) content))
 +(defun elpher-cache-content (address content)
 +  "Set the content cache for ADDRESS to CONTENT."
 +  (puthash address content elpher-content-cache))
  
 -(defun elpher-set-node-pos (node pos)
 -  "Set the cursor position cache of NODE to POS."
 -  (setcar (nthcdr 4 node) pos))
 +(defun elpher-get-cached-pos (address)
 +  "Retrieve the cached cursor position for ADDRESS, or nil if none exists."
 +  (gethash address elpher-pos-cache))
 +
 +(defun elpher-cache-pos (address pos)
 +  "Set the cursor position cache for ADDRESS to POS."
 +  (puthash address pos elpher-pos-cache))
  
  ;; Node graph traversal
  
    (setq elpher-current-node node)
    (if getter
        (funcall getter)
 -    (funcall (elpher-node-getter node))))
 +    (let* ((address (elpher-node-address node))
 +           (type (elpher-address-type address)))
 +      (funcall (car (alist-get type elpher-type-map))))))
  
  (defun elpher-visit-parent-node ()
    "Visit the parent of the current node."
        
  (defun elpher-reload-current-node ()
    "Reload the current node, discarding any existing cached content."
 -  (elpher-set-node-content elpher-current-node nil)
 +  (elpher-cache-content (elpher-node-address elpher-current-node) nil)
    (elpher-visit-node elpher-current-node))
  
  (defun elpher-save-pos ()
    "Save the current position of point to the current node."
    (when elpher-current-node
 -    (elpher-set-node-pos elpher-current-node (point))))
 +    (elpher-cache-pos (elpher-node-address elpher-current-node) (point))))
  
  (defun elpher-restore-pos ()
    "Restore the position of point to that cached in the current node."
 -  (let ((pos (elpher-node-pos elpher-current-node)))
 +  (let ((pos (elpher-get-cached-pos (elpher-node-address elpher-current-node))))
      (if pos
          (goto-char pos)
        (goto-char (point-min)))))
  
 +
  ;;; Buffer preparation
  ;;
  
 +(defun elpher-update-header ()
 +  "If `elpher-use-header' is true, display current node info in window header."
 +  (if elpher-use-header
 +      (setq header-line-format (elpher-node-display-string elpher-current-node))))
 +
  (defmacro elpher-with-clean-buffer (&rest args)
    "Evaluate ARGS with a clean *elpher* buffer as current."
    (list 'with-current-buffer "*elpher*"
          '(elpher-mode)
          (append (list 'let '((inhibit-read-only t))
 -                      '(erase-buffer))
 +                      '(erase-buffer)
 +                      '(elpher-update-header))
                  args)))
  
 +
  ;;; Index rendering
  ;;
  
 +(defun elpher-preprocess-text-response (string)
 +  "Clear away CRs and terminating period from STRING."
 +  (replace-regexp-in-string "\n\.\n$" "\n"
 +                            (replace-regexp-in-string "\r" ""
 +                                                      string)))
 +
  (defun elpher-insert-index (string)
    "Insert the index corresponding to STRING into the current buffer."
    ;; Should be able to split directly on CRLF, but some non-conformant
    ;; LF-only servers sadly exist, hence the following.
 -  (let* ((str-no-period (replace-regexp-in-string "\r\n\.\r\n$" "\r\n" string))
 -         (str-no-cr (replace-regexp-in-string "\r" "" str-no-period)))
 -    (dolist (line (split-string str-no-cr "\n"))
 +  (let ((str-processed (elpher-preprocess-text-response string)))
 +    (dolist (line (split-string str-processed "\n"))
        (unless (= (length line) 0)
 -        (elpher-insert-index-record line)))))
 +        (let* ((type (elt line 0))
 +               (fields (split-string (substring line 1) "\t"))
 +               (display-string (elt fields 0))
 +               (selector (elt fields 1))
 +               (host (elt fields 2))
 +               (port (if (elt fields 3)
 +                         (string-to-number (elt fields 3))
 +                       nil)))
 +          (elpher-insert-index-record display-string type selector host port))))))
  
  (defun elpher-insert-margin (&optional type-name)
    "Insert index margin, optionally containing the TYPE-NAME, into the current buffer."
          (insert " "))
      (insert (make-string elpher-margin-width ?\s))))
  
 -(defun elpher-insert-index-record (line)
 -  "Insert the index record corresponding to LINE into the current buffer."
 -  (let* ((type (elt line 0))
 -         (fields (split-string (substring line 1) "\t"))
 -         (display-string (elt fields 0))
 -         (selector (elt fields 1))
 -         (host (elt fields 2))
 -         (port (elt fields 3))
 -         (address (elpher-make-address selector host port))
 -         (type-map-entry (alist-get type elpher-type-map)))
 +(defun elpher-node-button-help (node)
 +  "Return a string containing the help text for a button corresponding to NODE."
 +  (let ((address (elpher-node-address node)))
 +    (if (eq (elpher-address-type address) ?h)
 +        (let ((url (cadr (split-string (elpher-address-selector address) "URL:"))))
 +          (format "mouse-1, RET: open url '%s'" url))
 +      (format "mouse-1, RET: open '%s' on %s port %s"
 +              (elpher-address-selector address)
 +              (elpher-address-host address)
 +              (elpher-address-port address)))))
 +
 +(defun elpher-insert-index-record (display-string type selector host port)
 +  "Function to insert an index record into the current buffer.
 +The contents of the record are dictated by TYPE, DISPLAY-STRING, SELECTOR, HOST
 +and PORT."
 +  (let ((address (elpher-make-address type selector host port))
 +        (type-map-entry (alist-get type elpher-type-map)))
      (if type-map-entry
-         (let* ((margin-code (cadr type-map-entry))
 -        (let ((getter (car type-map-entry))
 -              (margin-code (cadr type-map-entry))
 -              (face (elt type-map-entry 2)))
++        (let* ((margin-code (elt type-map-entry 1))
 +               (face (elt type-map-entry 2))
 +               (node (elpher-make-node display-string elpher-current-node address)))
            (elpher-insert-margin margin-code)
            (insert-text-button display-string
                                'face face
 -                              'elpher-node (elpher-make-node elpher-current-node
 -                                                               address
 -                                                               getter)
 +                              'elpher-node node
                                'action #'elpher-click-link
                                'follow-link t
 -                              'help-echo (format "mouse-1, RET: open '%s' on %s port %s"
 -                                                 selector host port)))
 +                              'help-echo (elpher-node-button-help node)))
        (pcase type
 -        (?i (elpher-insert-margin) ;; Information
 -            (insert (propertize
 -                     (if elpher-buttonify-urls-in-directories
 -                         (elpher-buttonify-urls display-string)
 -                       display-string)
 -                     'face 'elpher-info)))
 -        (?h (elpher-insert-margin "W") ;; Web link
 -            (let ((url (elt (split-string selector "URL:") 1)))
 -              (insert-text-button display-string
 -                                  'face 'elpher-url
 -                                  'elpher-url url
 -                                  'action #'elpher-click-url
 -                                  'follow-link t
 -                                  'help-echo (format "mouse-1, RET: open url %s" url))))
 -        (tp (elpher-insert-margin (concat (char-to-string tp) "?"))
 -            (insert (propertize display-string
 -                                'face 'elpher-unknown-face)))))
 +        (?i ;; Information
 +         (elpher-insert-margin)
 +         (insert (propertize
 +                  (if elpher-buttonify-urls-in-directories
 +                      (elpher-buttonify-urls display-string)
 +                    display-string)
 +                  'face 'elpher-info)))
 +        (other ;; Unknown
 +         (elpher-insert-margin (concat (char-to-string type) "?"))
 +         (insert (propertize display-string
 +                             'face 'elpher-unknown-face)))))
      (insert "\n")))
  
 +(defun elpher-click-link (button)
 +  "Function called when the gopher link BUTTON is activated (via mouse or keypress)."
 +  (let ((node (button-get button 'elpher-node)))
 +    (elpher-visit-node node)))
 +
  
  ;;; Selector retrieval (all kinds)
  ;;
@@@ -431,82 -383,87 +434,82 @@@ The result is stored as a string in th
  
  (defun elpher-get-index-node ()
    "Getter which retrieves the current node contents as an index."
 -  (let ((content (elpher-node-content elpher-current-node))
 -        (address (elpher-node-address elpher-current-node)))
 +  (let* ((address (elpher-node-address elpher-current-node))
 +         (content (elpher-get-cached-content address)))
      (if content
          (progn
            (elpher-with-clean-buffer
             (insert content)
             (elpher-restore-pos)))
 -      (if address
 -          (progn
 -            (elpher-with-clean-buffer
 -             (insert "LOADING DIRECTORY..."))
 -            (elpher-get-selector address
 -                                  (lambda (proc event)
 -                                    (unless (string-prefix-p "deleted" event)
 -                                      (elpher-with-clean-buffer
 -                                       (elpher-insert-index elpher-selector-string)
 -                                       (elpher-restore-pos)
 -                                       (elpher-set-node-content elpher-current-node
 -                                                                (buffer-string)))))))
 -        (progn
 -          (elpher-with-clean-buffer
 -           (elpher-insert-index elpher-start-index)
 -           (elpher-restore-pos)
 -           (elpher-set-node-content elpher-current-node
 -                                    (buffer-string))))))))
 +      (elpher-with-clean-buffer
 +       (insert "LOADING DIRECTORY..."))
 +      (elpher-get-selector address
 +                           (lambda (proc event)
 +                             (unless (string-prefix-p "deleted" event)
 +                               (elpher-with-clean-buffer
 +                                (elpher-insert-index elpher-selector-string)
 +                                (elpher-restore-pos)
 +                                (elpher-cache-content
 +                                 (elpher-node-address elpher-current-node)
 +                                 (buffer-string)))))))))
  
  ;; Text retrieval
  
  (defconst elpher-url-regex
 -  "\\(https?\\|gopher\\)://\\([a-zA-Z0-9.\-]+\\)\\(?3::[0-9]+\\)?\\(?4:/[^ \r\n\t(),]*\\)?"
 +  "\\([a-zA-Z]+\\)://\\([a-zA-Z0-9.\-]+\\)\\(?3::[0-9]+\\)?\\(?4:/[^ \r\n\t(),]*\\)?"
    "Regexp used to locate and buttinofy URLs in text files loaded by elpher.")
  
 +(defun elpher-make-node-from-matched-url (parent &optional string)
 +  "Convert most recent `elpher-url-regex' match to a node.
 +
 +PARENT defines the node to set as the parent to the new node.
 +
 +If STRING is non-nil, this is given as an argument to all `match-string'
 +calls, as is necessary if the match is performed by `string-match'."
 +  (let ((url (match-string 0 string))
 +        (protocol (downcase (match-string 1 string))))
 +    (if (string= protocol "gopher")
 +        (let* ((host (match-string 2 string))
 +               (port (if (> (length (match-string 3 string))  1)
 +                         (string-to-number (substring (match-string 3 string) 1))
 +                       70))
 +               (type-and-selector (match-string 4 string))
 +               (type (if (> (length type-and-selector) 1)
 +                         (elt type-and-selector 1)
 +                       ?1))
 +               (selector (if (> (length type-and-selector) 1)
 +                             (substring type-and-selector 2)
 +                           ""))
 +               (address (elpher-make-address type selector host port)))
 +          (elpher-make-node url elpher-current-node address))
 +      (let* ((host (match-string 2 string))
 +             (port (if (> (length (match-string 3 string)) 1)
 +                       (string-to-number (substring (match-string 3 string) 1))
 +                     70))
 +             (selector (concat "URL:" url))
 +             (address (elpher-make-address ?h selector host port)))
 +        (elpher-make-node url elpher-current-node address)))))
 +
 +
  (defun elpher-buttonify-urls (string)
    "Turn substrings which look like urls in STRING into clickable buttons."
    (with-temp-buffer
      (insert string)
      (goto-char (point-min))
      (while (re-search-forward elpher-url-regex nil t)
 -      (let ((url (match-string 0))
 -            (protocol (downcase (match-string 1))))
 -        (if (string= protocol "gopher")
 -            (let* ((host (match-string 2))
 -                   (port (if (match-string 3)
 -                             (string-to-number (substring (match-string 3) 1))
 -                           70))
 -                   (type-and-selector (match-string 4))
 -                   (type (if (> (length type-and-selector) 1)
 -                             (elt type-and-selector 1)
 -                           ?1))
 -                   (selector (if (> (length type-and-selector) 1)
 -                                 (substring type-and-selector 2)
 -                               ""))
 -                   (address (elpher-make-address selector host port))
 -                   (getter (car (alist-get type elpher-type-map))))
 -              (make-text-button (match-beginning 0)
 -                                (match-end 0)
 -                                'elpher-node (elpher-make-node elpher-current-node
 -                                                                 address
 -                                                                 getter)
 -                                'action #'elpher-click-link
 -                                'follow-link t
 -                                'help-echo (format "mouse-1, RET: open '%s' on %s port %s"
 -                                                   selector host port)))
 +        (let ((node (elpher-make-node-from-matched-url elpher-current-node)))
            (make-text-button (match-beginning 0)
                              (match-end 0)
 -                            'elpher-url url
 -                            'action #'elpher-click-url
 +                            'elpher-node  node
 +                            'action #'elpher-click-link
                              'follow-link t
 -                            'help-echo (format "mouse-1, RET: open url %s" url)))))
 +                            'help-echo (elpher-node-button-help node))))
      (buffer-string)))
  
 -(defun elpher-process-text (string)
 -  "Remove CRs and trailing period from the gopher text document STRING."
 -  (let* ((chopped-str (replace-regexp-in-string "\r\n\.\r\n$" "\r\n" string))
 -         (cleaned-str (replace-regexp-in-string "\r" "" chopped-str)))
 -    (elpher-buttonify-urls cleaned-str)))
 -
  (defun elpher-get-text-node ()
    "Getter which retrieves the current node contents as a text document."
 -  (let ((content (elpher-node-content elpher-current-node))
 -        (address (elpher-node-address elpher-current-node)))
 +  (let* ((address (elpher-node-address elpher-current-node))
 +         (content (elpher-get-cached-content address)))
      (if content
          (progn
            (elpher-with-clean-buffer
                                (lambda (proc event)
                                  (unless (string-prefix-p "deleted" event)
                                    (elpher-with-clean-buffer
 -                                   (insert (elpher-process-text elpher-selector-string))
 +                                   (insert (elpher-buttonify-urls
 +                                            (elpher-preprocess-text-response
 +                                             elpher-selector-string)))
                                     (elpher-restore-pos)
 -                                   (elpher-set-node-content elpher-current-node
 -                                                            (buffer-string))))))))))
 +                                   (elpher-cache-content
 +                                    (elpher-node-address elpher-current-node)
 +                                    (buffer-string))))))))))
  
  ;; Image retrieval
  
  (defun elpher-get-image-node ()
    "Getter which retrieves the current node contents as an image to view."
 -  (let ((content (elpher-node-content elpher-current-node))
 -        (address (elpher-node-address elpher-current-node)))
 +  (let* ((address (elpher-node-address elpher-current-node))
 +         (content (elpher-get-cached-content address)))
      (if content
          (progn
            (elpher-with-clean-buffer
                                   (lambda (proc event)
                                     (unless (string-prefix-p "deleted" event)
                                       (let ((image (create-image
 -                                                   (encode-coding-string elpher-selector-string
 -                                                                         'no-conversion)
 +                                                   (encode-coding-string
 +                                                    elpher-selector-string
 +                                                    'no-conversion)
                                                     nil t)))
                                         (elpher-with-clean-buffer
                                          (insert-image image)
                                          (elpher-restore-pos))
                                         (if elpher-cache-images
 -                                           (elpher-set-node-content elpher-current-node
 -                                                                    image)))))))
 +                                           (elpher-cache-content
 +                                            (elpher-node-address elpher-current-node)
 +                                            image)))))))
          (elpher-get-node-download)))))
  
  ;; Search retrieval
  
  (defun elpher-get-search-node ()
    "Getter which submits a search query to the address of the current node."
 -  (let ((content (elpher-node-content elpher-current-node))
 -        (address (elpher-node-address elpher-current-node))
 -        (aborted t))
 +  (let* ((address (elpher-node-address elpher-current-node))
 +         (content (elpher-get-cached-content address))
 +         (aborted t))
      (if content
          (progn
            (elpher-with-clean-buffer
        (unwind-protect
            (let* ((query-string (read-string "Query: "))
                   (query-selector (concat (elpher-address-selector address) "\t" query-string))
 -                 (search-address (elpher-make-address query-selector
 -                                                       (elpher-address-host address)
 -                                                       (elpher-address-port address))))
 +                 (search-address (elpher-make-address ?1
 +                                                      query-selector
 +                                                      (elpher-address-host address)
 +                                                      (elpher-address-port address))))
              (setq aborted nil)
              (elpher-with-clean-buffer
               (insert "LOADING RESULTS..."))
                                        (elpher-with-clean-buffer
                                         (elpher-insert-index elpher-selector-string))
                                        (goto-char (point-min))
 -                                      (elpher-set-node-content elpher-current-node
 -                                                                (buffer-string))))))
 +                                      (elpher-cache-content
 +                                       (elpher-node-address elpher-current-node)
 +                                       (buffer-string))))))
          (if aborted
              (elpher-visit-parent-node))))))
  
  
  (defun elpher-get-node-raw ()
    "Getter which retrieves the raw server response for the current node."
 -  (let* ((content (elpher-node-content elpher-current-node))
 -         (address (elpher-node-address elpher-current-node)))
 +  (let ((address (elpher-node-address elpher-current-node)))
      (elpher-with-clean-buffer
       (insert "LOADING RAW SERVER RESPONSE..."))
      (if address
                                    (message (format "Download complate, saved to file %s."
                                                     elpher-download-filename)))))))))
  
 +;; URL retrieval
 +
 +(defun elpher-get-url-node ()
 +  "Getter which attempts to open the URL specified by the current node."
 +  (let* ((address (elpher-node-address elpher-current-node))
 +         (selector (elpher-address-selector address)))
 +    (elpher-visit-parent-node) ; Do first in case of non-local exits.
 +    (let ((url (elt (split-string selector "URL:") 1)))
 +      (if elpher-open-urls-with-eww
 +          (browse-web url)
 +        (browse-url url)))))
 +
 +;; Telnet node connection
  
 -;;; Navigation procedures
 +(defun elpher-get-telnet-node ()
 +  "Getter which opens a telnet connection to the server specified by the current node."
 +  (let* ((address (elpher-node-address elpher-current-node))
 +         (host (elpher-address-host address))
 +         (port (elpher-address-port address)))
 +    (elpher-visit-parent-node)
 +    (telnet host port)))
 +
 +;; Start page node retrieval
 +
 +(defun elpher-get-start-node ()
 +  "Getter which displays the start page."
 +  (elpher-with-clean-buffer
 +   (elpher-insert-index elpher-start-index)
 +   (elpher-restore-pos)))
 +
 +;; Bookmarks page node retrieval
 +
 +(defun elpher-get-bookmarks-node ()
 +  "Getter which loads and displays the current bookmark list."
 +  (elpher-with-clean-buffer
 +   (insert "---- Bookmark list ----\n\n")
 +   (let ((bookmarks (elpher-load-bookmarks)))
 +     (if bookmarks
 +         (dolist (bookmark bookmarks)
 +           (let ((display-string (elpher-bookmark-display-string bookmark))
 +                 (address (elpher-bookmark-address bookmark)))
 +             (elpher-insert-index-record display-string
 +                                         (elpher-address-type address)
 +                                         (elpher-address-selector address)
 +                                         (elpher-address-host address)
 +                                         (elpher-address-port address))))
 +       (insert "No bookmarks found.\n")))
 +   (insert "\n-----------------------\n\n"
 +           "u: return to previous page.\n"
 +           "x: delete selected bookmark.\n"
 +           "a: rename selected bookmark.\n")
 +   (elpher-restore-pos)))
 +  
 +
 +;;; Bookmarks
 +;;
 +
 +(defun elpher-make-bookmark (display-string address)
 +  "Make an elpher bookmark.
 +DISPLAY-STRING determines how the bookmark will appear in the
 +bookmark list, while ADDRESS is the address of the entry."
 +  (list display-string address))
 +  
 +(defun elpher-bookmark-display-string (bookmark)
 +  "Get the display string of BOOKMARK."
 +  (elt bookmark 0))
 +
 +(defun elpher-set-bookmark-display-string (bookmark display-string)
 +  "Set the display string of BOOKMARK to DISPLAY-STRING."
 +  (setcar bookmark display-string))
 +
 +(defun elpher-bookmark-address (bookmark)
 +  "Get the address for BOOKMARK."
 +  (elt bookmark 1))
 +
 +(defun elpher-save-bookmarks (bookmarks)
 +  "Record the bookmark list BOOKMARKS to the user's bookmark file.
 +Beware that this completely replaces the existing contents of the file."
 +  (with-temp-file (locate-user-emacs-file "elpher-bookmarks")
 +    (erase-buffer)
 +    (pp bookmarks (current-buffer))))
 +
 +(defun elpher-load-bookmarks ()
 +  "Get the list of bookmarks from the users's bookmark file."
 +  (with-temp-buffer
 +    (ignore-errors
 +      (insert-file-contents (locate-user-emacs-file "elpher-bookmarks"))
 +      (goto-char (point-min))
 +      (read (current-buffer)))))
 +
 +(defun elpher-add-address-bookmark (address display-string)
 +  "Save a bookmark for ADDRESS with label DISPLAY-STRING.
 +If ADDRESS is already bookmarked, update the label only."
 +  (let ((bookmarks (elpher-load-bookmarks)))
 +    (let ((existing-bookmark (rassoc (list address) bookmarks)))
 +      (if existing-bookmark
 +          (elpher-set-bookmark-display-string existing-bookmark display-string)
 +        (add-to-list 'bookmarks (elpher-make-bookmark display-string address))))
 +    (elpher-save-bookmarks bookmarks)))
 +
 +(defun elpher-remove-address-bookmark (address)
 +  "Remove any bookmark to ADDRESS."
 +    (elpher-save-bookmarks
 +     (seq-filter (lambda (bookmark)
 +                   (not (equal (elpher-bookmark-address bookmark) address)))
 +                 (elpher-load-bookmarks))))
 +
 +;;; Interactive procedures
  ;;
  
  (defun elpher-next-link ()
    (interactive)
    (backward-button 1))
  
 -(defun elpher-click-link (button)
 -  "Function called when the gopher link BUTTON is activated (via mouse or keypress)."
 -  (let ((node (button-get button 'elpher-node)))
 -    (elpher-visit-node node)))
 -
 -(defun elpher-click-url (button)
 -  "Function called when the url link BUTTON is activated (via mouse or keypress)."
 -  (let ((url (button-get button 'elpher-url)))
 -    (if elpher-open-urls-with-eww
 -        (browse-web url)
 -      (browse-url url))))
 -
  (defun elpher-follow-current-link ()
    "Open the link or url at point."
    (interactive)
    (push-button))
  
  (defun elpher-go ()
-   "Go to a particular gopher site."
+   "Go to a particular gopher site read from the minibuffer.
+ The site may be specified via a URL or explicitly in terms of
+ host, selector and port."
    (interactive)
    (let ((node
           (let ((host-or-url (read-string "Gopher host or URL: ")))
             (if (string-match elpher-url-regex host-or-url)
 -               (if (not (string= (downcase (match-string 1 host-or-url)) "gopher"))
 -                   (error "Only gopher URLs acceptable")
 -                 (let* ((host (match-string 2 host-or-url))
 -                        (port (if (match-string 3 host-or-url)
 -                                  (string-to-number (substring (match-string 3 host-or-url) 1))
 -                                70))
 -                        (type-and-selector (match-string 4 host-or-url))
 -                        (type (if (> (length type-and-selector) 1)
 -                                  (elt type-and-selector 1)
 -                                ?1))
 -                        (selector (if (> (length type-and-selector) 1)
 -                                      (substring type-and-selector 2)
 -                                    ""))
 -                        (address (elpher-make-address selector host port))
 -                        (getter (car (alist-get type elpher-type-map))))
 -                   (elpher-make-node elpher-current-node
 -                                     address
 -                                     getter)))
 -             (let* ((selector (read-string "Selector (default none): " nil nil ""))
 -                    (port (read-string "Port (default 70): " nil nil 70))
 -                    (address (list selector host-or-url port)))
 -               (elpher-make-node elpher-current-node
 -                                 address
 -                                 #'elpher-get-index-node))))))
 +               (elpher-make-node-from-matched-url elpher-current-node
 +                                                  host-or-url)
 +             (let ((selector (read-string "Selector (default none): " nil nil ""))
 +                   (port-string (read-string "Port (default 70): " nil nil "70")))
 +               (elpher-make-node (concat "gopher://" host-or-url
 +                                         ":" port-string
 +                                         "/1" selector)
 +                                 elpher-current-node
 +                                 (elpher-make-address ?1 selector host-or-url
 +                                                      (string-to-number port-string))))))))
      (switch-to-buffer "*elpher*")
      (elpher-visit-node node)))
  
      (message "No current site.")))
  
  (defun elpher-view-raw ()
--  "View current page as plain text."
++  "View raw server response for current page."
    (interactive)
    (if elpher-current-node
--      (elpher-visit-node elpher-current-node
--                         #'elpher-get-node-raw)
++      (if (elpher-address-special-p (elpher-node-address elpher-current-node))
++          (error "This page was not generated by a server.")
++        (elpher-visit-node elpher-current-node
++                           #'elpher-get-node-raw))
      (message "No current site.")))
  
  (defun elpher-back ()
    (interactive)
    (if (elpher-node-parent elpher-current-node)
        (elpher-visit-parent-node)
 -    (message "No previous site.")))
 +    (error "No previous site")))
  
  (defun elpher-download ()
    "Download the link at point."
    (let ((button (button-at (point))))
      (if button
          (let ((node (button-get button 'elpher-node)))
--          (if node
--              (elpher-visit-node (button-get button 'elpher-node)
--                                 #'elpher-get-node-download)
-             (error "Can only download gopher links, not general URLs")))
 -            (message "Can only download gopher links, not general URLs.")))
 -      (message "No link selected."))))
++          (if (elpher-address-special-p (elpher-node-address node))
++              (error "Cannot download this link")
++            (elpher-visit-node (button-get button 'elpher-node)
++                               #'elpher-get-node-download)))
 +      (error "No link selected"))))
  
  (defun elpher-build-link-map ()
    "Build alist mapping link names to destination nodes in current buffer."
        (setq b (next-button (button-start b))))
      link-map))
  
 -(defun elpher-menu ()
 +(defun elpher-jump ()
    "Select a directory entry by name.  Similar to the info browser (m)enu command."
    (interactive)
    (let* ((link-map (elpher-build-link-map)))
      (if link-map
          (let ((key (let ((completion-ignore-case t))
-                      (completing-read "Directory entry/link (tab to autocomplete): "
+                      (completing-read "Directory item/link: "
                                        link-map nil t))))
            (if (and key (> (length key) 0))
                (let ((b (cdr (assoc key link-map))))
  (defun elpher-root-dir ()
    "Visit root of current server."
    (interactive)
 -  (let ((address (elpher-node-address elpher-current-node)))
 -    (if address
 +  (let* ((address (elpher-node-address elpher-current-node))
 +         (host (elpher-address-host address)))
 +    (if host
          (let ((host (elpher-address-host address))
                (selector (elpher-address-selector address))
                (port (elpher-address-port address)))
            (if (> (length selector) 0)
 -              (let ((root-address (elpher-make-address "" host port)))
 -                (elpher-visit-node (elpher-make-node elpher-current-node
 -                                                     root-address
 -                                                     #'elpher-get-index-node)))
 -            (message "Already at root directory of current server.")))
 -      (message "Command invalid for Elpher start page."))))
 +              (let ((root-address (elpher-make-address ?1 "" host port)))
 +                (elpher-visit-node
 +                 (elpher-make-node (concat "gopher://" host
 +                                           ":" (number-to-string port)
 +                                           "/1/")
 +                                   elpher-current-node
 +                                   root-address)))
 +            (error "Already at root directory of current server")))
 +      (error "Command invalid for this page"))))
 +
 +(defun elpher-bookmarks-current-p ()
 +  "Return non-nil if current node is a bookmarks page."
 +  (eq (elpher-address-type (elpher-node-address elpher-current-node)) 'bookmarks))
 +
 +(defun elpher-reload-bookmarks ()
 +  "Reload bookmarks if current node is a bookmarks page."
 +  (if (elpher-bookmarks-current-p)
 +      (elpher-reload-current-node)))
 +
 +(defun elpher-bookmark-current ()
 +  "Bookmark the current node."
 +  (interactive)
 +  (unless (elpher-bookmarks-current-p)
 +      (let ((address (elpher-node-address elpher-current-node))
 +            (display-string (read-string "Bookmark display string: "
 +                                         (elpher-node-display-string elpher-current-node))))
 +        (elpher-add-address-bookmark address display-string)
 +        (message "Bookmark added."))))
 +
 +(defun elpher-bookmark-link ()
 +  "Bookmark the link at point."
 +  (interactive)
 +  (let ((button (button-at (point))))
 +    (if button
 +        (let* ((node (button-get button 'elpher-node))
 +               (address (elpher-node-address node))
 +               (display-string (read-string "Bookmark display string: "
 +                                            (elpher-node-display-string node))))
 +          (elpher-add-address-bookmark address display-string)
 +          (elpher-reload-bookmarks)
 +          (message "Bookmark added."))
 +      (error "No link selected"))))
 +
 +(defun elpher-unbookmark-current ()
 +  "Remove bookmark for the current node."
 +  (interactive)
 +  (unless (elpher-bookmarks-current-p)
 +    (elpher-remove-address-bookmark (elpher-node-address elpher-current-node))
 +    (message "Bookmark removed.")))
 +
 +(defun elpher-unbookmark-link ()
 +  "Remove bookmark for the link at point."
 +  (interactive)
 +  (let ((button (button-at (point))))
 +    (if button
 +        (let ((node (button-get button 'elpher-node)))
 +          (elpher-remove-address-bookmark (elpher-node-address node))
 +          (elpher-reload-bookmarks)
 +          (message "Bookmark removed."))
 +      (error "No link selected"))))
 +
 +(defun elpher-bookmarks ()
 +  "Visit bookmarks."
 +  (interactive)
 +  (elpher-visit-node
 +   (elpher-make-node "Bookmarks"
 +                     elpher-current-node
 +                     (elpher-make-address 'bookmarks))))
 +
 +(defun elpher-info-node (node)
 +  "Display information on NODE."
 +  (let ((display-string (elpher-node-display-string node))
 +        (address (elpher-node-address node)))
 +    (if address
 +        (message "`%s' on %s port %s"
 +                (elpher-address-selector address)
 +                (elpher-address-host address)
 +                (elpher-address-port address))
 +      (message "%s" display-string))))
 +
 +(defun elpher-info-link ()
 +  "Display information on node corresponding to link at point."
 +  (interactive)
 +  (let ((button (button-at (point))))
 +    (if button
 +        (elpher-info-node (button-get button 'elpher-node))
 +      (error "No item selected"))))
 +  
 +(defun elpher-info-current ()
 +  "Display information on current node."
 +  (interactive)
 +  (elpher-info-node elpher-current-node))
 +
 +(defun elpher-get-address-url (address)
 +  "Get URL representation of ADDRESS."
 +  (concat "gopher://"
 +          (elpher-address-host address)
 +          (let ((port (elpher-address-port address)))
 +            (if (equal port 70)
 +                ""
 +              (format ":%d" port)))
 +          "/" (string (elpher-address-type address))
 +          (elpher-address-selector address)))
 +
 +(defun elpher-copy-node-url (node)
 +  "Copy URL representation of address of NODE to `kill-ring'."
 +  (let ((address (elpher-node-address node)))
 +    (if address
 +        (let ((url (elpher-get-address-url address)))
 +          (message url)
 +          (kill-new url))
 +      (error (format "Cannot represent %s as URL" (elpher-node-display-string node))))))
 +
 +(defun elpher-copy-link-url ()
 +  "Copy URL of item at point to `kill-ring'."
 +  (interactive)
 +  (let ((button (button-at (point))))
 +    (if button
 +        (elpher-copy-node-url (button-get button 'elpher-node))
 +      (error "No item selected"))))
 +
 +(defun elpher-copy-current-url ()
 +  "Copy URL of current node to `kill-ring'."
 +  (interactive)
 +  (elpher-copy-node-url elpher-current-node))
  
  ;;; Mode and keymap
  ;;
      (define-key map (kbd "R") 'elpher-reload)
      (define-key map (kbd "w") 'elpher-view-raw)
      (define-key map (kbd "d") 'elpher-download)
 -    (define-key map (kbd "m") 'elpher-menu)
 +    (define-key map (kbd "m") 'elpher-jump)
 +    (define-key map (kbd "i") 'elpher-info-link)
 +    (define-key map (kbd "I") 'elpher-info-current)
 +    (define-key map (kbd "c") 'elpher-copy-link-url)
 +    (define-key map (kbd "C") 'elpher-copy-current-url)
 +    (define-key map (kbd "a") 'elpher-bookmark-link)
 +    (define-key map (kbd "A") 'elpher-bookmark-current)
 +    (define-key map (kbd "x") 'elpher-unbookmark-link)
 +    (define-key map (kbd "X") 'elpher-unbookmark-current)
 +    (define-key map (kbd "B") 'elpher-bookmarks)
      (when (fboundp 'evil-define-key)
 -      (evil-define-key 'normal map
 +      (evil-define-key 'motion map
          (kbd "TAB") 'elpher-next-link
          (kbd "C-]") 'elpher-follow-current-link
          (kbd "C-t") 'elpher-back
          (kbd "R") 'elpher-reload
          (kbd "w") 'elpher-view-raw
          (kbd "d") 'elpher-download
 -        (kbd "m") 'elpher-menu))
 +        (kbd "m") 'elpher-jump
 +        (kbd "i") 'elpher-info-link
 +        (kbd "I") 'elpher-info-current
 +        (kbd "c") 'elpher-copy-link-url
 +        (kbd "C") 'elpher-copy-current-url
 +        (kbd "a") 'elpher-bookmark-link
 +        (kbd "A") 'elpher-bookmark-current
 +        (kbd "x") 'elpher-unbookmark-link
 +        (kbd "X") 'elpher-unbookmark-current
 +        (kbd "B") 'elpher-bookmarks))
      map)
    "Keymap for gopher client.")
  
  (define-derived-mode elpher-mode special-mode "elpher"
    "Major mode for elpher, an elisp gopher client.")
  
 +(when (fboundp 'evil-set-initial-state)
 +  (evil-set-initial-state 'elpher-mode 'motion))
  
  ;;; Main start procedure
  ;;
        (switch-to-buffer "*elpher*")
      (switch-to-buffer "*elpher*")
      (setq elpher-current-node nil)
 -    (let ((start-node (elpher-make-node nil
 -                                        elpher-start-address
 -                                        #'elpher-get-index-node)))
 +    (let ((start-node (elpher-make-node "Elpher Start Page" nil (elpher-make-address 'start))))
        (elpher-visit-node start-node)))
    "Started Elpher.") ; Otherwise (elpher) evaluates to start page string.