diff options
-rw-r--r-- | .agignore | 20 | ||||
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | sx-auth.el | 27 | ||||
-rw-r--r-- | sx-cache.el | 41 | ||||
-rw-r--r-- | sx-encoding.el | 87 | ||||
-rw-r--r-- | sx-favorites.el | 5 | ||||
-rw-r--r-- | sx-filter.el | 33 | ||||
-rw-r--r-- | sx-method.el | 5 | ||||
-rw-r--r-- | sx-networks.el | 6 | ||||
-rw-r--r-- | sx-question-list.el | 59 | ||||
-rw-r--r-- | sx-question-mode.el | 82 | ||||
-rw-r--r-- | sx-question.el | 59 | ||||
-rw-r--r-- | sx-request.el | 90 | ||||
-rw-r--r-- | sx-site.el | 10 | ||||
-rw-r--r-- | sx.el | 53 | ||||
-rw-r--r-- | sx.org | 126 | ||||
-rw-r--r-- | test/tests.el | 1 |
17 files changed, 505 insertions, 201 deletions
diff --git a/.agignore b/.agignore new file mode 100644 index 0000000..e00db68 --- /dev/null +++ b/.agignore @@ -0,0 +1,20 @@ +# Backup files +*~ +\#*\# + +# Compiled Elisp +*.elc + +# Generated by tests +/.cask/ +/.stackmode/ +/url/ + +# User-local variables +.dir-locals.el + +# Test files +test/data-samples + +# Info files +*.info @@ -8,3 +8,5 @@ .dir-locals.el /.stackmode/ /url/ +/sx.info +/sx.texi @@ -19,8 +19,6 @@ ;;; Commentary: -;; - ;;; Code: (require 'sx) @@ -36,7 +34,6 @@ (defvar sx-auth-access-token nil "Your access token. - This is needed to use your account to write questions, make comments, and read your inbox. Do not alter this unless you know what you are doing!") @@ -82,10 +79,28 @@ FILTER).") (defun sx-auth-authenticate () "Authenticate this application. - Authentication is required to read your personal data (such as notifications) and to write with the API (asking and answering -questions)." +questions). + +When this function is called, `browse-url' is used to send the +user to an authorization page managed by StackExchange. The +following privileges are requested: + +* read_inbox + use SX to manage and visit items in your inbox + +* write_acesss + write comments, ask questions, and post answers on your + behalf + +* no_expiry + do not pester you to reauthorize again + +After authorization with StackExchange, the user is then +redirected to a website managed by SX. The access token required +to use authenticated methods is included in the hash (which is +parsed and displayed prominently on the page)." (interactive) (setq sx-auth-access-token @@ -107,6 +122,8 @@ questions)." (error "You must enter this code to use this client fully")) (sx-cache-set 'auth `((access_token . ,sx-auth-access-token))))) +(defalias 'sx-authenticate #'sx-auth-authenticate) + (defun sx-auth--method-p (method &optional submethod) "Check if METHOD is one that may require authentication. If it has `auth-required' SUBMETHODs, or no submethod, return t." diff --git a/sx-cache.el b/sx-cache.el index a564a53..9f152e2 100644 --- a/sx-cache.el +++ b/sx-cache.el @@ -30,39 +30,44 @@ (defcustom sx-cache-directory (expand-file-name ".stackmode" user-emacs-directory) - "Directory containined cached files and precompiled filters.") + "Directory containing cached data." + :type 'directory + :group 'sx-cache) + +(defun sx-cache--ensure-sx-cache-directory-exists () + "Ensure `sx-cache-directory' exists." + (unless (file-exists-p sx-cache-directory) + (mkdir sx-cache-directory))) (defun sx-cache-get-file-name (filename) - "Expands FILENAME in the context of `sx-cache-directory'." + "Expand FILENAME in the context of `sx-cache-directory'." (expand-file-name (concat (symbol-name filename) ".el") sx-cache-directory)) (defun sx-cache-get (cache &optional form) "Return the data within CACHE. +If CACHE does not exist, use `sx-cache-set' to set CACHE to the +result of evaluating FORM. -If CACHE does not exist, evaluate FORM and set it to its return. - -As with `sx-cache-set', CACHE is a file name within the -context of `sx-cache-directory'." - (unless (file-exists-p sx-cache-directory) - (mkdir sx-cache-directory)) +CACHE is resolved to a file name by `sx-cache-get-file-name'." + (sx-cache--ensure-sx-cache-directory-exists) (let ((file (sx-cache-get-file-name cache))) + ;; If the file exists, return the data it contains (if (file-exists-p file) (with-temp-buffer (insert-file-contents (sx-cache-get-file-name cache)) (read (buffer-string))) + ;; Otherwise, set CACHE to the evaluation of FORM. + ;; `sx-cache-set' returns the data that CACHE was set to. (sx-cache-set cache (eval form))))) (defun sx-cache-set (cache data) - "Set the content of CACHE to DATA. + "Set the content of CACHE to DATA and save. +DATA will be written as returned by `prin1'. -As with `sx-cache-get', CACHE is a file name within the -context of `sx-cache-directory'. - -DATA will be written as returned by `prin1'." - (unless (file-exists-p sx-cache-directory) - (mkdir sx-cache-directory)) +CACHE is resolved to a file name by `sx-cache-get-file-name'." + (sx-cache--ensure-sx-cache-directory-exists) (write-region (prin1-to-string data) nil (sx-cache-get-file-name cache)) data) @@ -79,10 +84,8 @@ re-initialize the cache." (defun sx-cache-invalidate-all (&optional save-auth) "Invalidate all caches using `sx-cache--invalidate'. - -Afterwards reinitialize caches using `sx-initialize'. - -If SAVE-AUTH is non-nil, do not clear AUTH cache." +Afterwards reinitialize caches using `sx-initialize'. If +SAVE-AUTH is non-nil, do not clear AUTH cache." (let ((caches (let ((default-directory sx-cache-directory)) (file-expand-wildcards "*.el")))) (when save-auth diff --git a/sx-encoding.el b/sx-encoding.el index 9d48e60..f683615 100644 --- a/sx-encoding.el +++ b/sx-encoding.el @@ -19,8 +19,6 @@ ;;; Commentary: -;; - ;;; Code: (require 'cl-lib) @@ -62,28 +60,45 @@ ucirc "û" Ucirc "Û" ugrave "ù" Ugrave "Ù" uml "¨" upsih "ϒ" Upsilon "Υ" upsilon "υ" uuml "ü" Uuml "Ü" weierp "℘" Xi "Ξ" xi "ξ" yacute "ý" Yacute "Ý" yen "¥" yuml "ÿ" Yuml "Ÿ" Zeta "Ζ" zeta "ζ" zwj "" zwnj "") - "Plist of html entities to replace when displaying question titles and other text." + "Plist of HTML entities and their respective glyphs. +See `sx-encoding-decode-entities'." :type '(repeat (choice symbol string)) :group 'sx) (defun sx-encoding-decode-entities (string) + "Decode HTML entities (e.g. \""\") in STRING. + +Done according to `sx-encoding-html-entities-plist'. If this +list does not contain the entity, it is assumed to be a number +and converted to a string (with `char-to-string'). + +Return the decoded string." (let* ((plist sx-encoding-html-entities-plist) - (get-function (lambda (s) (let ((ss (substring s 1 -1))) - ;; Handle things like " - (or (plist-get plist (intern ss)) - ;; Handle things like ' - (format "%c" (string-to-number - (substring ss 1)))))))) + (get-function + (lambda (s) + (let ((ss (substring s 1 -1))) + ;; Handle things like " + (or (plist-get plist (intern ss)) + ;; Handle things like ' + (char-to-string + (string-to-number + ;; Skip the `#' + (substring ss 1)))))))) (replace-regexp-in-string "&[^; ]*;" get-function string))) (defun sx-encoding-normalize-line-endings (string) - "Normalize the line endings for STRING" + "Normalize the line endings for STRING. +The API returns strings that use Windows-style line endings. +These are largely useless in an Emacs environment. Windows uses +\"\\r\\n\", Unix uses just \"\\n\". Deleting \"\\r\" is sufficient for +conversion." (delete ?\r string)) (defun sx-encoding-clean-content (string) - "Cleans STRING for display. + "Clean STRING for display. Applies `sx-encoding-normalize-line-endings' and -`sx-encoding-decode-entities'." +`sx-encoding-decode-entities' (in that order) to prepare STRING +for sane display." (sx-encoding-decode-entities (sx-encoding-normalize-line-endings string))) @@ -91,17 +106,24 @@ Applies `sx-encoding-normalize-line-endings' and (defun sx-encoding-clean-content-deep (data) "Clean DATA recursively where necessary. -See `sx-encoding-clean-content'." +If DATA is a list or a vector, map this function over DATA and +return as the the same type of structure. + +If DATA is a cons cell (but not a list), use +`sx-encoding-clean-content-deep' on the `cdr' of DATA. + +If DATA is a string, return DATA after applying +`sx-encoding-clean-content'. + +Otherwise, return DATA. + +This function is highly specialized for the data structures +returned by `json-read' via `sx-request-make'. It may fail in +some cases." (if (consp data) - ;; If we're looking at a cons cell, test to see if is a list. If - ;; it is, map ourselves over the entire list. If it is not, - ;; reconstruct the cons cell using a cleaned cdr. (if (listp (cdr data)) (cl-map #'list #'sx-encoding-clean-content-deep data) (cons (car data) (sx-encoding-clean-content-deep (cdr data)))) - ;; If we're looking at an atom, clean and return if we're looking - ;; at a string, map if we're looking at a vector, and just return - ;; if we aren't looking at either. (cond ((stringp data) (sx-encoding-clean-content data)) @@ -110,28 +132,25 @@ See `sx-encoding-clean-content'." (t data)))) (defun sx-encoding-gzipped-p (data) - "Checks for magic bytes in DATA. - -Check if the first two bytes of a string in DATA match magic -numbers identifying the gzip file format. See [1] for the file -format description. - -http://www.gzip.org/zlib/rfc-gzip.html + "Check for magic bytes in DATA. +Check if the first two bytes of a string in DATA match the magic +numbers identifying the gzip file format. -http://emacs.stackexchange.com/a/2978" +See URL `http://www.gzip.org/zlib/rfc-gzip.html'." + ;; Credit: http://emacs.stackexchange.com/a/2978 (equal (substring (string-as-unibyte data) 0 2) (unibyte-string 31 139))) -(defun sx-encoding-gzipped-buffer-p (filename) - "Check if the BUFFER is gzip-compressed. - -See `gzip-check-magic' for details." - (sx-encoding-gzip-check-magic (buffer-string))) +(defun sx-encoding-gzipped-buffer-p (buffer) + "Check if BUFFER is gzip-compressed. +See `sx-encoding-gzipped-p'." + (with-current-buffer buffer + (sx-encoding-gzip-check-magic + (buffer-string)))) (defun sx-encoding-gzipped-file-p (file) "Check if the FILE is gzip-compressed. - -See `gzip-check-magic' for details." +See `sx-encoding-gzipped-p'." (let ((first-two-bytes (with-temp-buffer (set-buffer-multibyte nil) (insert-file-contents-literally file nil 0 2) diff --git a/sx-favorites.el b/sx-favorites.el index f254137..c00d262 100644 --- a/sx-favorites.el +++ b/sx-favorites.el @@ -19,8 +19,6 @@ ;;; Commentary: -;; - ;;; Code: (require 'sx-method) @@ -39,13 +37,11 @@ (defvar sx-favorites--user-favorite-list nil "Alist of questions favorited by the user. - Each element has the form (SITE FAVORITE-LIST). And each element in FAVORITE-LIST is the numerical QUESTION_ID.") (defun sx-favorites--initialize () "Ensure question-favorites cache is available. - Added as hook to initialization." (or (setq sx-favorites--user-favorite-list (sx-cache-get 'question-favorites)) @@ -62,7 +58,6 @@ Added as hook to initialization." (defun sx-favorites--update-site-favorites (site) "Update list of starred QUESTION_IDs for SITE. - Writes list to cache QUESTION-FAVORITES." (let* ((favs (sx-favorites--retrieve-favorites site)) (site-cell (assoc site diff --git a/sx-filter.el b/sx-filter.el index 90681e8..38084b9 100644 --- a/sx-filter.el +++ b/sx-filter.el @@ -19,8 +19,6 @@ ;;; Commentary: -;; - ;;; Code: @@ -35,25 +33,30 @@ (defvar sx--filter-alist (sx-cache-get 'filter) - "") + "An alist of known filters. See `sx-filter-compile'. +Structure: + + (((INCLUDE EXCLUDE BASE ) . \"compiled filter \") + ((INCLUDE2 EXCLUDE2 BASE2) . \"compiled filter2\") + ...)") ;;; Compilation -;;; TODO allow BASE to be a precompiled filter name +;;; @TODO allow BASE to be a precompiled filter name (defun sx-filter-compile (&optional include exclude base) "Compile INCLUDE and EXCLUDE into a filter derived from BASE. +INCLUDE and EXCLUDE must both be lists; BASE should be a string. -INCLUDE and EXCLUDE must both be lists; BASE should be a symbol -or string." +Returns the compiled filter as a string." (let ((keyword-arguments `((include . ,(if include (sx--thing-as-string include))) (exclude . ,(if exclude (sx--thing-as-string exclude))) (base . ,(if base base))))) - (let ((response (sx-request-make - "filter/create" - keyword-arguments))) - (sx-assoc-let (elt response 0) + (let ((response (elt (sx-request-make + "filter/create" + keyword-arguments) 0))) + (sx-assoc-let response .filter)))) @@ -64,10 +67,14 @@ or string." (apply #'sx-filter-get filter-variable)) (defun sx-filter-get (&optional include exclude base) - "Return the string representation of the given filter." - ;; Maybe we alreay have this filter + "Return the string representation of the given filter. + +If the filter data exist in `sx--filter-alist', that value will +be returned. Otherwise, compile INCLUDE, EXCLUDE, and BASE into +a filter with `sx-filter-compile' and push the association onto +`sx--filter-alist'. Re-cache the alist with `sx-cache-set' and +return the compiled filter." (or (cdr (assoc (list include exclude base) sx--filter-alist)) - ;; If we don't, build it, save it, and return it. (let ((filter (sx-filter-compile include exclude base))) (when filter (push (cons (list include exclude base) filter) sx--filter-alist) diff --git a/sx-method.el b/sx-method.el index 5499fb1..c5764cd 100644 --- a/sx-method.el +++ b/sx-method.el @@ -19,7 +19,10 @@ ;;; Commentary: -;; +;;; This file is effectively a common-use wrapper for +;;; `sx-request-make'. It provides higher-level handling such as +;;; (authentication, filters, ...) that `sx-request-make' doesn't need +;;; to handle. ;;; Code: (require 'json) diff --git a/sx-networks.el b/sx-networks.el index 110f975..f5aa9f5 100644 --- a/sx-networks.el +++ b/sx-networks.el @@ -19,8 +19,6 @@ ;;; Commentary: -;; - ;;; Code: (require 'sx-method) @@ -54,7 +52,6 @@ (defun sx-network--get-associated () "Retrieve cached information for network user. - If cache is not available, retrieve current data." (or (and (setq sx-network--user-information (sx-cache-get 'network-user) sx-network--user-sites @@ -63,7 +60,6 @@ If cache is not available, retrieve current data." (defun sx-network--update () "Update user information. - Sets cache and then uses `sx-network--get-associated' to update the variables." (sx-cache-set 'network-user @@ -76,7 +72,6 @@ the variables." (defun sx-network--initialize () "Ensure network-user cache is available. - Added as hook to initialization." ;; Cache was not retrieved, retrieve it. (sx-network--get-associated)) @@ -84,7 +79,6 @@ Added as hook to initialization." (defun sx-network--map-site-url-to-site-api () "Convert `me/associations' to a set of `api_site_parameter's. - `me/associations' does not return `api_site_parameter' so cannot be directly used to retrieve content per site. This creates a list of sites the user is active on." diff --git a/sx-question-list.el b/sx-question-list.el index be088c8..9e94536 100644 --- a/sx-question-list.el +++ b/sx-question-list.el @@ -161,12 +161,12 @@ Non-interactively, DATA is a question alist." (tabulated-list-get-id) (user-error "Not in `sx-question-list-mode'")))) (sx-question--mark-read data) - (sx-question-list-next 1) + (sx-question-list-next 1) (when (called-interactively-p 'any) (sx-question-list-refresh 'redisplay 'noupdate))) (defvar sx-question-list--current-page "Latest" - ;; Other values (once we implement them) are "Top Voted", + ;; @TODO Other values (once we implement them) are "Top Voted", ;; "Unanswered", etc. "Variable describing current page being viewed.") @@ -210,7 +210,9 @@ Non-interactively, DATA is a question alist." "Site being displayed in the *question-list* buffer.") (defvar sx-question-list--current-dataset nil - "") + "The logical data behind the displayed list of questions. +This dataset contains even questions that are hidden by the user, +and thus not displayed in the list of questions.") (defun sx-question-list-refresh (&optional redisplay no-update) "Update the list of questions. @@ -244,33 +246,36 @@ a new list before redisplaying." (defcustom sx-question-list-ago-string " ago" "String appended to descriptions of the time since something happened. -Used in the questions list to indicate a question was updated \"4d ago\"." +Used in the questions list to indicate a question was updated +\"4d ago\"." :type 'string :group 'sx-question-list) -(defun sx-question-list--print-info (data) - "Convert `json-read' DATA into tabulated-list format." - (sx-assoc-let data +(defun sx-question-list--print-info (question-data) + "Convert `json-read' QUESTION-DATA into tabulated-list format. +See `sx-question-list-refresh'." + (sx-assoc-let question-data (let ((favorite (if (member .question_id (assoc .site sx-favorites--user-favorite-list)) (if (char-displayable-p ?\x2b26) "\x2b26" "*") " "))) (list - data + question-data (vector (list (int-to-string .score) 'face (if .upvoted 'sx-question-list-score-upvoted 'sx-question-list-score)) (list (int-to-string .answer_count) - 'face (if (sx-question--accepted-answer-id data) + 'face (if (sx-question--accepted-answer-id question-data) 'sx-question-list-answers-accepted 'sx-question-list-answers)) (concat (propertize .title - 'face (if (sx-question--read-p data) + 'face (if (sx-question--read-p question-data) 'sx-question-list-read-question - ;; Increment `sx-question-list--unread-count' for the mode-line. + ;; Increment `sx-question-list--unread-count' for + ;; the mode-line. (cl-incf sx-question-list--unread-count) 'sx-question-list-unread-question)) (propertize " " 'display "\n ") @@ -285,32 +290,37 @@ Used in the questions list to indicate a question was updated \"4d ago\"." (propertize " " 'display "\n"))))))) (defun sx-question-list-view-previous (n) - "Hide this question, move to previous one, display it." + "Move cursor up N questions up and display this question. +Displayed in `sx-question-mode--window', replacing any question +that may currently be there." (interactive "p") (sx-question-list-view-next (- n))) (defun sx-question-list-view-next (n) - "Hide this question, move to next one, display it." + "Move cursor down N questions and display this question. +Displayed in `sx-question-mode--window', replacing any question +that may currently be there." (interactive "p") (sx-question-list-next n) (sx-question-list-display-question)) (defun sx-question-list-next (n) - "Move to the next entry." + "Move cursor down N questions. +This does not update `sx-question-mode--window'." (interactive "p") (forward-line n)) (defun sx-question-list-previous (n) - "Move to the previous entry." + "Move cursor up N questions. +This does not update `sx-question-mode--window'." (interactive "p") (sx-question-list-next (- n))) (defun sx-question-list-display-question (&optional data focus) "Display question given by DATA. -If called interactively (or with DATA being nil), display -question under point. -Also when called interactively (or when FOCUS is non-nil), also -focus the relevant window." +When DATA is nil, display question under point. When FOCUS is +non-nil (the default when called interactively), also focus the +relevant window." (interactive '(nil t)) (unless data (setq data (tabulated-list-get-id))) (unless data (error "No question here!")) @@ -337,7 +347,7 @@ focus the relevant window." (set-window-parameter sx-question-mode--window 'quit-restore - ;; See https://www.gnu.org/software/emacs/manual/html_node/elisp/Window-Parameters.html#Window-Parameters + ;; See (info "(elisp) Window Parameters") `(window window ,(selected-window) ,sx-question-mode--buffer)) (when focus (if sx-question-mode--window @@ -345,7 +355,11 @@ focus the relevant window." (switch-to-buffer sx-question-mode--buffer)))) (defun sx-question-list-switch-site (site) - "Switch the current site to SITE and display its questions" + "Switch the current site to SITE and display its questions. +Uses `ido-completing-read' if variable `ido-mode' is active. Retrieves +completions from `sx-site-get-api-tokens'. Sets +`sx-question-list--current-site' and then +`sx-question-list-refresh' with `redisplay'." (interactive (list (funcall (if ido-mode #'ido-completing-read #'completing-read) "Switch to site: " (sx-site-get-api-tokens) @@ -359,7 +373,8 @@ focus the relevant window." "Buffer where the list of questions is displayed.") (defun list-questions (no-update) - "Display a list of StackExchange questions." + "Display a list of StackExchange questions. +NO-UPDATE is passed to `sx-question-list-refresh'." (interactive "P") (sx-initialize) (unless (buffer-live-p sx-question-list--buffer) diff --git a/sx-question-mode.el b/sx-question-mode.el index 089ee12..f8a0d1e 100644 --- a/sx-question-mode.el +++ b/sx-question-mode.el @@ -19,8 +19,6 @@ ;;; Commentary: -;; - ;;; Code: (require 'markdown-mode) @@ -54,6 +52,7 @@ (defun sx-question-mode--display (data &optional window) "Display question given by DATA on WINDOW. If WINDOW is nil, use selected one. + Returns the question buffer." (let ((inhibit-read-only t)) (with-current-buffer @@ -65,7 +64,7 @@ Returns the question buffer." (defun sx-question-mode--display-buffer (window) "Display and return the buffer used for displaying a question. -Create the buffer if necessary. +Create `sx-question-mode--buffer' if necessary. If WINDOW is given, use that to display the buffer." ;; Create the buffer if necessary. (unless (buffer-live-p sx-question-mode--buffer) @@ -84,8 +83,9 @@ If WINDOW is given, use that to display the buffer." ;;; Printing a question's content ;;;; Faces and Variables + (defvar sx-question-mode--overlays nil - "") + "Question mode overlays.") (make-variable-buffer-local 'sx-question-mode--overlays) (defface sx-question-mode-header @@ -147,14 +147,14 @@ If WINDOW is given, use that to display the buffer." '((((background dark)) :background "#090909") (((background light)) :background "#f4f4f4")) "Face used on the question body in the question buffer. -Shouldn't have a foreground, or this will interfere with +This shouldn't have a foreground, or this will interfere with font-locking." :group 'sx-question-mode-faces) (defcustom sx-question-mode-last-edit-format " (edited %s ago by %s)" "Format used to describe last edit date in the header. -First %s is replaced with the date, and the second %s with the -editor's name." +First \"%s\" is replaced with the date and the second \"%s\" with +the editor's name." :type 'string :group 'sx-question-mode) @@ -176,8 +176,8 @@ editor's name." (defcustom sx-question-mode-comments-format "%s: %s\n" "Format used to display comments. -First \"%s\" is replaced with user name. -Second \"%s\" is replaced with the comment." +First \"%s\" is replaced with user name. Second \"%s\" is +replaced with the comment." :type 'string :group 'sx-question-mode) @@ -206,17 +206,17 @@ QUESTION must be a data structure returned by `json-read'." (defvar sx-question-mode--section-help-echo (format - (propertize "%s to hide/display content" 'face 'minibuffer-prompt) - (propertize "RET" 'face 'font-lock-function-name-face)) - "") + (propertize "%s to hide/display content" 'face 'minibuffer-prompt) + (propertize "RET" 'face 'font-lock-function-name-face)) + "Help echoed in the minibuffer when point is on a section.") (defvar sx-question-mode--title-properties `(face sx-question-mode-title action sx-question-mode-hide-show-section help-echo ,sx-question-mode--section-help-echo button t - follow-link t) - "") + follow-link t) + "Title properties.") (defun sx-question-mode--print-section (data) "Print a section corresponding to DATA. @@ -293,9 +293,11 @@ DATA can represent a question or an answer." (propertize .display_name 'face 'sx-question-mode-author))) -(defun sx-question-mode--print-comment (data) - "Print the comment described by alist DATA." - (sx-assoc-let data +(defun sx-question-mode--print-comment (comment-data) + "Print the comment described by alist COMMENT-DATA. +The comment is indented, filled, and then printed according to +`sx-question-mode-comments-format'." + (sx-assoc-let comment-data (insert (format sx-question-mode-comments-format @@ -310,8 +312,10 @@ DATA can represent a question or an answer." 3))))) (defmacro sx-question-mode--wrap-in-overlay (properties &rest body) - "Execute BODY and wrap any inserted text in an overlay. -Overlay is pushed on `sx-question-mode--overlays' and given PROPERTIES. + "Start a scope with overlay PROPERTIES and execute BODY. +Overlay is pushed on `sx-question-mode--overlays' and given +PROPERTIES. + Return the result of BODY." (declare (indent 1) (debug t)) @@ -325,7 +329,7 @@ Return the result of BODY." result)) (defmacro sx-question-mode--wrap-in-text-property (properties &rest body) - "Execute BODY and PROPERTIES to any inserted text. + "Start a scope with PROPERTIES and execute BODY. Return the result of BODY." (declare (indent 1) (debug t)) @@ -335,9 +339,14 @@ Return the result of BODY." result)) (defun sx-question-mode--insert-header (&rest args) - "Insert HEADER and VALUE. -HEADER is given `sx-question-mode-header' face, and value is given FACE. -\(fn header value face [header value face] [header value face] ...)" + "Insert propertized ARGS. +ARGS is a list of repeating values -- `header', `value', and +`face'. `header' is given `sx-question-mode-header' as a face, +where `value' is given `face' as its face. + +Syntax: + + \(fn HEADER VALUE FACE [HEADER VALUE FACE] [HEADER VALUE FACE] ...)" (while args (insert (propertize (pop args) 'face 'sx-question-mode-header) @@ -351,7 +360,7 @@ HEADER is given `sx-question-mode-header' face, and value is given FACE. "String to be displayed as the bullet of markdown list items.") (defun sx-question-mode--fill-and-fontify (text) - "Fill TEXT according to `markdown-mode' and return it." + "Return TEXT filled according to `markdown-mode'." (with-temp-buffer (erase-buffer) (insert text) @@ -415,7 +424,7 @@ URL is used as 'help-echo and 'url properties." ;; Mouse-over 'help-echo (format (propertize "URL: %s, %s to visit" 'face 'minibuffer-prompt) - (propertize url 'face 'default) + (propertize url 'face 'default) (propertize "RET" 'face 'font-lock-function-name-face)) ;; In case we need it. 'url url @@ -430,22 +439,22 @@ URL is used as 'help-echo and 'url properties." 'action #'sx-question-mode-follow-link)) (defun sx-question-mode-follow-link (&optional pos) - "Follow link at POS or point" + "Follow link at POS. If POS is nil, use `point'." (interactive) (browse-url (or (get-text-property (or pos (point)) 'url) - (error "No url under point: %s" (or pos (point)))))) + (user-error "No url under point: %s" (or pos (point)))))) -(defun sx-question-mode-find-reference (id &optional id2) +(defun sx-question-mode-find-reference (id &optional fallback-id) "Find url identified by reference ID in current buffer. -If ID is nil, use ID2 instead." +If ID is nil, use FALLBACK-ID instead." (save-excursion (save-match-data (goto-char (point-min)) (when (search-forward-regexp (format (rx line-start (0+ blank) "[%s]:" (0+ blank) (group-n 1 (1+ (not blank)))) - (or id id2)) + (or id fallback-id)) nil t) (match-string-no-properties 1))))) @@ -468,6 +477,7 @@ If ID is nil, use ID2 instead." "Screen line to which we recenter after moving between sections. This is used as an argument to `recenter', only used if the end of section is outside the window. + If nil, no recentering is performed." :type '(choice (const :tag "Don't recenter" nil) integer) @@ -497,13 +507,14 @@ Prefix argument N moves N sections down or up." (defun sx-question-mode-previous-section (&optional n) "Move down to previous section (question or answer) of this buffer. -Prefix argument N moves N sections up or down." +Prefix argument moves N sections up or down." (interactive "p") (sx-question-mode-next-section (- (or n 1)))) (defun sx-question-mode--goto-property-change (prop &optional direction) - "Move forward until the value of text-property sx-question-mode--PROP changes. + "Move forward to the next change of text-property sx-question-mode--PROP. Return the new value of PROP at point. + If DIRECTION is negative, move backwards instead." (let ((prop (intern (format "sx-question-mode--%s" prop))) (func (if (and (numberp direction) @@ -516,9 +527,9 @@ If DIRECTION is negative, move backwards instead." (goto-char (funcall func (point) prop nil limit)) (get-text-property (point) prop))) -;;; Optional argument is for `push-button'. (defun sx-question-mode-hide-show-section (&optional _) - "Hide or show section under point." + "Hide or show section under point. +Optional argument _ is for `push-button'." (interactive) (let ((ov (car (or (sx-question-mode--section-overlays-at (point)) (sx-question-mode--section-overlays-at @@ -537,8 +548,9 @@ If DIRECTION is negative, move backwards instead." ;;; Major-mode (define-derived-mode sx-question-mode markdown-mode "Question" - "Major mode for a question and its answers. + "Major mode to display and navigate a question and its answers. Letters do not insert themselves; instead, they are commands. + \\<sx-question-mode> \\{sx-question-mode}" ;; Determine how to close this window. diff --git a/sx-question.el b/sx-question.el index 75f786c..c02cf62 100644 --- a/sx-question.el +++ b/sx-question.el @@ -19,8 +19,6 @@ ;;; Commentary: -;; - ;;; Code: @@ -46,10 +44,17 @@ answer.owner answer.body_markdown answer.comments) - (user.profile_image shallow_user.profile_image))) + (user.profile_image shallow_user.profile_image)) + "The filter applied when retrieving question data. +See `sx-question-get-questions' and `sx-question-get-question'.") (defun sx-question-get-questions (site &optional page) - "Get the page PAGE of questions from SITE." + "Get SITE questions. Return page PAGE (the first if nil). +Return a list of question. Each question is an alist of +properties returned by the API with an added (site SITE) +property. + +`sx-method-call' is used with `sx-question-browse-filter'." (mapcar (lambda (question) (cons (cons 'site site) question)) (sx-method-call 'questions @@ -58,8 +63,9 @@ :auth t :filter sx-question-browse-filter))) -(defun sx-question-get-question (site id) - "Get the question ID from SITE." +(defun sx-question-get-question (site question-id) + "Query SITE for a QUESTION-ID and return it. +If QUESTION-ID doesn't exist on SITE, raise an error." (let ((res (sx-method-call 'questions :id id :site site @@ -67,25 +73,34 @@ :filter sx-question-browse-filter))) (if (vectorp res) (elt res 0) - (error "Couldn't find question %S in %S" id site)))) + (error "Couldn't find question %S in %S" + question-id site)))) ;;; Question Properties + ;;;; Read/unread -(defvar sx-question--user-read-list nil +(defvar sx-question--user-read-list nil "Alist of questions read by the user. -Each element has the form (SITE . QUESTION-LIST). -And each element in QUESTION-LIST has the form (QUESTION_ID . LAST-VIEWED-DATE).") + +Each element has the form + + (SITE . QUESTION-LIST) + +where each element in QUESTION-LIST has the form + + (QUESTION_ID . LAST-VIEWED-DATE).") (defun sx-question--ensure-read-list (site) - "Ensure the `sx-question--user-read-list' has been read from cache. + "Ensure `sx-question--user-read-list' has been read from cache. If no cache exists for it, initialize one with SITE." (unless sx-question--user-read-list (setq sx-question--user-read-list (sx-cache-get 'read-questions `'((,site)))))) (defun sx-question--read-p (question) - "Non-nil if QUESTION has been read since last updated." + "Non-nil if QUESTION has been read since last updated. +See `sx-question--user-read-list'." (sx-assoc-let question (sx-question--ensure-read-list .site) (let ((ql (cdr (assoc .site sx-question--user-read-list)))) @@ -94,7 +109,8 @@ If no cache exists for it, initialize one with SITE." .last_activity_date))))) (defun sx-question--mark-read (question) - "Mark QUESTION as being read, until it is updated again." + "Mark QUESTION as being read until it is updated again. +See `sx-question--user-read-list'." (sx-assoc-let question (sx-question--ensure-read-list .site) (let ((site-cell (assoc .site sx-question--user-read-list)) @@ -111,20 +127,23 @@ If no cache exists for it, initialize one with SITE." (t (sx-sorted-insert-skip-first q-cell site-cell (lambda (x y) (> (car x) (car y)))))))) - ;; This causes a small lag on `j' and `k' as the list gets large. - ;; Should we do this on a timer? ;; Save the results. + ;; @TODO This causes a small lag on `j' and `k' as the list gets + ;; large. Should we do this on a timer? (sx-cache-set 'read-questions sx-question--user-read-list)) ;;;; Hidden -(defvar sx-question--user-hidden-list nil +(defvar sx-question--user-hidden-list nil "Alist of questions hidden by the user. -Each element has the form (SITE . QUESTION-LIST). -And each element in QUESTION-LIST has the form (QUESTION_ID . LAST-VIEWED-DATE).") + +Each element has the form + + (SITE QUESTION_ID QUESTION_ID ...)") (defun sx-question--ensure-hidden-list (site) "Ensure the `sx-question--user-hidden-list' has been read from cache. + If no cache exists for it, initialize one with SITE." (unless sx-question--user-hidden-list (setq sx-question--user-hidden-list @@ -161,13 +180,13 @@ If no cache exists for it, initialize one with SITE." ;;;; Other data (defun sx-question--accepted-answer-id (question) - "Return accepted answer in QUESTION, or nil if none." + "Return accepted answer in QUESTION or nil if none exists." (sx-assoc-let question (and (integerp .accepted_answer_id) .accepted_answer_id))) (defun sx-question--tag-format (tag) - "Formats TAG for display" + "Formats TAG for display." (concat "[" tag "]")) (provide 'sx-question) diff --git a/sx-request.el b/sx-request.el index 8686216..c667978 100644 --- a/sx-request.el +++ b/sx-request.el @@ -19,7 +19,30 @@ ;;; Commentary: +;; API requests are handled on three separate tiers: ;; +;; `sx-method-call': +;; +;; This is the function that should be used most often, since it +;; runs necessary checks (authentication) and provides basic +;; processing of the result for consistency. +;; +;; `sx-request-make': +;; +;; This is the fundamental function for interacting with the API. +;; It makes no provisions for 'common' usage, but it does ensure +;; data is retrieved successfully or an appropriate signal is +;; thrown. +;; +;; `url.el' and `json.el': +;; +;; The whole solution is built upon `url-retrieve-synchronously' +;; for making the request and `json-read-from-string' for parsing +;; it into a properly symbolic data structure. +;; +;; When at all possible, use `sx-method-call'. There are specialized +;; cases for the use of `sx-request-make' outside of sx-method.el, but +;; these must be well-documented inline with the code. ;;; Code: @@ -44,33 +67,52 @@ (format "https://api.stackexchange.com/%s/" sx-request-api-version) "The base URL to make requests from.") -(defcustom sx-request-silent-p - t - "When `t', requests default to being silent.") - -(defcustom sx-request-cache-p - t - "Cache requests made to the StackExchange API.") - (defcustom sx-request-unzip-program "gunzip" - "program used to unzip the response") + "Program used to unzip the response if it is compressed. +This program must accept compressed data on standard input." + :group 'sx-request + :type 'string) (defvar sx-request-remaining-api-requests nil - "The number of API requests remaining according to the most -recent call. Set by `sx-request-make'.") + "The number of API requests remaining. +Set by `sx-request-make'.") (defcustom sx-request-remaining-api-requests-message-threshold 50 - "After `sx-request-remaining-api-requests' drops below this + "Lower bound for printed warnings of API usage limits. +After `sx-request-remaining-api-requests' drops below this number, `sx-request-make' will begin printing out the -number of requests left every time it finishes a call.") +number of requests left every time it finishes a call." + :group 'sx-request + :type 'integer) ;;; Making Requests (defun sx-request-make + "Make a request to the API, executing METHOD with ARGS. +You should almost certainly be using `sx-method-call' instead of +this function. REQUEST-METHOD is one of `GET' (default) or `POST'. + +Returns cleaned response content. +See (`sx-encoding-clean-content-deep'). + +The full set of arguments is built with +`sx-request--build-keyword-arguments', prepending +`sx-request-api-key' to receive a higher quota. It will also +include user's `access_token` if it is avaialble. This call is +then resolved with `url-retrieve-synchronously' to a temporary +buffer that it returns. The headers are then stripped using a +search a blank line (\"\\n\\n\"). The main body of the response +is then tested with `sx-encoding-gzipped-buffer-p' for +compression. If it is compressed, `sx-request-unzip-program' is +called to uncompress the response. The uncompressed respons is +then read with `json-read-from-string'. + +`sx-request-remaining-api-requests' is updated appropriately and +the main content of the response is returned." (method &optional args request-method) (let* ((url-automatic-caching sx-request-cache-p) (url-inhibit-uncompression t) @@ -85,9 +127,10 @@ number of requests left every time it finishes a call.") (error "Something went wrong in `url-retrieve-synchronously'") (with-current-buffer response-buffer (let* ((data (progn + ;; @TODO use url-http-end-of-headers (goto-char (point-min)) (if (not (search-forward "\n\n" nil t)) - (error "Response headers missing; response corrupt") + (error "Headers missing; response corrupt") (delete-region (point-min) (point)) (buffer-string)))) (response-zipped-p (sx-encoding-gzipped-p data)) @@ -97,6 +140,8 @@ number of requests left every time it finishes a call.") sx-request-unzip-program nil t) (buffer-string))) + ;; @TODO should use `condition-case' here -- set + ;; RESPONSE to 'corrupt or something (response (with-demoted-errors "`json' error: %S" (json-read-from-string data)))) (when (and (not response) (string-equal data "{}")) @@ -107,8 +152,7 @@ number of requests left every time it finishes a call.") (when .error_id (error "Request failed: (%s) [%i %s] %S" .method .error_id .error_name .error_message)) - (when (< (setq sx-request-remaining-api-requests - .quota_remaining) + (when (< (setq sx-request-remaining-api-requests .quota_remaining) sx-request-remaining-api-requests-message-threshold) (sx-message "%d API requests reamining" sx-request-remaining-api-requests)) @@ -126,9 +170,15 @@ Currently returns nil." ;;; Support Functions (defun sx-request--build-keyword-arguments (alist &optional - kv-value-sep) - "Build a \"key=value&key=value&...\"-style string with the elements -of ALIST. If any value in the alist is `nil', that pair will not + kv-sep need-auth) + "Format ALIST as a key-value list joined with KV-SEP. +If authentication is needed, include it also or error if it is +not available. + +If NEED-AUTH is non-nil, authentication is required. + +Build a \"key=value&key=value&...\"-style string with the elements +of ALIST. If any value in the alist is nil, that pair will not be included in the return. If you wish to pass a notion of false, use the symbol `false'. Each element is processed with `sx--thing-as-string'." @@ -144,7 +194,7 @@ false, use the symbol `false'. Each element is processed with (concat (sx--thing-as-string (car pair)) "=" - (sx--thing-as-string (cdr pair) kv-value-sep))) + (sx--thing-as-string (cdr pair) kv-sep))) (delq nil (mapcar (lambda (pair) (when (cdr pair) pair)) @@ -19,8 +19,6 @@ ;;; Commentary: -;; - ;;; Code: (require 'sx-method) @@ -43,9 +41,11 @@ related_site.api_site_parameter related_site.relation) nil - none)) + none) + "Filter for browsing sites.") (defun sx-site--get-site-list () + "Return all sites with `sx-site-browse-filter'." (sx-cache-get 'site-list '(sx-method-call 'sites @@ -54,7 +54,9 @@ (defcustom sx-site-favorites nil - "Favorite sites." + "List of favorite sites. +Each entry is a string corresponding to a single site's +api_site_parameter." :group 'sx-site) (defun sx-site-get-api-tokens () @@ -49,7 +49,7 @@ (defmacro sx-sorted-insert-skip-first (newelt list &optional predicate) "Inserted NEWELT into LIST sorted by PREDICATE. -This is designed for the (site id id ...) lists. So the first car +This is designed for the (site id id ...) lists. So the first car is intentionally skipped." `(let ((tail ,list) (x ,newelt)) @@ -61,7 +61,8 @@ is intentionally skipped." (setcdr tail (cons x (cdr tail))))) (defun sx-message (format-string &rest args) - "Display a message." + "Display FORMAT-STRING as a message with ARGS. +See `format'." (message "[stack] %s" (apply #'format format-string args))) (defun sx-message-help-echo () @@ -72,7 +73,9 @@ is intentionally skipped." (defun sx--thing-as-string (thing &optional sequence-sep) "Return a string representation of THING. If THING is already a string, just return it. -Optional argument SEQUENCE-SEP is the separator applied between elements of a sequence." + +Optional argument SEQUENCE-SEP is the separator applied between +elements of a sequence." (cond ((stringp thing) thing) ((symbolp thing) (symbol-name thing)) @@ -82,7 +85,24 @@ Optional argument SEQUENCE-SEP is the separator applied between elements of a se thing (if sequence-sep sequence-sep ";"))))) (defun sx--filter-data (data desired-tree) - "Filters DATA and return the DESIRED-TREE." + "Filter DATA and return the DESIRED-TREE. + +For example: + + (sx--filter-data + '((prop1 . value1) + (prop2 . value2) + (prop3 + (test1 . 1) + (test2 . 2)) + (prop4 . t)) + '(prop1 (prop3 test2))) + +would yield + + ((prop1 . value1) + (prop3 + (test2 . 2)))" (if (vectorp data) (apply #'vector (mapcar (lambda (entry) @@ -92,7 +112,7 @@ Optional argument SEQUENCE-SEP is the separator applied between elements of a se (delq nil (mapcar (lambda (cons-cell) - ;; TODO the resolution of `f' is O(2n) in the worst + ;; @TODO the resolution of `f' is O(2n) in the worst ;; case. It may be faster to implement the same ;; functionality as a `while' loop to stop looking the ;; list once it has found a match. Do speed tests. @@ -112,7 +132,7 @@ Optional argument SEQUENCE-SEP is the separator applied between elements of a se ;;; Interpreting request data (defun sx--deep-dot-search (data) "Find symbols somewhere inside DATA which start with a `.'. -Returns a list where each element is a cons cell. The car is the +Returns a list where each element is a cons cell. The car is the symbol, the cdr is the symbol without the `.'." (cond ((symbolp data) @@ -127,13 +147,13 @@ symbol, the cdr is the symbol without the `.'." (remove nil (mapcar #'sx--deep-dot-search data)))))) (defmacro sx-assoc-let (alist &rest body) - "Execute BODY while let-binding dotted symbols to their values in ALIST. -Dotted symbol is any symbol starting with a `.'. Only those + "Use dotted symbols let-bound to their values in ALIST and execute BODY. +Dotted symbol is any symbol starting with a `.'. Only those present in BODY are letbound, which leads to optimal performance. -For instance the following code +For instance, the following code - (stack-core-with-data alist + (sx-assoc-let alist (list .title .body)) is equivalent to @@ -150,19 +170,19 @@ is equivalent to (defcustom sx-init-hook nil "Hook run when stack-mode initializes. - -Run after `sx-init--internal-hook'.") +Run after `sx-init--internal-hook'." + :group 'sx + :type 'hook) (defvar sx-init--internal-hook nil "Hook run when stack-mode initializes. - This is used internally to set initial values for variables such as filters.") -(defun sx--< (property x y &optional pred) +(defun sx--< (property x y &optional predicate) "Non-nil if PROPERTY attribute of alist X is less than that of Y. -With optional argument PRED, use it instead of `<'." - (funcall (or pred #'<) +With optional argument PREDICATE, use it instead of `<'." + (funcall (or predicate #'<) (cdr (assoc property x)) (cdr (assoc property y)))) @@ -184,6 +204,7 @@ If it has, holds the time at which initialization happened.") (defun sx-initialize (&optional force) "Run initialization hooks if they haven't been run yet. These are `sx-init--internal-hook' and `sx-init-hook'. + If FORCE is non-nil, run them even if they've already been run." (when (or force (not sx-initialized)) (prog1 @@ -0,0 +1,126 @@ +#+MACRO: version 0.1 +#+MACRO: versiondate 16 November 2014 +#+MACRO: updated last updated {{{versiondate}}} + +#+TITLE: SX: A StackExchange Client (v{{{version}}}) +#+DATE: 16 November 2014 +#+AUTHOR: @@texinfo:@url{@@www.github.com/vermiculus/stack-mode@@texinfo:}@@ +#+LANGUAGE: en + +#+OPTIONS: ':t toc:t + +#+TEXINFO_FILENAME: sx.info +#+TEXINFO_HEADER: @syncodeindex pg cp + +#+TEXINFO_DIR_CATEGORY: Texinfo documentation system +#+TEXINFO_DIR_TITLE: SX: (StackExchange Client) +#+TEXINFO_DIR_DESC: A StackExchange client for Emacs + +#+TEXINFO_PRINTED_TITLE: SX: A StackExchange Client +#+SUBTITLE: for version {{{version}}}, last updated {{{versiondate}}} + +* Copying + :PROPERTIES: + :COPYING: t + :END: + +This manual is for SX (version {{{version}}}, {{{updated}}}), a +StackExchange client for Emacs. + +Copyright © 2014 Free Software Foundation, Inc. + +#+BEGIN_QUOTE +Permission is granted to copy, distribute and/or modify this +document under the terms of the GNU Free Documentation License, +Version 1.3 or any later version published by the Free Software +Foundation; with no Invariant Sections, with no Front-Cover Texts, +and with no Back-Cover Texts. A copy of the license is included in +the section entitled "GNU Free Documentation License". +#+END_QUOTE + +* Introduction +SX is a StackExchange client for Emacs. This means that it supports +many of the same features that the official web interface does, but in +a way that is /specialized/ for Emacs: + +- question browsing for any site on the network +- asking, answering, and commenting +- advanced searching and filtering +- offline archiving +- inbox notifications +- ... + +All of these features are implemented in a way that makes sense with +Emacs conventions. Of course, the core convention of Emacs is +arbitrary customizability -- [[#hooks][hack away]]! + +* Basic Usage +** Authenticating +Use ~sx-auth-authenticate~. Calling this function will open up a +webpage on StackExchange that will prompt you to authorize this +application. Once you do this, StackExchange will redirect you to our +own authorization landing page. This landing page will prominently +display your access token. (This is /your/ token -- keep this +private!) Enter this token into Emacs. Emacs will set and save it to +a cache file. + +** Browsing Questions +To browse a list of questions retrieved from the site, use +~list-questions~. This pulls the first page of questions from the +current site and displays them in a list. Refresh the page with =g=, +use =n= and =p= to navigate without viewing the question, =j= and =k= +to do the same /with/ viewing the question, and =RET= to visit the +question at point. + +* List of Hooks + :PROPERTIES: + :CUSTOM_ID: hooks + :END: + +# Do not list internal hooks. While they are useful, they should be +# used only by contributors. + +- ~sx-init-hook~ :: Run when ~sx-initialize~ is called. + +* Contributing +This document is maintained in Org format. Updates to the source code +should be accompanied by updates to this document when user-facing +functionality is changed. + +Note that some distinctions are made which may not be apparent when +viewing the document with Info. + +** Markup Conventions +Markup is used consistently as follows: + +- packages :: =package.el= +- keybinding :: =C-x C-s= (use ~kbd~ format) +- values :: =value= +- symbols :: =symbol= +- functions :: ~function~ + +To make the Info export readable, lists and source code blocks are +separated from body text with a blank line (as to start a new +paragraph). + +** Document Attributes +Attributes should be given in uppercase: + +#+BEGIN_SRC org + ,#+BEGIN_SRC elisp + (some elisp) + ,#+END_SRC +#+END_SRC + +** Source Code Blocks +The language for Emacs Lisp source code blocks should be given as +=elisp= and its content should be indented by two spaces. See +~org-edit-src-content-indentation~. + +* COMMENT Local Variables +# LocalWords: StackExchange SX inbox sx API url json inline Org +# LocalWords: Markup keybinding keybindings customizability webpage + +# Local Variables: +# org-export-date-timestamp-format: "$B %e %Y" +# End: diff --git a/test/tests.el b/test/tests.el index d53cd31..c76e137 100644 --- a/test/tests.el +++ b/test/tests.el @@ -32,7 +32,6 @@ sx-initialized t sx-request-remaining-api-requests-message-threshold 50000 debug-on-error t - sx-request-silent-p nil user-emacs-directory "." sx-test-data-questions |