X-Git-Url: https://thelambdalab.xyz/gitweb/index.cgi?p=elpher.git;a=blobdiff_plain;f=elpher.el;h=95c3057285661880c392d850e7108178752609f6;hp=37353451416e8510cdff61adc6a1da83b4908eb9;hb=afa26cc2c8d943167c020b2d34b6abf620f57513;hpb=f75386b219d1289a140b2fa508cde75a6f8872e0 diff --git a/elpher.el b/elpher.el index 3735345..95c3057 100644 --- a/elpher.el +++ b/elpher.el @@ -4,9 +4,9 @@ ;; Author: Tim Vaughan ;; Created: 11 April 2019 -;; Version: 2.0.0 +;; Version: 2.4.4 ;; Keywords: comm gopher -;; Homepage: https://github.com/tgvaughan/elpher +;; Homepage: http://thelambdalab.xyz/elpher ;; Package-Requires: ((emacs "26")) ;; This file is not part of GNU Emacs. @@ -36,7 +36,8 @@ ;; - pleasant and configurable colouring of Gopher directories, ;; - direct visualisation of image files, ;; - a simple bookmark management system, -;; - connections using TLS encryption. +;; - connections using TLS encryption, +;; - support for the fledgling Gemini protocol. ;; To launch Elpher, simply use 'M-x elpher'. This will open a start ;; page containing information on key bindings and suggested starting @@ -45,7 +46,8 @@ ;; Full instructions can be found in the Elpher info manual. ;; Elpher is under active development. Any suggestions for -;; improvements are welcome! +;; improvements are welcome, and can be made on the official +;; project page, gopher://thelambdalab.xyz/1/projects/elpher/. ;;; Code: @@ -58,12 +60,14 @@ (require 'pp) (require 'shr) (require 'url-util) +(require 'subr-x) +(require 'dns) ;;; Global constants ;; -(defconst elpher-version "2.0.0" +(defconst elpher-version "2.4.4" "Current version of elpher.") (defconst elpher-margin-width 6 @@ -75,7 +79,7 @@ ((gopher ?4) elpher-get-gopher-node elpher-render-download "bin" elpher-binary) ((gopher ?5) elpher-get-gopher-node elpher-render-download "bin" elpher-binary) ((gopher ?7) elpher-get-gopher-query-node elpher-render-index "?" elpher-search) - ((gopher ?9) elpher-get-gopher-node elpher-render-node-download "bin" elpher-binary) + ((gopher ?9) elpher-get-gopher-node elpher-render-download "bin" elpher-binary) ((gopher ?g) elpher-get-gopher-node elpher-render-image "img" elpher-image) ((gopher ?p) elpher-get-gopher-node elpher-render-image "img" elpher-image) ((gopher ?I) elpher-get-gopher-node elpher-render-image "img" elpher-image) @@ -86,9 +90,9 @@ (gemini elpher-get-gemini-node elpher-render-gemini "gem" elpher-gemini) (telnet elpher-get-telnet-node nil "tel" elpher-telnet) (other-url elpher-get-other-url-node nil "url" elpher-other-url) - ((special bookmarks) elpher-get-bookmarks-node nil) + ((special bookmarks) elpher-get-bookmarks-node nil "/" elpher-index) ((special start) elpher-get-start-node nil)) - "Association list from types to getters, margin codes and index faces.") + "Association list from types to getters, renderers, margin codes and index faces.") ;;; Customization group @@ -125,7 +129,7 @@ "Face used for html type directory records.") (defface elpher-gemini - '((t :inherit font-lock-function-name-face)) + '((t :inherit font-lock-regexp-grouping-backslash)) "Face used for html type directory records.") (defface elpher-other-url @@ -159,7 +163,7 @@ Otherwise, use the system browser via the BROWSE-URL function." :type '(boolean)) -(defcustom elpher-buttonify-urls-in-directories nil +(defcustom elpher-buttonify-urls-in-directories t "If non-nil, turns URLs matched in directories into clickable buttons." :type '(boolean)) @@ -173,6 +177,9 @@ While enabling this may seem convenient, it is also potentially dangerous as it allows switching from an encrypted channel back to plain text without user input." :type '(boolean)) +(defcustom elpher-connection-timeout 5 + "Specifies the number of seconds to wait for a network connection to time out." + :type '(integer)) ;;; Model ;; @@ -189,37 +196,49 @@ allows switching from an encrypted channel back to plain text without user input (let ((data (match-data))) ; Prevent parsing clobbering match data (unwind-protect (let ((url (url-generic-parse-url url-string))) - (setf (url-fullness url) t) - (setf (url-filename url) - (url-unhex-string (url-filename url))) - (unless (url-type url) - (setf (url-type url) "gopher")) - (let ((is-gopher (or (equal "gopher" (url-type url)) - (equal "gophers" (url-type url)))) - (is-gemini (equal "gemini" (url-type url)))) - (when is-gopher + (unless (and (not (url-fullness url)) (url-type url)) + (setf (url-fullness url) t) + (setf (url-filename url) + (url-unhex-string (url-filename url))) + (unless (url-type url) + (setf (url-type url) "gopher")) + (when (or (equal "gopher" (url-type url)) + (equal "gophers" (url-type url))) ;; Gopher defaults (unless (url-host url) (setf (url-host url) (url-filename url)) (setf (url-filename url) "")) (when (or (equal (url-filename url) "") (equal (url-filename url) "/")) - (setf (url-filename url) "/1")))) + (setf (url-filename url) "/1"))) + (when (equal "gemini" (url-type url)) + ;; Gemini defaults + (if (equal (url-filename url) "") + (setf (url-filename url) "/")))) url) (set-match-data data)))) (defun elpher-make-gopher-address (type selector host port &optional tls) - "Create an ADDRESS object corresponding to the given gopher directory record -attributes: TYPE, SELECTOR, HOST and PORT." - (if (and (equal type ?h) - (string-prefix-p "URL:" selector)) - (elpher-address-from-url (elt (split-string selector "URL:") 1)) + "Create an ADDRESS object using gopher directory record attributes. +The basic attributes include: TYPE, SELECTOR, HOST and PORT. +If the optional attribute TLS is non-nil, the address will be marked as +requiring gopher-over-TLS." + (cond + ((and (equal type ?h) + (string-prefix-p "URL:" selector)) + (elpher-address-from-url (elt (split-string selector "URL:") 1))) + ((equal type ?8) + (elpher-address-from-url + (concat "telnet" + "://" host + ":" (number-to-string port)))) + (t (elpher-address-from-url (concat "gopher" (if tls "s" "") "://" host ":" (number-to-string port) "/" (string type) - selector)))) + selector))))) (defun elpher-make-special-address (type) "Create an ADDRESS object corresponding to the given special page symbol TYPE." @@ -232,7 +251,9 @@ attributes: TYPE, SELECTOR, HOST and PORT." nil)) (defun elpher-address-type (address) - "Retrieve selector type from ADDRESS object." + "Retrieve type of ADDRESS object. +This is used to determine how to retrieve and render the document the +address refers to, via the table `elpher-type-map'." (if (symbolp address) (list 'special address) (let ((protocol (url-type address))) @@ -244,14 +265,19 @@ attributes: TYPE, SELECTOR, HOST and PORT." (string-to-char (substring (url-filename address) 1))))) ((equal protocol "gemini") 'gemini) + ((equal protocol "telnet") + 'telnet) (t 'other-url))))) (defun elpher-address-protocol (address) + "Retrieve the transport protocol for ADDRESS. This is nil for special addresses." (if (symbolp address) nil (url-type address))) (defun elpher-address-filename (address) + "Retrieve the filename component of ADDRESS. +For gopher addresses this is a combination of the selector type and selector." (if (symbolp address) nil (url-filename address))) @@ -261,8 +287,11 @@ attributes: TYPE, SELECTOR, HOST and PORT." (url-host address)) (defun elpher-address-port (address) - "Retrieve port from ADDRESS object." - (url-port address)) + "Retrieve port from ADDRESS object. +If no address is defined, returns 0. (This is for compatibility with the URL library.)" + (if (symbolp address) + 0 + (url-port address))) (defun elpher-address-special-p (address) "Return non-nil if ADDRESS object is special (e.g. start page, bookmarks page)." @@ -279,36 +308,8 @@ attributes: TYPE, SELECTOR, HOST and PORT." "" (substring (url-filename address) 2))) -;; Node - -(defun elpher-make-node (display-string address &optional parent) - "Create a node in the page hierarchy. - -DISPLAY-STRING records the display string used for the page. - -ADDRESS specifies the address object of the page. - -The optional PARENT specifies the parent node in the hierarchy. -This is set every time the node is visited, so while it forms -an important part of the node data there is no need to set it -initially." - (list display-string address parent)) - -(defun elpher-node-display-string (node) - "Retrieve the display string of NODE." - (elt node 0)) - -(defun elpher-node-address (node) - "Retrieve the ADDRESS object of NODE." - (elt node 1)) - -(defun elpher-node-parent (node) - "Retrieve the parent node of NODE." - (elt node 2)) +;; Page -(defun elpher-set-node-parent (node parent) - "Set the parent node of NODE to be PARENT." - (setcar (cdr (cdr node)) parent)) ;; Cache @@ -331,24 +332,31 @@ initially." "Set the cursor position cache for ADDRESS to POS." (puthash address pos elpher-pos-cache)) -;; Node graph traversal +;; Page -(defvar elpher-current-node nil) +(defun elpher-make-page (address display-string) + (list address display-string)) -(defun elpher-visit-node (node &optional renderer preserve-parent) - "Visit NODE using its own renderer or RENDERER, if non-nil. -Additionally, set the parent of NODE to `elpher-current-node', -unless PRESERVE-PARENT is non-nil." +(defun elpher-page-address (page) + (elt page 0)) + +(defun elpher-page-display-string (page) + (elt page 1)) + + +(defvar elpher-current-page nil) +(defvar elpher-history nil) + +(defun elpher-visit-page (page &optional renderer no-history) + "Visit PAGE using its own renderer or RENDERER, if non-nil. +Additionally, push PAGE onto the stack of previously-visited pages, +unless NO-HISTORY is non-nil." (elpher-save-pos) (elpher-process-cleanup) - (unless preserve-parent - (if (and (elpher-node-parent elpher-current-node) - (equal (elpher-node-address elpher-current-node) - (elpher-node-address node))) - (elpher-set-node-parent node (elpher-node-parent elpher-current-node)) - (elpher-set-node-parent node elpher-current-node))) - (setq elpher-current-node node) - (let* ((address (elpher-node-address node)) + (unless no-history + (push page elpher-history)) + (setq elpher-current-page page) + (let* ((address (elpher-page-address node)) (type (elpher-address-type address)) (type-record (cdr (assoc type elpher-type-map)))) (if type-record @@ -356,22 +364,22 @@ unless PRESERVE-PARENT is non-nil." (if renderer renderer (cadr type-record))) - (elpher-visit-parent-node) + (elpher-visit-previous-page) (pcase type (`(gopher ,type-char) (error "Unsupported gopher selector type '%c' for '%s'" type-char (elpher-address-to-url address))) - (else + (other (error "Unsupported address type '%S' for '%s'" - type (elpher-address-to-url address))))))) + other (elpher-address-to-url address))))))) -(defun elpher-visit-parent-node () +(defun elpher-visit-previous-page () "Visit the parent of the current node." - (let ((parent-node (elpher-node-parent elpher-current-node))) - (when parent-node - (elpher-visit-node parent-node nil t)))) + (let ((previous-page (pop elpher-history))) + (when previous-page + (elpher-visit-node previous-page nil t)))) -(defun elpher-reload-current-node () +(defun elpher-reload-current-page () "Reload the current node, discarding any existing cached content." (elpher-cache-content (elpher-node-address elpher-current-node) nil) (elpher-visit-node elpher-current-node)) @@ -395,7 +403,16 @@ unless PRESERVE-PARENT is non-nil." (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)))) + (let* ((display-string (elpher-node-display-string elpher-current-node)) + (address (elpher-node-address elpher-current-node)) + (tls-string (if (and (not (elpher-address-special-p address)) + (member (elpher-address-protocol address) + '("gophers" "gemini"))) + " [TLS encryption]" + "")) + (header (concat display-string + (propertize tls-string 'face 'bold)))) + (setq header-line-format header)))) (defmacro elpher-with-clean-buffer (&rest args) "Evaluate ARGS with a clean *elpher* buffer as current." @@ -432,10 +449,12 @@ away CRs and any terminating period." ;; (defun elpher-network-error (address error) + "Display ERROR message following unsuccessful negotiation with ADDRESS. +ERROR can be either an error object or a string." (elpher-with-clean-buffer (insert (propertize "\n---- ERROR -----\n\n" 'face 'error) "When attempting to retrieve " (elpher-address-to-url address) ":\n" - (error-message-string error) ".\n" + (if (stringp error) error (error-message-string error)) "\n" (propertize "\n----------------\n\n" 'face 'error) "Press 'u' to return to the previous page."))) @@ -443,68 +462,93 @@ away CRs and any terminating period." ;;; Gopher selector retrieval ;; +(defvar elpher-network-timer nil + "Timer used for network connections.") + (defun elpher-process-cleanup () - "Immediately shut down any extant elpher process." + "Immediately shut down any extant elpher process and timers." (let ((p (get-process "elpher-process"))) - (if p (delete-process p)))) + (if p (delete-process p))) + (if (timerp elpher-network-timer) + (cancel-timer elpher-network-timer))) (defvar elpher-use-tls nil "If non-nil, use TLS to communicate with gopher servers.") -(defvar elpher-selector-string) - -(defun elpher-get-selector (address after &optional propagate-error) - "Retrieve selector specified by ADDRESS, then execute AFTER. -The result is stored as a string in the variable ‘elpher-selector-string’. - -Usually errors result in an error page being displayed. This is only -appropriate if the selector is to be directly viewed. If PROPAGATE-ERROR -is non-nil, this message is not displayed. Instead, the error propagates -up to the calling function." - (setq elpher-selector-string "") +(defun elpher-get-selector (address renderer &optional force-ipv4) + "Retrieve selector specified by ADDRESS, then render it using RENDERER. +If FORCE-IPV4 is non-nil, explicitly look up and use IPv4 address corresponding +to ADDRESS." (when (equal (elpher-address-protocol address) "gophers") - (if (gnutls-available-p) - (when (not elpher-use-tls) - (setq elpher-use-tls t) - (message "Engaging TLS mode.")) - (error "Cannot retrieve TLS selector: GnuTLS not available"))) - (condition-case the-error + (if (gnutls-available-p) + (when (not elpher-use-tls) + (setq elpher-use-tls t) + (message "Engaging TLS gopher mode.")) + (error "Cannot retrieve TLS gopher selector: GnuTLS not available"))) + (unless (< (elpher-address-port address) 65536) + (error "Cannot retrieve gopher selector: port number > 65536")) + (condition-case nil (let* ((kill-buffer-query-functions nil) + (port (elpher-address-port address)) + (host (elpher-address-host address)) + (selector-string "") (proc (open-network-stream "elpher-process" - nil - (elpher-address-host address) - (if (> (elpher-address-port address) 0) - (elpher-address-port address) - 70) - :type (if elpher-use-tls 'tls 'plain)))) + nil + (if force-ipv4 (dns-query host) host) + (if (> port 0) port 70) + :type (if elpher-use-tls 'tls 'plain) + :nowait t)) + (timer (run-at-time elpher-connection-timeout + nil + (lambda () + (pcase (process-status proc) + ('failed + (if (and (not (equal (elpher-address-protocol address) + "gophers")) + elpher-use-tls + (or elpher-auto-disengage-TLS + (yes-or-no-p "Could not establish encrypted connection. Disable TLS mode?"))) + (progn + (message "Disabling TLS mode.") + (setq elpher-use-tls nil) + (elpher-get-selector address renderer)) + (elpher-network-error address "Could not establish encrypted connection"))) + ('connect + (elpher-process-cleanup) + (unless force-ipv4 + (message "Connection timed out. Retrying with IPv4 address.") + (elpher-get-selector address renderer t)))))))) + (setq elpher-network-timer timer) (set-process-coding-system proc 'binary) (set-process-filter proc - (lambda (proc string) - (setq elpher-selector-string - (concat elpher-selector-string string)))) - (set-process-sentinel proc after) - (process-send-string proc - (concat (elpher-gopher-address-selector address) "\n"))) + (lambda (_proc string) + (cancel-timer timer) + (setq selector-string + (concat selector-string string)))) + (set-process-sentinel proc + (lambda (_proc event) + (condition-case the-error + (cond + ((string-prefix-p "deleted" event)) + ((string-prefix-p "open" event) + (let ((inhibit-eol-conversion t)) + (process-send-string + proc + (concat (elpher-gopher-address-selector address) + "\r\n")))) + (t + (cancel-timer timer) + (funcall renderer selector-string) + (elpher-restore-pos))) + (error + (elpher-network-error address the-error)))))) (error - (if (and (consp the-error) - (eq (car the-error) 'gnutls-error) - (not (equal (elpher-address-protocol address) "gophers")) - (or elpher-auto-disengage-TLS - (yes-or-no-p "Could not establish encrypted connection. Disable TLS mode? "))) - (progn - (message "Disengaging TLS mode.") - (setq elpher-use-tls nil) - (elpher-get-selector address after)) - (elpher-process-cleanup) - (if propagate-error - (error the-error) - (elpher-with-clean-buffer - (insert (propertize "\n---- ERROR -----\n\n" 'face 'error) - "Failed to connect to " (elpher-address-to-url address) ".\n" - (propertize "\n----------------\n\n" 'face 'error) - "Press 'u' to return to the previous page."))))))) + (error "Error initiating connection to server")))) (defun elpher-get-gopher-node (renderer) + "Getter function for gopher nodes. +The RENDERER procedure is used to display the contents of the node +once they are retrieved from the gopher server." (let* ((address (elpher-node-address elpher-current-node)) (content (elpher-get-cached-content address))) (if (and content (funcall renderer nil)) @@ -513,11 +557,10 @@ up to the calling function." (elpher-restore-pos)) (elpher-with-clean-buffer (insert "LOADING... (use 'u' to cancel)")) - (elpher-get-selector address - (lambda (proc event) - (unless (string-prefix-p "deleted" event) - (funcall renderer elpher-selector-string) - (elpher-restore-pos))))))) + (condition-case the-error + (elpher-get-selector address renderer) + (error + (elpher-network-error address the-error)))))) ;; Index rendering @@ -527,17 +570,18 @@ up to the calling function." ;; LF-only servers sadly exist, hence the following. (let ((str-processed (elpher-preprocess-text-response string))) (dolist (line (split-string str-processed "\n")) - (unless (= (length line) 0) - (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)) - (address (elpher-make-gopher-address type selector host port))) - (elpher-insert-index-record display-string address)))))) + (ignore-errors + (unless (= (length line) 0) + (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)) + (address (elpher-make-gopher-address type selector host port))) + (elpher-insert-index-record display-string address))))))) (defun elpher-insert-margin (&optional type-name) "Insert index margin, optionally containing the TYPE-NAME, into the current buffer." @@ -554,7 +598,9 @@ up to the calling function." (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))) - (format "mouse-1, RET: open '%s'" (elpher-address-to-url address)))) + (format "mouse-1, RET: open '%s'" (if (elpher-address-special-p address) + address + (elpher-address-to-url address))))) (defun elpher-insert-index-record (display-string &optional address) "Function to insert an index record into the current buffer. @@ -577,11 +623,11 @@ If ADDRESS is not supplied or nil the record is rendered as an (pcase type ((or '(gopher ?i) 'nil) ;; Information (elpher-insert-margin) - (insert (propertize - (if elpher-buttonify-urls-in-directories - (elpher-buttonify-urls display-string) - display-string) - 'face 'elpher-info))) + (let ((propertized-display-string + (propertize display-string 'face 'elpher-info))) + (insert (if elpher-buttonify-urls-in-directories + (elpher-buttonify-urls propertized-display-string) + propertized-display-string)))) (`(gopher ,selector-type) ;; Unknown (elpher-insert-margin (concat (char-to-string selector-type) "?")) (insert (propertize display-string @@ -593,8 +639,8 @@ If ADDRESS is not supplied or nil the record is rendered as an (let ((node (button-get button 'elpher-node))) (elpher-visit-node node))) -(defun elpher-render-index (data &optional mime-type-string) - "Render DATA as an index, MIME-TYPE-STRING is unused" +(defun elpher-render-index (data &optional _mime-type-string) + "Render DATA as an index. MIME-TYPE-STRING is unused." (elpher-with-clean-buffer (if (not data) t @@ -605,8 +651,8 @@ If ADDRESS is not supplied or nil the record is rendered as an ;; Text rendering (defconst elpher-url-regex - "\\([a-zA-Z]+\\)://\\([a-zA-Z0-9.\-]+\\|\[[a-zA-Z0-9:]+\]\\)\\(?3::[0-9]+\\)?\\(?4:/[^<> \r\n\t(),]*\\)?" - "Regexp used to locate and buttinofy URLs in text files loaded by elpher.") + "\\([a-zA-Z]+\\)://\\([a-zA-Z0-9.\-]*[a-zA-Z0-9\-]\\|\[[a-zA-Z0-9:]+\]\\)\\(:[0-9]+\\)?\\(/\\([0-9a-zA-Z\-_~?/@|:.%#]*[0-9a-zA-Z\-_~?/@|#]\\)?\\)?" + "Regexp used to locate and buttniofy URLs in text files loaded by elpher.") (defun elpher-buttonify-urls (string) "Turn substrings which look like urls in STRING into clickable buttons." @@ -614,18 +660,19 @@ If ADDRESS is not supplied or nil the record is rendered as an (insert string) (goto-char (point-min)) (while (re-search-forward elpher-url-regex nil t) - (let ((node (elpher-make-node (match-string 0) + (let ((node (elpher-make-node (substring-no-properties (match-string 0)) (elpher-address-from-url (match-string 0))))) (make-text-button (match-beginning 0) (match-end 0) 'elpher-node node 'action #'elpher-click-link 'follow-link t - 'help-echo (elpher-node-button-help node)))) + 'help-echo (elpher-node-button-help node) + 'face 'button))) (buffer-string))) -(defun elpher-render-text (data &optional mime-type-string) - "Render DATA as text, MIME-TYPE-STRING is unused." +(defun elpher-render-text (data &optional _mime-type-string) + "Render DATA as text. MIME-TYPE-STRING is unused." (elpher-with-clean-buffer (if (not data) t @@ -636,8 +683,8 @@ If ADDRESS is not supplied or nil the record is rendered as an ;; Image retrieval -(defun elpher-render-image (data &optional mime-type-string) - "Display DATA as image, MIME-TYPE-STRING is unused." +(defun elpher-render-image (data &optional _mime-type-string) + "Display DATA as image. MIME-TYPE-STRING is unused." (if (not data) nil (if (display-images-p) @@ -648,11 +695,13 @@ If ADDRESS is not supplied or nil the record is rendered as an (elpher-with-clean-buffer (insert-image image) (elpher-restore-pos)))) - (elpher-save-to-file data)))) + (elpher-render-download data)))) ;; Search retrieval and rendering (defun elpher-get-gopher-query-node (renderer) + "Getter for gopher addresses requiring input. +The response is rendered using the rendering function RENDERER." (let* ((address (elpher-node-address elpher-current-node)) (content (elpher-get-cached-content address)) (aborted t)) @@ -673,29 +722,27 @@ If ADDRESS is not supplied or nil the record is rendered as an (elpher-with-clean-buffer (insert "LOADING RESULTS... (use 'u' to cancel)")) - (elpher-get-selector search-address - (lambda (proc event) - (unless (string-prefix-p "deleted" event) - (funcall renderer elpher-selector-string) - (elpher-restore-pos))))) + (elpher-get-selector search-address renderer)) (if aborted (elpher-visit-parent-node)))))) ;; Raw server response rendering (defun elpher-render-raw (data &optional mime-type-string) - "Display raw DATA in buffer, MIME-TYPE-STRING is unused." + "Display raw DATA in buffer. MIME-TYPE-STRING is also displayed if provided." (if (not data) nil (elpher-with-clean-buffer + (when mime-type-string + (insert "MIME type specified by server: '" mime-type-string "'\n")) (insert data) (goto-char (point-min))) (message "Displaying raw server response. Reload or redraw to return to standard view."))) ;; File save "rendering" -(defun elpher-render-download (data &optional mime-type-string) - "Save DATA to file, MIME-TYPE-STRING is unused." +(defun elpher-render-download (data &optional _mime-type-string) + "Save DATA to file. MIME-TYPE-STRING is unused." (if (not data) nil (let* ((address (elpher-node-address elpher-current-node)) @@ -714,91 +761,135 @@ If ADDRESS is not supplied or nil the record is rendered as an ;; HTML rendering -(defun elpher-render-html (data &optional mime-type-string) - "Render DATA as HTML using shr, MIME-TYPE-STRING is unused." +(defun elpher-render-html (data &optional _mime-type-string) + "Render DATA as HTML using shr. MIME-TYPE-STRING is unused." (elpher-with-clean-buffer (if (not data) t (let ((dom (with-temp-buffer - (insert string) + (insert data) (libxml-parse-html-region (point-min) (point-max))))) (shr-insert-document dom))))) ;; Gemini node retrieval -(defvar elpher-gemini-response) +(defvar elpher-gemini-redirect-chain) -(defun elpher-get-gemini-response (address after) - "Retrieve gemini ADDRESS, then execute AFTER. -The response is stored in the variable ‘elpher-gemini-response’." - (setq elpher-gemini-response "") +(defun elpher-get-gemini-response (address renderer &optional force-ipv4) + "Retrieve gemini ADDRESS, then render using RENDERER. +If FORCE-IPV4 is non-nil, explicitly look up and use IPv4 address corresponding +to ADDRESS." (if (not (gnutls-available-p)) - (error "Cannot retrieve TLS selector: GnuTLS not available") - (let* ((kill-buffer-query-functions nil) - (proc (open-network-stream "elpher-process" - nil - (elpher-address-host address) - (if (> (elpher-address-port address) 0) - (elpher-address-port address) - 1965) - :type 'tls))) - (set-process-coding-system proc 'binary) - (set-process-filter proc - (lambda (proc string) - (setq elpher-gemini-response - (concat elpher-gemini-response string)))) - (set-process-sentinel proc after) - (process-send-string proc - (concat (elpher-address-to-url address) "\r\n"))))) - - -(defun elpher-process-gemini-response (renderer) - "Process the gemini response found in the variable `elpher-gemini-response' and -pass the result to RENDERER." - (condition-case the-error - (let* ((response-header (car (split-string elpher-gemini-response "\r\n"))) - (response-body (substring elpher-gemini-response - (+ (string-match "\r\n" elpher-gemini-response) 2))) - (response-code (car (split-string response-header))) - (response-meta (string-trim - (substring response-header - (string-match "[ \t]+" response-header))))) - (pcase (elt response-code 0) - (?1 ; Input required - (elpher-with-clean-buffer - (insert "Gemini server is requesting input.")) - (let* ((query-string (read-string (concat response-meta ": "))) - (url (elpher-address-to-url (elpher-node-address elpher-current-node))) - (query-address (elpher-address-from-url (concat url "?" query-string)))) - (elpher-get-gemini-response query-address - (lambda (proc event) - (unless (string-prefix-p "deleted" event) - (funcall #'elpher-process-gemini-response - renderer) - (elpher-restore-pos)))))) - (?2 ; Normal response - (message response-header) - (funcall renderer response-body response-meta)) - (?3 ; Redirect - (message "Following redirect to %s" response-meta) - (let ((redirect-address (elpher-address-from-gemini-url response-meta))) - (elpher-get-gemini-response redirect-address - (lambda (proc event) - (unless (string-prefix-p "deleted" event) - (funcall #'elpher-process-gemini-response - renderer) - (elpher-restore-pos)))))) - (?4 ; Temporary failure - (error "Gemini server reports TEMPORARY FAILURE for this request")) - (?5 ; Permanent failure - (error "Gemini server reports PERMANENT FAILURE for this request")) - (?6 ; Client certificate required - (error "Gemini server requires client certificate (unsupported at this time)")) - (other - (error "Gemini server responded with unknown response code %S" - response-code)))) - (error - (elpher-network-error (elpher-node-address elpher-current-node) the-error)))) + (error "Cannot establish gemini connection: GnuTLS not available") + (unless (< (elpher-address-port address) 65536) + (error "Cannot establish gemini connection: port number > 65536")) + (condition-case nil + (let* ((kill-buffer-query-functions nil) + (port (elpher-address-port address)) + (host (elpher-address-host address)) + (response-string "") + (proc (open-network-stream "elpher-process" + nil + (if force-ipv4 (dns-query host) host) + (if (> port 0) port 1965) + :type 'tls + :nowait t)) + (timer (run-at-time elpher-connection-timeout nil + (lambda () + (elpher-process-cleanup) + (unless force-ipv4 + ; Try again with IPv4 + (message "Connection timed out. Retrying with IPv4.") + (elpher-get-gemini-response address renderer t)))))) + (setq elpher-network-timer timer) + (set-process-coding-system proc 'binary) + (set-process-filter proc + (lambda (_proc string) + (cancel-timer timer) + (setq response-string + (concat response-string string)))) + (set-process-sentinel proc + (lambda (proc event) + (condition-case the-error + (cond + ((string-prefix-p "open" event) ; request URL + (let ((inhibit-eol-conversion t)) + (process-send-string + proc + (concat (elpher-address-to-url address) + "\r\n")))) + ((string-prefix-p "deleted" event)) ; do nothing + ((and (string-empty-p response-string) + (not force-ipv4)) + ; Try again with IPv4 + (message "Connection failed. Retrying with IPv4.") + (cancel-timer timer) + (elpher-get-gemini-response address renderer t)) + (t + (funcall #'elpher-process-gemini-response + response-string + renderer) + (elpher-restore-pos))) + (error + (elpher-network-error address the-error)))))) + (error + (error "Error initiating connection to server"))))) + +(defun elpher-parse-gemini-response (response) + "Parse the RESPONSE string and return a list of components. +The list is of the form (code meta body). A response of nil implies +that the response was malformed." + (let ((header-end-idx (string-match "\r\n" response))) + (if header-end-idx + (let ((header (string-trim (substring response 0 header-end-idx))) + (body (substring response (+ header-end-idx 2)))) + (if (>= (length header) 2) + (let ((code (substring header 0 2)) + (meta (string-trim (substring header 2)))) + (list code meta body)) + (error "Malformed response: No response status found in header %s" header))) + (error "Malformed response: No CRLF-delimited header found")))) + +(defun elpher-process-gemini-response (response-string renderer) + "Process the gemini response RESPONSE-STRING and pass the result to RENDERER." + (let ((response-components (elpher-parse-gemini-response response-string))) + (let ((response-code (elt response-components 0)) + (response-meta (elt response-components 1)) + (response-body (elt response-components 2))) + (pcase (elt response-code 0) + (?1 ; Input required + (elpher-with-clean-buffer + (insert "Gemini server is requesting input.")) + (let* ((query-string (read-string (concat response-meta ": "))) + (url (elpher-address-to-url (elpher-node-address elpher-current-node))) + (query-address (elpher-address-from-url (concat url "?" query-string)))) + (elpher-get-gemini-response query-address renderer))) + (?2 ; Normal response + (funcall renderer response-body response-meta)) + (?3 ; Redirect + (message "Following redirect to %s" response-meta) + (if (>= (length elpher-gemini-redirect-chain) 5) + (error "More than 5 consecutive redirects followed")) + (let ((redirect-address (elpher-address-from-gemini-url response-meta))) + (if (member redirect-address elpher-gemini-redirect-chain) + (error "Redirect loop detected")) + (if (not (string= (elpher-address-protocol redirect-address) + "gemini")) + (error "Server tried to automatically redirect to non-gemini URL: %s" + response-meta)) + (add-to-list 'elpher-gemini-redirect-chain redirect-address) + (elpher-get-gemini-response redirect-address renderer))) + (?4 ; Temporary failure + (error "Gemini server reports TEMPORARY FAILURE for this request: %s %s" + response-code response-meta)) + (?5 ; Permanent failure + (error "Gemini server reports PERMANENT FAILURE for this request: %s %s" + response-code response-meta)) + (?6 ; Client certificate required + (error "Gemini server requires client certificate (unsupported at this time)")) + (_other + (error "Gemini server response unknown: %s %s" + response-code response-meta)))))) (defun elpher-get-gemini-node (renderer) "Getter which retrieves and renders a Gemini node and renders it using RENDERER." @@ -811,78 +902,94 @@ pass the result to RENDERER." (elpher-restore-pos)) (elpher-with-clean-buffer (insert "LOADING GEMINI... (use 'u' to cancel)")) - (elpher-get-gemini-response address - (lambda (proc event) - (unless (string-prefix-p "deleted" event) - (funcall #'elpher-process-gemini-response - renderer) - (elpher-restore-pos))))) + (setq elpher-gemini-redirect-chain nil) + (elpher-get-gemini-response address renderer)) (error (elpher-network-error address the-error))))) (defun elpher-render-gemini (body &optional mime-type-string) - "Render gemini response BODY with rendering hints in META." + "Render gemini response BODY with rendering MIME-TYPE-STRING." (if (not body) t (let* ((mime-type-string* (if (or (not mime-type-string) (string-empty-p mime-type-string)) "text/gemini; charset=utf-8" mime-type-string)) - (mime-type-split (split-string mime-type-string* ";")) + (mime-type-split (split-string mime-type-string* ";" t)) (mime-type (string-trim (car mime-type-split))) (parameters (mapcar (lambda (s) (let ((key-val (split-string s "="))) (list (downcase (string-trim (car key-val))) (downcase (string-trim (cadr key-val)))))) (cdr mime-type-split)))) - (if (and (equal "text/gemini" mime-type) - (not (assoc "charset" parameters))) - (setq parameters (cons (list "charset" "utf-8") parameters))) (when (string-prefix-p "text/" mime-type) - (if (assoc "charset" parameters) - (setq elpher-gemini-response - (decode-coding-string elpher-gemini-response - (intern (cadr (assoc "charset" parameters)))))) - (setq elpher-gemini-response - (replace-regexp-in-string "\r" "" elpher-gemini-response))) + (setq body (decode-coding-string + body + (if (assoc "charset" parameters) + (intern (cadr (assoc "charset" parameters))) + 'utf-8))) + (setq body (replace-regexp-in-string "\r" "" body))) (pcase mime-type ((or "text/gemini" "") - (elpher-render-gemini-text/gemini body parameters)) + (elpher-render-gemini-map body parameters)) + ("text/html" + (elpher-render-html body)) ((pred (string-prefix-p "text/")) - (elpher-render-gemini-text/plain body parameters)) + (elpher-render-gemini-plain-text body parameters)) ((pred (string-prefix-p "image/")) (elpher-render-image body)) - (other + (_other (error "Unsupported MIME type %S" mime-type)))))) (defun elpher-gemini-get-link-url (line) + "Extract the url portion of LINE, a gemini map file link line." (string-trim (elt (split-string (substring line 2)) 0))) (defun elpher-gemini-get-link-display-string (line) + "Extract the display string portion of LINE, a gemini map file link line." (let* ((rest (string-trim (elt (split-string line "=>") 1))) (idx (string-match "[ \t]" rest))) (if idx (string-trim (substring rest (+ idx 1))) ""))) +(defun elpher-collapse-dot-sequences (filename) + "Collapse dot sequences in FILENAME. +For instance, the filename /a/b/../c/./d will reduce to /a/c/d" + (let* ((path (split-string filename "/")) + (path-reversed-normalized + (seq-reduce (lambda (a b) + (cond ((and a (equal b "..") (cdr a))) + ((and (not a) (equal b "..")) a) ;leading .. are dropped + ((equal b ".") a) + (t (cons b a)))) + path nil))) + (string-join (reverse path-reversed-normalized) "/"))) + (defun elpher-address-from-gemini-url (url) "Extract address from URL with defaults as per gemini map files." (let ((address (url-generic-parse-url url))) (unless (and (url-type address) (not (url-fullness address))) ;avoid mangling mailto: urls (setf (url-fullness address) t) - (unless (url-host address) ;if there is an explicit host, filenames are explicit + (if (url-host address) ;if there is an explicit host, filenames are absolute + (if (string-empty-p (url-filename address)) + (setf (url-filename address) "/")) ;ensure empty filename is marked as absolute (setf (url-host address) (url-host (elpher-node-address elpher-current-node))) (unless (string-prefix-p "/" (url-filename address)) ;deal with relative links (setf (url-filename address) - (concat (file-name-directory + (concat (file-name-directory (url-filename (elpher-node-address elpher-current-node))) (url-filename address))))) (unless (url-type address) - (setf (url-type address) "gemini"))) + (setf (url-type address) "gemini")) + (if (equal (url-type address) "gemini") + (setf (url-filename address) + (elpher-collapse-dot-sequences (url-filename address))))) address)) -(defun elpher-render-gemini-text/gemini (data parameters) +(defun elpher-render-gemini-map (data _parameters) + "Render DATA as a gemini map file, PARAMETERS is currently unused." (elpher-with-clean-buffer (dolist (line (split-string data "\n")) (if (string-prefix-p "=>" line) @@ -897,9 +1004,10 @@ pass the result to RENDERER." (elpher-node-address elpher-current-node) (buffer-string)))) -(defun elpher-render-gemini-text/plain (data parameters) +(defun elpher-render-gemini-plain-text (data _parameters) + "Render DATA as plain text file. PARAMETERS is currently unused." (elpher-with-clean-buffer - (insert (elpher-buttonify-urls (elpher-preprocess-text-response data))) + (insert (elpher-buttonify-urls data)) (elpher-cache-content (elpher-node-address elpher-current-node) (buffer-string)))) @@ -931,7 +1039,9 @@ pass the result to RENDERER." (host (elpher-address-host address)) (port (elpher-address-port address))) (elpher-visit-parent-node) - (telnet host port))) + (if (> port 0) + (telnet host port) + (telnet host)))) ;; Start page node retrieval @@ -939,7 +1049,7 @@ pass the result to RENDERER." "Getter which displays the start page (RENDERER must be nil)." (when renderer (elpher-visit-parent-node) - (error "Command not supported for start page.")) + (error "Command not supported for start page")) (elpher-with-clean-buffer (insert " --------------------------------------------\n" " Elpher Gopher Client \n" @@ -951,9 +1061,10 @@ pass the result to RENDERER." " - TAB/Shift-TAB: next/prev item on current page\n" " - RET/mouse-1: open item under cursor\n" " - m: select an item on current page by name (autocompletes)\n" - " - u: return to previous page\n" + " - u/mouse-3: return to previous page\n" " - o/O: visit different selector or the root menu of the current server\n" " - g: go to a particular gopher address\n" + " - d/D: download item under cursor or current page\n" " - i/I: info on item under cursor or current page\n" " - c/C: copy URL representation of item under cursor or current page\n" " - a/A: bookmark the item under cursor or current page\n" @@ -961,10 +1072,9 @@ pass the result to RENDERER." " - B: visit the bookmarks page\n" " - r: redraw current page (using cached contents if available)\n" " - R: reload current page (regenerates cache)\n" - " - T: toggle TLS mode\n" - " - d/D: download item under cursor or current page\n" + " - S: set character coding system for gopher (default is to autodetect)\n" + " - T: toggle TLS gopher mode\n" " - .: display the raw server response for the current page\n" - " - S: set an explicit character coding system (default is to autodetect)\n" "\n" "Start your exploration of gopher space:\n") (elpher-insert-index-record "Floodgap Systems Gopher Server" @@ -973,12 +1083,22 @@ pass the result to RENDERER." "Alternatively, select the following item and enter some search terms:\n") (elpher-insert-index-record "Veronica-2 Gopher Search Engine" (elpher-make-gopher-address ?7 "/v2/vs" "gopher.floodgap.com" 70)) + (insert "\n" + "This page contains your bookmarked sites (also visit with B):\n") + (elpher-insert-index-record "Your Bookmarks" 'bookmarks) + (insert "\n" + "For Elpher release news or to leave feedback, visit:\n") + (elpher-insert-index-record "The Elpher Project Page" + (elpher-make-gopher-address ?1 + "/projects/elpher/" + "thelambdalab.xyz" + 70)) (insert "\n" "** Refer to the ") (let ((help-string "RET,mouse-1: Open Elpher info manual (if available)")) (insert-text-button "Elpher info manual" 'face 'link - 'action (lambda (button) + 'action (lambda (_) (interactive) (info "(elpher)")) 'follow-link t @@ -986,7 +1106,7 @@ pass the result to RENDERER." (insert " for the full documentation. **\n") (insert (propertize (concat " (This should be available if you have installed Elpher using\n" - " MELPA. Otherwise you will have to install the manual yourself.)") + " MELPA. Otherwise you will have to install the manual yourself.)\n") 'face 'shadow)) (elpher-restore-pos))) @@ -996,7 +1116,7 @@ pass the result to RENDERER." "Getter to load and display the current bookmark list (RENDERER must be nil)." (when renderer (elpher-visit-parent-node) - (error "Command not supported for bookmarks page.")) + (error "Command not supported for bookmarks page")) (elpher-with-clean-buffer (insert "---- Bookmark list ----\n\n") (let ((bookmarks (elpher-load-bookmarks))) @@ -1006,12 +1126,23 @@ pass the result to RENDERER." (address (elpher-address-from-url (elpher-bookmark-url bookmark)))) (elpher-insert-index-record display-string address))) (insert "No bookmarks found.\n"))) - (insert "\n-----------------------\n\n" + (insert "\n-----------------------\n" + "\n" "- u: return to previous page\n" "- x: delete selected bookmark\n" - "- a: rename selected bookmark\n\n" - "Bookmarks are stored in the file " - (locate-user-emacs-file "elpher-bookmarks")) + "- a: rename selected bookmark\n" + "\n" + "Bookmarks are stored in the file ") + (let ((filename (locate-user-emacs-file "elpher-bookmarks")) + (help-string "RET,mouse-1: Open bookmarks file in new buffer for editing.")) + (insert-text-button filename + 'face 'link + 'action (lambda (_) + (interactive) + (find-file filename)) + 'follow-link t + 'help-echo help-string)) + (insert "\n") (elpher-restore-pos))) @@ -1021,8 +1152,8 @@ pass the result to RENDERER." (defun elpher-make-bookmark (display-string url) "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 (elpher-address-to-url address))) +bookmark list, while URL is the url of the entry." + (list display-string url)) (defun elpher-bookmark-display-string (bookmark) "Get the display string of BOOKMARK." @@ -1036,11 +1167,10 @@ bookmark list, while ADDRESS is the address of the entry." "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 "elpher2-bookmarks") + (with-temp-file (locate-user-emacs-file "elpher-bookmarks") (erase-buffer) (insert "; Elpher bookmarks file\n\n" "; Bookmarks are stored as a list of (label URL) items.\n" @@ -1050,11 +1180,21 @@ Beware that this completely replaces the existing contents of the file." (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 "elpher2-bookmarks")) - (goto-char (point-min)) - (read (current-buffer))))) + (let ((bookmarks + (with-temp-buffer + (ignore-errors + (insert-file-contents (locate-user-emacs-file "elpher-bookmarks")) + (goto-char (point-min)) + (read (current-buffer)))))) + (if (and bookmarks (listp (cadar bookmarks))) + (progn + (message "Reading old bookmark file. (Will be updated on write.)") + (mapcar (lambda (old-bm) + (list (car old-bm) + (elpher-address-to-url (apply #'elpher-make-gopher-address + (cadr old-bm))))) + bookmarks)) + bookmarks))) (defun elpher-add-address-bookmark (address display-string) "Save a bookmark for ADDRESS with label DISPLAY-STRING.))) @@ -1064,7 +1204,7 @@ If ADDRESS is already bookmarked, update the label only." (let ((existing-bookmark (rassoc (list url) bookmarks))) (if existing-bookmark (elpher-set-bookmark-display-string existing-bookmark display-string) - (add-to-list 'bookmarks (elpher-make-bookmark display-string url)))) + (push (elpher-make-bookmark display-string url) bookmarks))) (elpher-save-bookmarks bookmarks))) (defun elpher-remove-address-bookmark (address) @@ -1127,15 +1267,15 @@ If ADDRESS is already bookmarked, update the label only." (message "No current site."))) (defun elpher-toggle-tls () - "Toggle TLS encryption mode." + "Toggle TLS encryption mode for gopher." (interactive) (setq elpher-use-tls (not elpher-use-tls)) (if elpher-use-tls (if (gnutls-available-p) - (message "TLS mode enabled. (Will not affect current page until reload.)") + (message "TLS gopher mode enabled. (Will not affect current page until reload.)") (setq elpher-use-tls nil) - (error "Cannot enable TLS mode: GnuTLS not available")) - (message "TLS mode disabled. (Will not affect current page until reload.)"))) + (error "Cannot enable TLS gopher mode: GnuTLS not available")) + (message "TLS gopher mode disabled. (Will not affect current page until reload.)"))) (defun elpher-view-raw () "View raw server response for current page." @@ -1185,7 +1325,7 @@ If ADDRESS is already bookmarked, update the label only." (let ((link-map nil) (b (next-button (point-min) t))) (while b - (add-to-list 'link-map (cons (button-label b) b)) + (push (cons (button-label b) b) link-map) (setq b (next-button (button-start b)))) link-map)) @@ -1290,7 +1430,7 @@ If ADDRESS is already bookmarked, update the label only." (address (elpher-node-address node))) (if (elpher-address-special-p address) (message "Special page: %s" display-string) - (message (elpher-address-to-url address))))) + (message "%s" (elpher-address-to-url address))))) (defun elpher-info-link () "Display information on node corresponding to link at point." @@ -1327,14 +1467,14 @@ If ADDRESS is already bookmarked, update the label only." (interactive) (elpher-copy-node-url elpher-current-node)) -(defun elpher-set-coding-system () - "Specify an explicit character coding system." +(defun elpher-set-gopher-coding-system () + "Specify an explicit character coding system for gopher selectors." (interactive) - (let ((system (read-coding-system "Set coding system to use (default is to autodetect): " nil))) + (let ((system (read-coding-system "Set coding system to use for gopher (default is to autodetect): " nil))) (setq elpher-user-coding-system system) (if system - (message "Coding system fixed to %s. (Reload to see effect)." system) - (message "Coding system set to autodetect. (Reload to see effect).")))) + (message "Gopher coding system fixed to %s. (Reload to see effect)." system) + (message "Gopher coding system set to autodetect. (Reload to see effect).")))) ;;; Mode and keymap @@ -1345,6 +1485,7 @@ If ADDRESS is already bookmarked, update the label only." (define-key map (kbd "TAB") 'elpher-next-link) (define-key map (kbd "") 'elpher-prev-link) (define-key map (kbd "u") 'elpher-back) + (define-key map [mouse-3] 'elpher-back) (define-key map (kbd "O") 'elpher-root-dir) (define-key map (kbd "g") 'elpher-go) (define-key map (kbd "o") 'elpher-go-current) @@ -1364,13 +1505,14 @@ If ADDRESS is already bookmarked, update the label only." (define-key map (kbd "x") 'elpher-unbookmark-link) (define-key map (kbd "X") 'elpher-unbookmark-current) (define-key map (kbd "B") 'elpher-bookmarks) - (define-key map (kbd "S") 'elpher-set-coding-system) - (when (fboundp 'evil-mode) + (define-key map (kbd "S") 'elpher-set-gopher-coding-system) + (when (fboundp 'evil-define-key*) (evil-define-key* 'motion map (kbd "TAB") 'elpher-next-link (kbd "C-") 'elpher-follow-current-link (kbd "C-t") 'elpher-back (kbd "u") 'elpher-back + [mouse-3] 'elpher-back (kbd "g") 'elpher-go (kbd "o") 'elpher-go-current (kbd "r") 'elpher-redraw @@ -1389,12 +1531,12 @@ If ADDRESS is already bookmarked, update the label only." (kbd "x") 'elpher-unbookmark-link (kbd "X") 'elpher-unbookmark-current (kbd "B") 'elpher-bookmarks - (kbd "S") 'elpher-set-coding-system)) + (kbd "S") 'elpher-set-gopher-coding-system)) map) "Keymap for gopher client.") (define-derived-mode elpher-mode special-mode "elpher" - "Major mode for elpher, an elisp gopher client.))))))) + "Major mode for elpher, an elisp gopher client. This mode is automatically enabled by the interactive functions which initialize the gopher client, namely