aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agignore20
-rw-r--r--.gitignore2
-rw-r--r--sx-auth.el21
-rw-r--r--sx-cache.el31
-rw-r--r--sx-encoding.el77
-rw-r--r--sx-filter.el35
-rw-r--r--sx-method.el22
-rw-r--r--sx-question-list.el66
-rw-r--r--sx-question-mode.el73
-rw-r--r--sx-question.el65
-rw-r--r--sx-request.el98
-rw-r--r--sx-site.el10
-rw-r--r--sx-time.el2
-rw-r--r--sx.el42
-rw-r--r--sx.org124
-rw-r--r--test/tests.el1
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
diff --git a/.gitignore b/.gitignore
index 2585bf5..cfaa152 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,5 @@
.dir-locals.el
/.stackmode/
/url/
+/sx.info
+/sx.texi
diff --git a/sx-auth.el b/sx-auth.el
index b470523..4299f37 100644
--- a/sx-auth.el
+++ b/sx-auth.el
@@ -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))
diff --git a/sx-site.el b/sx-site.el
index 6bef91f..4e880b1 100644
--- a/sx-site.el
+++ b/sx-site.el
@@ -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 ()
diff --git a/sx-time.el b/sx-time.el
index 9c4dfaa..1ce0886 100644
--- a/sx-time.el
+++ b/sx-time.el
@@ -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)
diff --git a/sx.el b/sx.el
index eab1ead..8b2456d 100644
--- a/sx.el
+++ b/sx.el
@@ -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
diff --git a/sx.org b/sx.org
new file mode 100644
index 0000000..10adf1c
--- /dev/null
+++ b/sx.org
@@ -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