X-Git-Url: https://thelambdalab.xyz/gitweb/index.cgi?a=blobdiff_plain;f=elpher.el;h=51309f7c2345857f1229c285162120662d62dfd4;hb=327849c1705cd459284775025076887eb3558c33;hp=2764f1dea4c55927ff5685fe7cac4b48d4616bde;hpb=3c0aac7af8aa0dbe0619efa149ad1030191cc604;p=elpher.git diff --git a/elpher.el b/elpher.el index 2764f1d..51309f7 100644 --- a/elpher.el +++ b/elpher.el @@ -4,7 +4,7 @@ ;; Author: Tim Vaughan ;; 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 @@ -50,11 +49,13 @@ ;;; 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 @@ -64,10 +65,10 @@ (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.") @@ -102,8 +103,10 @@ (?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.") @@ -140,6 +143,10 @@ '((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.") @@ -163,6 +170,10 @@ 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