Fixed bug introduced in previous commit.
[elpher.git] / elpher.el
index 2764f1d..51309f7 100644 (file)
--- a/elpher.el
+++ b/elpher.el
@@ -4,7 +4,7 @@
 
 ;; Author: Tim Vaughan <tgvaughan@gmail.com>
 ;; Created: 11 April 2019
-;; Version: 1.0.0
+;; Version: 1.1.0
 ;; Keywords: comm gopher
 ;; Homepage: https://github.com/tgvaughan/elpher
 ;; Package-Requires: ((emacs "25"))
@@ -36,9 +36,8 @@
 ;; - (m)enu key support, similar to Emacs' info browser,
 ;; - clickable web and gopher links in plain text.
 
-;; The caching mechanism works by maintaining a hierarchy of visited
-;; pages rather than a linear history, meaning that it is quick and
-;; easy to navigate this history.
+;; Visited pages are stored as a hierarchy rather than a linear history,
+;; meaning that navigation between these pages is quick and easy.
 
 ;; To launch Elpher, simply use 'M-x elpher'.  This will open a start
 ;; page containing information on key bindings and suggested starting
 ;;; Code:
 
 (provide 'elpher)
+(require 'seq)
+(require 'pp)
 
 ;;; Global constants
 ;;
 
-(defconst elpher-version "1.0.0"
+(defconst elpher-version "1.1.0"
   "Current version of elpher.")
 
 (defconst elpher-margin-width 6
   (mapconcat
    'identity
    (list "i\tfake\tfake\t1"
-         "i--------------------------------------------\tfake\tfake\t1"
-         "i           Elpher Gopher Client             \tfake\tfake\t1"
-         (format "i              version %s\tfake\tfake\t1" elpher-version)
-         "i--------------------------------------------\tfake\tfake\t1"
+         "i     --------------------------------------------\tfake\tfake\t1"
+         "i                Elpher Gopher Client             \tfake\tfake\t1"
+         (format "i                   version %s\tfake\tfake\t1" elpher-version)
+         "i     --------------------------------------------\tfake\tfake\t1"
          "i\tfake\tfake\t1"
          "iUsage:\tfake\tfake\t1"
          "i\tfake\tfake\t1"
@@ -90,7 +91,7 @@
          "isearch terms:\tfake\tfake\t1"
          "i\tfake\tfake\t1"
          "7Veronica-2 Gopher Search Engine\t/v2/vs\tgopher.floodgap.com\t70"
-         ".")
+         ".\r\n")
    "\r\n")
   "Source for elpher start page.")
 
     (?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))
+    (?h elpher-get-url-node "W" elpher-url))
   "Association list from types to getters, margin codes and index faces.")
 
 
   '((t :inherit org-level-6))
   "Face used for url type directory records.")
 
+(defface elpher-telnet
+  '((t :inherit org-level-6))
+  "Face used for telnet type directory records.")
+
 (defface elpher-binary
   '((t :inherit org-level-7))
   "Face used for binary type directory records.")
 Otherwise, use the system browser via the BROWSE-URL function."
   :type '(boolean))
 
+(defcustom elpher-buttonify-urls-in-directories nil
+  "If non-nil, turns URLs matched in directories into clickable buttons."
+  :type '(boolean))
+
 (defcustom elpher-cache-images nil
   "If non-nil, cache images in memory in the same way as other content."
   :type '(boolean))
@@ -238,7 +249,7 @@ content and cursor position fields of the node."
 
 ;; Node graph traversal
 
-(defvar elpher-current-node)
+(defvar elpher-current-node nil)
 
 (defun elpher-visit-node (node &optional getter)
   "Visit NODE using its own getter or GETTER, if non-nil."
@@ -272,6 +283,7 @@ content and cursor position fields of the node."
         (goto-char pos)
       (goto-char (point-min)))))
 
+
 ;;; Buffer preparation
 ;;
 
@@ -283,6 +295,7 @@ content and cursor position fields of the node."
                       '(erase-buffer))
                 args)))
 
+
 ;;; Index rendering
 ;;
 
@@ -290,9 +303,11 @@ content and cursor position fields of the node."
   "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.
-  (dolist (line (split-string (replace-regexp-in-string "\r" "" string) "\n"))
-    (unless (= (length line) 0)
-      (elpher-insert-index-record line))))
+  (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"))
+      (unless (= (length line) 0)
+        (elpher-insert-index-record line)))))
 
 (defun elpher-insert-margin (&optional type-name)
   "Insert index margin, optionally containing the TYPE-NAME, into the current buffer."
@@ -306,6 +321,17 @@ content and cursor position fields of the node."
         (insert " "))
     (insert (make-string elpher-margin-width ?\s))))
 
+(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-node-getter node) #'elpher-get-url-node)
+        (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 (line)
   "Insert the index record corresponding to LINE into the current buffer."
   (let* ((type (elt line 0))
@@ -313,41 +339,49 @@ content and cursor position fields of the node."
          (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)))
+         (port (elt fields 3)))
+    (elpher-insert-index-record-helper type display-string selector host port)))
+
+(defun elpher-insert-index-record-helper (type display-string selector host port)
+  "Helper 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.
+
+This function is essentially the second half of `elpher-insert-index-record',
+but broken out so that it can be used by other functions to construct indices
+on the fly."
+  (let ((address (elpher-make-address selector host port))
+        (type-map-entry (alist-get type elpher-type-map)))
     (if type-map-entry
-        (let ((getter (car type-map-entry))
-              (margin-code (cadr type-map-entry))
-              (face (caddr type-map-entry)))
+        (let* ((getter (car type-map-entry))
+               (margin-code (cadr type-map-entry))
+               (face (caddr type-map-entry))
+               (node (elpher-make-node elpher-current-node address getter)))
           (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
+                              'elpher-node-type type
                               '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 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))))
-        (?.) ;; Occurs at end of index, can safely ignore.
+            (insert (propertize
+                     (if elpher-buttonify-urls-in-directories
+                         (elpher-buttonify-urls display-string)
+                       display-string)
+                     'face 'elpher-info)))
         (tp (elpher-insert-margin (concat (char-to-string tp) "?"))
             (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)
 ;;
@@ -406,44 +440,56 @@ The result is stored as a string in the variable ‘elpher-selector-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 selector host port))
+               (getter (car (alist-get type elpher-type-map))))
+          (elpher-make-node elpher-current-node address getter))
+      (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 selector host port))
+             (getter (car (alist-get ?h elpher-type-map))))
+        (elpher-make-node elpher-current-node address getter)))))
+
+
 (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 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)
@@ -582,8 +628,122 @@ The result is stored as a string in the variable ‘elpher-selector-string’."
                                   (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
+
+(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)))
 
-;;; Navigation procedures
+
+;;; Bookmarks
+;;
+
+(defun elpher-make-bookmark (type display-string address)
+  (list type display-string address))
+  
+(defun elpher-bookmark-type (bookmark)
+  (elt bookmark 0))
+
+(defun elpher-bookmark-display-string (bookmark)
+  (elt bookmark 1))
+
+(defun elpher-bookmark-address (bookmark)
+  (elt bookmark 2))
+
+(defun elpher-save-bookmarks (bookmarks)
+  (with-temp-file (locate-user-emacs-file "elpher-bookmarks")
+    (erase-buffer)
+    (pp bookmarks (current-buffer))))
+
+(defun elpher-load-bookmarks ()
+  (with-temp-buffer 
+    (ignore-errors
+      (insert-file-contents (locate-user-emacs-file "elpher-bookmarks"))
+      (goto-char (point-min))
+      (read (current-buffer)))))
+
+(defun elpher-add-bookmark (bookmark)
+  (let ((bookmarks (elpher-load-bookmarks)))
+    (add-to-list 'bookmarks bookmark)
+    (elpher-save-bookmarks bookmarks)))
+
+(defun elpher-remove-bookmark (bookmark)
+  (elpher-save-bookmarks
+   (seq-filter (lambda (this-bookmark)
+                 (not (equal bookmark this-bookmark)))
+               (elpher-load-bookmarks))))
+     
+(defun elpher-display-bookmarks ()
+  (interactive)
+  (elpher-with-clean-buffer
+   (insert
+    "Use 'u' to return to the previous page.\n\n"
+    "---- Bookmark list ----\n\n")
+   (let ((bookmarks (elpher-load-bookmarks)))
+     (if bookmarks
+         (dolist (bookmark (elpher-load-bookmarks))
+           (let ((type (elpher-bookmark-type bookmark))
+                 (display-string (elpher-bookmark-display-string bookmark))
+                 (address (elpher-bookmark-address bookmark)))
+             (elpher-insert-index-record-helper type display-string
+                                                (elpher-address-selector address)
+                                                (elpher-address-host address)
+                                                (elpher-address-port address))))
+       (insert "No bookmarks found.\n")))
+   (insert "\n-----------------------")
+   (goto-char (point-min))
+   (elpher-next-link)))
+
+(defun elpher-bookmark-link ()
+  "Bookmark the link at point."
+  (interactive)
+  (let ((button (button-at (point))))
+    (if button
+        (let ((node (button-get button 'elpher-node))
+              (type (button-get button 'elpher-node-type))
+              (label (button-label button)))
+          (if node
+              (progn
+                (elpher-add-bookmark
+                 (elpher-make-bookmark type
+                                       label
+                                       (elpher-node-address node)))
+                (message "Bookmarked \"%s\"" label))
+            (error "Can only bookmark gopher links, not general URLs")))
+      (error "No link selected"))))
+
+(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))
+              (type (button-get button 'elpher-node-type)))
+          (if node
+              (elpher-remove-bookmark 
+               (elpher-make-bookmark type
+                                     (button-label button)
+                                     (elpher-node-address node)))
+            (error "Can only bookmark gopher links, not general URLs")))
+      (error "No link selected"))))
+
+;;; Interactive navigation procedures
 ;;
 
 (defun elpher-next-link ()
@@ -596,18 +756,6 @@ The result is stored as a string in the variable ‘elpher-selector-string’."
   (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)
@@ -616,39 +764,47 @@ The result is stored as a string in the variable ‘elpher-selector-string’."
 (defun elpher-go ()
   "Go to a particular gopher site."
   (interactive)
-  (switch-to-buffer "*elpher*")
-  (let* (
-         (hostname (read-string "Gopher host: "))
-         (selector (read-string "Selector (default none): " nil nil ""))
-         (port (read-string "Port (default 70): " nil nil 70))
-         (address (list selector hostname port)))
-    (elpher-visit-node
-     (elpher-make-node elpher-current-node
-                        address
-                        #'elpher-get-index-node))))
+  (let ((node
+         (let ((host-or-url (read-string "Gopher host or URL: ")))
+           (if (string-match elpher-url-regex host-or-url)
+               (elpher-make-node-from-matched-url elpher-current-node
+                                                  host-or-url)
+             (let ((selector (read-string "Selector (default none): " nil nil ""))
+                   (port (read-string "Port (default 70): " nil nil 70)))
+               (elpher-make-node elpher-current-node
+                                 (elpher-make-address selector host-or-url port)
+                                 #'elpher-get-index-node))))))
+    (switch-to-buffer "*elpher*")
+    (elpher-visit-node node)))
 
 (defun  elpher-redraw ()
   "Redraw current page."
   (interactive)
-  (elpher-visit-node elpher-current-node))
+  (if elpher-current-node
+      (elpher-visit-node elpher-current-node)
+    (message "No current site.")))
 
 (defun  elpher-reload ()
   "Reload current page."
   (interactive)
-  (elpher-reload-current-node))
+  (if elpher-current-node
+      (elpher-reload-current-node)
+    (message "No current site.")))
 
 (defun elpher-view-raw ()
   "View current page as plain text."
   (interactive)
-  (elpher-visit-node elpher-current-node
-                      #'elpher-get-node-raw))
+  (if elpher-current-node
+      (elpher-visit-node elpher-current-node
+                         #'elpher-get-node-raw)
+    (message "No current site.")))
 
 (defun elpher-back ()
   "Go to previous site."
   (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."
@@ -659,8 +815,8 @@ The result is stored as a string in the variable ‘elpher-selector-string’."
           (if node
               (elpher-visit-node (button-get button 'elpher-node)
                                  #'elpher-get-node-download)
-            (message "Can only download gopher links, not general URLs.")))
-      (message "No link selected."))))
+            (error "Can only download gopher links, not general URLs")))
+      (error "No link selected"))))
 
 (defun elpher-build-link-map ()
   "Build alist mapping link names to destination nodes in current buffer."
@@ -697,8 +853,9 @@ The result is stored as a string in the variable ‘elpher-selector-string’."
                 (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."))))
+            (error "Already at root directory of current server")))
+      (error "Command invalid for Elpher start page"))))
+
 
 ;;; Mode and keymap
 ;;
@@ -716,7 +873,8 @@ The result is stored as a string in the variable ‘elpher-selector-string’."
     (define-key map (kbd "d") 'elpher-download)
     (define-key map (kbd "m") 'elpher-menu)
     (when (fboundp 'evil-define-key)
-      (evil-define-key 'normal map
+      (add-to-list 'evil-motion-state-modes 'elpher-mode)
+      (evil-define-key 'motion map
         (kbd "TAB") 'elpher-next-link
         (kbd "C-]") 'elpher-follow-current-link
         (kbd "C-t") 'elpher-back
@@ -727,7 +885,10 @@ The result is stored as a string in the variable ‘elpher-selector-string’."
         (kbd "R") 'elpher-reload
         (kbd "w") 'elpher-view-raw
         (kbd "d") 'elpher-download
-        (kbd "m") 'elpher-menu))
+        (kbd "m") 'elpher-menu
+        (kbd "a") 'elpher-bookmark-link
+        (kbd "x") 'elpher-unbookmark-link
+        (kbd "B") 'elpher-display-bookmarks))
     map)
   "Keymap for gopher client.")
 
@@ -742,12 +903,14 @@ The result is stored as a string in the variable ‘elpher-selector-string’."
 (defun elpher ()
   "Start elpher with default landing page."
   (interactive)
-  (switch-to-buffer "*elpher*")
-  (setq elpher-current-node nil)
-  (let ((start-node (elpher-make-node nil
-                                      elpher-start-address
-                                      #'elpher-get-index-node)))
-    (elpher-visit-node start-node))
+  (if (get-buffer "*elpher*")
+      (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)))
+      (elpher-visit-node start-node)))
   "Started Elpher.") ; Otherwise (elpher) evaluates to start page string.
 
 ;;; elpher.el ends here