diff options
-rw-r--r-- | .agignore | 20 | ||||
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | sx-auth.el | 21 | ||||
-rw-r--r-- | sx-cache.el | 31 | ||||
-rw-r--r-- | sx-encoding.el | 77 | ||||
-rw-r--r-- | sx-filter.el | 35 | ||||
-rw-r--r-- | sx-method.el | 22 | ||||
-rw-r--r-- | sx-question-list.el | 66 | ||||
-rw-r--r-- | sx-question-mode.el | 73 | ||||
-rw-r--r-- | sx-question.el | 65 | ||||
-rw-r--r-- | sx-request.el | 98 | ||||
-rw-r--r-- | sx-site.el | 10 | ||||
-rw-r--r-- | sx-time.el | 2 | ||||
-rw-r--r-- | sx.el | 42 | ||||
-rw-r--r-- | sx.org | 124 | ||||
-rw-r--r-- | test/tests.el | 1 |
16 files changed, 523 insertions, 166 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 @@ -46,7 +46,26 @@ what you are doing!") 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 diff --git a/sx-cache.el b/sx-cache.el index a564a53..63025ea 100644 --- a/sx-cache.el +++ b/sx-cache.el @@ -30,10 +30,15 @@ (defcustom sx-cache-directory (expand-file-name ".stackmode" user-emacs-directory) - "Directory containined cached files and precompiled filters.") + "Directory containining cached data.") + +(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)) @@ -41,28 +46,28 @@ (defun sx-cache-get (cache &optional form) "Return the data within CACHE. -If CACHE does not exist, evaluate FORM and set it to its return. +If CACHE does not exist, use `sx-cache-set' to set CACHE to the +result of evaluating FORM. -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 changes permanently. -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'. -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) diff --git a/sx-encoding.el b/sx-encoding.el index 9d48e60..8af020e 100644 --- a/sx-encoding.el +++ b/sx-encoding.el @@ -19,8 +19,6 @@ ;;; Commentary: -;; - ;;; Code: (require 'cl-lib) @@ -62,28 +60,48 @@ 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 +109,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)) @@ -112,26 +137,24 @@ See `sx-encoding-clean-content'." (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 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." +See `sx-encoding-gzipped-p'." (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-filter.el b/sx-filter.el index 90681e8..1241614 100644 --- a/sx-filter.el +++ b/sx-filter.el @@ -19,8 +19,6 @@ ;;; Commentary: -;; - ;;; Code: @@ -35,25 +33,32 @@ (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 symbol -or string." +INCLUDE and EXCLUDE must both be lists; BASE should be a 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 +69,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 alise 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 e9c4f60..2d8f9d2 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) @@ -29,7 +32,7 @@ (require 'sx-filter) (defun sx-method-call - (method &optional keyword-arguments filter need-auth use-post silent) + (method &optional keyword-arguments filter need-auth use-post) "Call METHOD with KEYWORD-ARGUMENTS using FILTER. If NEED-AUTH is non-nil, an auth-token is required. If 'WARN, @@ -39,18 +42,13 @@ token set. If USE-POST is non-nil, use `POST' rather than `GET' for passing arguments. -If SILENT is non-nil, no messages will be printed. +Return the response content as a complex alist. -Return the entire response as a complex alist." - (sx-request-make - method - (cons (cons 'filter - (sx-filter-get-var - (cond (filter filter) - ((boundp 'stack-filter) stack-filter)))) +See `sx-request-make' and `sx-filter-get-var'." + (sx-request-make method + (cons (cons 'filter (sx-filter-get-var filter)) keyword-arguments) - need-auth - use-post)) + need-auth use-post)) (provide 'sx-method) ;;; sx-method.el ends here diff --git a/sx-question-list.el b/sx-question-list.el index be088c8..6a36f6f 100644 --- a/sx-question-list.el +++ b/sx-question-list.el @@ -166,7 +166,7 @@ Non-interactively, DATA is a question alist." (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,10 +210,11 @@ 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.") (defun sx-question-list-refresh (&optional redisplay no-update) "Update the list of questions. + If REDISPLAY is non-nil (or if interactive), also call `tabulated-list-print'. If the prefix argument NO-UPDATE is nil, query StackExchange for a new list before redisplaying." @@ -244,33 +245,37 @@ 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\"." :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' 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,42 @@ 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 to the previous question and display it. + +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 to the next question and display it. + +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 to the next entry. + +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 to the previous entry. + +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 +352,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 +360,12 @@ 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 `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) @@ -358,8 +378,10 @@ focus the relevant window." (defvar sx-question-list--buffer nil "Buffer where the list of questions is displayed.") -(defun list-questions (no-update) - "Display a list of StackExchange questions." +(defun sx-list-questions (no-update) + "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) @@ -370,7 +392,7 @@ focus the relevant window." (sx-question-list-refresh 'redisplay no-update)) (switch-to-buffer sx-question-list--buffer)) -(defalias 'sx-list-questions #'list-questions) +(defalias 'list-questions #'sx-list-questions) (provide 'sx-question-list) ;;; sx-question-list.el ends here diff --git a/sx-question-mode.el b/sx-question-mode.el index 089ee12..627081b 100644 --- a/sx-question-mode.el +++ b/sx-question-mode.el @@ -19,8 +19,6 @@ ;;; Commentary: -;; - ;;; Code: (require 'markdown-mode) @@ -53,7 +51,9 @@ (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 +65,9 @@ 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,6 +86,7 @@ If WINDOW is given, use that to display the buffer." ;;; Printing a question's content ;;;; Faces and Variables + (defvar sx-question-mode--overlays nil "") (make-variable-buffer-local 'sx-question-mode--overlays) @@ -147,14 +150,16 @@ 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 +181,9 @@ 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) @@ -191,6 +197,7 @@ Second \"%s\" is replaced with the comment." ;;;; Functions (defun sx-question-mode--print-question (question) "Print a buffer describing QUESTION. + QUESTION must be a data structure returned by `json-read'." (setq sx-question-mode--data question) ;; Clear the overlays @@ -220,6 +227,7 @@ QUESTION must be a data structure returned by `json-read'." (defun sx-question-mode--print-section (data) "Print a section corresponding to DATA. + DATA can represent a question or an answer." ;; This makes `data' accessible through ;; `(get-text-property (point) 'sx-question-mode--data-here)' @@ -293,9 +301,12 @@ 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 printed according to +`sx-question-mode-comments-format'." + (sx-assoc-let comment-data (insert (format sx-question-mode-comments-format @@ -311,7 +322,10 @@ DATA can represent a question or an answer." (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. + +Overlay is pushed on `sx-question-mode--overlays' and given +PROPERTIES. + Return the result of BODY." (declare (indent 1) (debug t)) @@ -326,6 +340,7 @@ Return the result of BODY." (defmacro sx-question-mode--wrap-in-text-property (properties &rest body) "Execute BODY and PROPERTIES to any inserted text. + Return the result of BODY." (declare (indent 1) (debug t)) @@ -335,9 +350,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. + +Use as (fn header value face + [header value face] ...)" (while args (insert (propertize (pop args) 'face 'sx-question-mode-header) @@ -351,7 +371,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) @@ -409,6 +429,7 @@ HEADER is given `sx-question-mode-header' face, and value is given FACE. (defun sx-question-mode--propertize-link (text url) "Return a link propertized version of string TEXT. + URL is used as 'help-echo and 'url properties." (propertize text @@ -430,22 +451,23 @@ 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)))))) -(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))))) @@ -466,8 +488,10 @@ If ID is nil, use ID2 instead." ;; for comments). (defcustom sx-question-mode-recenter-line 1 "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) @@ -475,6 +499,7 @@ If nil, no recentering is performed." (defun sx-question-mode-next-section (&optional n) "Move down to next section (question or answer) of this buffer. + Prefix argument N moves N sections down or up." (interactive "p") (let ((count (if n (abs n) 1))) @@ -497,13 +522,16 @@ 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." (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. + 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) @@ -537,8 +565,10 @@ 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. @@ -585,6 +615,7 @@ Letters do not insert themselves; instead, they are commands. (defun sx-question-mode-refresh () "Refresh currently displayed question. + Queries the API for any changes to the question or its answers or comments, and redisplays it." (interactive) diff --git a/sx-question.el b/sx-question.el index 972e9d9..d576b73 100644 --- a/sx-question.el +++ b/sx-question.el @@ -19,8 +19,6 @@ ;;; Commentary: -;; - ;;; Code: @@ -44,10 +42,16 @@ answer.owner answer.body_markdown answer.comments) - (user.profile_image shallow_user.profile_image))) + (user.profile_image shallow_user.profile_image)) + "The filter applied with `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 the page PAGE of questions from SITE. + +Return a list of questions, each consed with (site SITE). + +`sx-method-call' is used with `sx-question-browse-filter'." (mapcar (lambda (question) (cons (cons 'site site) question)) (sx-method-call @@ -56,33 +60,46 @@ (page . ,page)) 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 - (format "questions/%s" id) + (format "questions/%s" question-id) `((site . ,site)) 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 "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)))) @@ -91,7 +108,9 @@ 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)) @@ -108,20 +127,28 @@ 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 "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-LIST). + +And each element in QUESTION-LIST has the form + + (QUESTION_ID . LAST-VIEWED-DATE).") (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 @@ -158,13 +185,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 d982057..89c9a59 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,26 +67,28 @@ (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.") - +;;; @TODO Shouldn't this be made moot by our caching system? (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.") (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.") @@ -71,26 +96,44 @@ number of requests left every time it finishes a call.") ;;; Making Requests (defun sx-request-make - (method &optional args need-auth use-post silent) + (method &optional args need-auth use-post) + "Make a request to the API, executing METHOD with ARGS. + +You should almost certainly be using `sx-method-call' instead of +this function. + +Returns cleaned response content. +See (`sx-encoding-clean-content-deep'). + +The full call is built with `sx-request-build', prepending +`sx-request-api-key' to receive a higher quota. 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." (let ((url-automatic-caching sx-request-cache-p) (url-inhibit-uncompression t) - (silent (or silent sx-request-silent-p)) (request-method (if use-post "POST" "GET")) (request-args (sx-request--build-keyword-arguments args nil need-auth)) (request-url (concat sx-request-api-root method))) - (unless silent (sx-message "Request: %S" request-url)) + (sx-message "Request: %S" request-url) (let ((response-buffer (sx-request--request request-url request-args - request-method - silent))) + request-method))) (if (not response-buffer) (error "Something went wrong in `url-retrieve-synchronously'") (with-current-buffer response-buffer (let* ((data (progn (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)) @@ -100,6 +143,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 "{}")) @@ -110,8 +155,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)) @@ -120,19 +164,23 @@ number of requests left every time it finishes a call.") ;;; Support Functions -(defun sx-request--request (url args method silent) +(defun sx-request--request (url args method) + "Return the response buffer for URL with ARGS using METHOD." (let ((url-request-method method) (url-request-extra-headers '(("Content-Type" . "application/x-www-form-urlencoded"))) (url-request-data args)) - (cond - ((equal '(24 . 4) (cons emacs-major-version emacs-minor-version)) - (url-retrieve-synchronously url silent)) - (t (url-retrieve-synchronously url))))) + (url-retrieve-synchronously url))) + (defun sx-request--build-keyword-arguments (alist &optional - kv-value-sep need-auth) - "Build a \"key=value&key=value&...\"-style string with the elements + 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. + +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 @@ -148,7 +196,7 @@ false, use the symbol `false'. Each element is processed with ;; Pass user error when asking to warn (warn (user-error - "This query requires authentication. Please run `M-x sx-auth-authenticate' and try again.")) + "This query requires authentication; run `M-x sx-auth-authenticate' and try again")) ((not auth) (lwarn "stack-mode" :debug "This query requires authentication") @@ -161,7 +209,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,7 +41,8 @@ related_site.api_site_parameter related_site.relation) nil - none)) + none) + "") (defun sx-site--get-site-list () (sx-cache-get @@ -54,7 +53,10 @@ (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 () @@ -51,12 +51,14 @@ (defcustom sx-time-date-format-year "%H:%M %e %b %Y" "Format used for dates on a past year. + See also `sx-time-date-format'." :type 'string :group 'sx-time) (defcustom sx-time-date-format "%H:%M - %d %b" "Format used for dates on this year. + See also `sx-time-date-format-year'." :type 'string :group 'sx-time) @@ -71,8 +71,11 @@ 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 yeild + + ((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,6 +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 symbol, the cdr is the symbol without the `.'." (cond @@ -127,11 +148,12 @@ 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. + "Execute BODY with dotted symbols let-bound to their values in ALIST. + 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 (list .title .body)) @@ -159,15 +181,17 @@ Run after `sx-init--internal-hook'.") 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)))) (defmacro sx-init-variable (variable value &optional setter) "Set VARIABLE to VALUE using SETTER. + SETTER should be a function of two arguments. If SETTER is nil, `set' is used." (eval @@ -183,7 +207,9 @@ 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,124 @@ +#+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. + +* About this Document +This document is maintained in Org format. Updates to the source code +should almost always be accompanied by updates to this document. 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 6a48257..bb23310 100644 --- a/test/tests.el +++ b/test/tests.el @@ -31,7 +31,6 @@ (setq sx-request-remaining-api-requests-message-threshold 50000 debug-on-error t - sx-request-silent-p nil user-emacs-directory "." sx-test-data-questions |