aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSean Allred <code@seanallred.com>2014-11-18 23:49:07 -0500
committerSean Allred <code@seanallred.com>2014-11-19 00:00:42 -0500
commit0dd95e3a3d4ee52f52a585388c3ba65e045c305b (patch)
treef8c4497519cf6f741ea7ec379c537f4b71a4de88
parent20dd7254da8e95bd01ce57f806733dee20005039 (diff)
parent681319aeb250a83d982d1e3e02264a7af0ae4120 (diff)
Merge branch 'master' into documentation
Conflicts: sx-method.el sx-question-list.el sx-question-mode.el sx-question.el sx-request.el sx.el
-rw-r--r--sx-auth.el22
-rw-r--r--sx-cache.el25
-rw-r--r--sx-favorites.el86
-rw-r--r--sx-method.el12
-rw-r--r--sx-networks.el112
-rw-r--r--sx-question-list.el97
-rw-r--r--sx-question-mode.el164
-rw-r--r--sx-question.el63
-rw-r--r--sx-request.el87
-rw-r--r--sx.el64
10 files changed, 584 insertions, 148 deletions
diff --git a/sx-auth.el b/sx-auth.el
index 96523f6..4299f37 100644
--- a/sx-auth.el
+++ b/sx-auth.el
@@ -28,7 +28,7 @@
(require 'sx-cache)
(defconst sx-auth-root
- "https://stackexchange.com/oauth/")
+ "https://stackexchange.com/oauth/dialog")
(defconst sx-auth-redirect-uri
"http://vermiculus.github.io/stack-mode/auth/auth.htm")
(defconst sx-auth-client-id
@@ -69,15 +69,17 @@ parsed and displayed prominently on the page)."
(interactive)
(setq
sx-auth-access-token
- (let ((url (sx-request-build
- "dialog"
- `((client_id . ,sx-auth-client-id)
- (scope . (read_inbox
- no_expiry
- write_access))
- (redirect_uri . ,(url-hexify-string
- sx-auth-redirect-uri)))
- "," sx-auth-root)))
+ (let ((url (concat
+ sx-auth-root
+ "?"
+ (sx-request--build-keyword-arguments
+ `((client_id . ,sx-auth-client-id)
+ (scope . (read_inbox
+ no_expiry
+ write_access))
+ (redirect_uri . ,(url-hexify-string
+ sx-auth-redirect-uri)))
+ ","))))
(browse-url url)
(read-string "Enter the access token displayed on the webpage: ")))
(if (string-equal "" sx-auth-access-token)
diff --git a/sx-cache.el b/sx-cache.el
index 80b6ced..63025ea 100644
--- a/sx-cache.el
+++ b/sx-cache.el
@@ -72,6 +72,31 @@ CACHE is resolved to a file name by `sx-cache-get-file-name'."
(sx-cache-get-file-name cache))
data)
+(defun sx-cache--invalidate (cache &optional vars init-method)
+ "Set cache CACHE to nil.
+
+VARS is a list of variables to unbind to ensure cache is cleared.
+If INIT-METHOD is defined, call it after all invalidation to
+re-initialize the cache."
+ (sx-cache-set cache nil)
+ (mapc #'makunbound vars)
+ (funcall init-method))
+
+(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."
+ (let ((caches (let ((default-directory sx-cache-directory))
+ (file-expand-wildcards "*.el"))))
+ (when save-auth
+ (setq caches (cl-remove-if (lambda (x)
+ (string= x "auth.el")) caches)))
+ (lwarn 'stack-mode :debug "Invalidating: %S" caches)
+ (mapc #'sx-cache--invalidate caches)
+ (sx-initialize 'force)))
+
(provide 'sx-cache)
;;; sx-cache.el ends here
diff --git a/sx-favorites.el b/sx-favorites.el
new file mode 100644
index 0000000..3aa96dd
--- /dev/null
+++ b/sx-favorites.el
@@ -0,0 +1,86 @@
+;;; sx-favorites.el --- Starred questions -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2014 Sean Allred
+
+;; Author: Sean Allred <code@seanallred.com>
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;;
+
+;;; Code:
+
+(require 'sx-method)
+(require 'sx-cache)
+(require 'sx-site)
+(require 'sx-networks)
+
+(defvar sx-favorite-list-filter
+ '((.backoff
+ .items
+ .quota_max
+ .quota_remaining
+ question.question_id)
+ nil
+ none))
+
+(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))
+ (sx-favorites-update)))
+;; Append to ensure `sx-network--initialize is run before it.
+(add-hook 'sx-init--internal-hook #'sx-favorites--initialize 'append)
+
+(defun sx-favorites--retrieve-favorites (site)
+ "Obtain list of starred QUESTION_IDs for SITE."
+ (sx-method-call (format "me/favorites?site=%s" site)
+ nil
+ sx-favorite-list-filter
+ 'warn))
+
+(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
+ sx-favorites--user-favorite-list))
+ (fav-cell (mapcar #'cdar favs)))
+ (if site-cell
+ (setcdr site-cell fav-cell)
+ (push (cons site fav-cell) sx-favorites--user-favorite-list))
+ (sx-cache-set 'question-favorites sx-favorites--user-favorite-list)))
+
+(defun sx-favorites-update ()
+ "Update all sites retrieved from `sx-network--user-sites'."
+ (mapc #'sx-favorites--update-site-favorites
+ sx-network--user-sites))
+
+(provide 'sx-favorites)
+;;; sx-favorites.el ends here
+
+;; Local Variables:
+;; indent-tabs-mode: nil
+;; End:
diff --git a/sx-method.el b/sx-method.el
index 9a6dcc5..2d8f9d2 100644
--- a/sx-method.el
+++ b/sx-method.el
@@ -32,15 +32,23 @@
(require 'sx-filter)
(defun sx-method-call
- (method &optional keyword-arguments filter)
+ (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,
+warn the user `(user-error ...)' if they do not have an AUTH
+token set.
+
+If USE-POST is non-nil, use `POST' rather than `GET' for passing
+arguments.
+
Return the response content as a complex alist.
See `sx-request-make' and `sx-filter-get-var'."
(sx-request-make method
(cons (cons 'filter (sx-filter-get-var filter))
- keyword-arguments)))
+ keyword-arguments)
+ need-auth use-post))
(provide 'sx-method)
;;; sx-method.el ends here
diff --git a/sx-networks.el b/sx-networks.el
new file mode 100644
index 0000000..755d62c
--- /dev/null
+++ b/sx-networks.el
@@ -0,0 +1,112 @@
+;;; sx-networks.el --- user network information -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2014 Sean Allred
+
+;; Author: Sean Allred <code@seanallred.com>
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;;
+
+;;; Code:
+
+(require 'sx-method)
+(require 'sx-cache)
+(require 'sx-site)
+
+(defvar sx-network--user-filter
+ '((.backoff
+ .error_id
+ .error_message
+ .error_name
+ .has_more
+ .items
+ .quota_max
+ .quota_remaining
+ badge_count.bronze
+ badge_count.silver
+ badge_count.gold
+ network_user.account_id
+ network_user.answer_count
+ network_user.badge_counts
+ network_user.creation_date
+ network_user.last_access_date
+ network_user.reputation
+ network_user.site_name
+ network_user.site_url
+ network_user.user_id
+ network_user.user_type)
+ nil
+ none))
+
+(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
+ (sx-network--map-site-url-to-site-api)))
+ (sx-network--update)))
+
+(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
+ (sx-method-call "me/associated"
+ '((types . (main_site meta_site)))
+ sx-network--user-filter
+ 'warn))
+ (sx-network--get-associated))
+
+(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))
+(add-hook 'sx-init--internal-hook #'sx-network--initialize)
+
+(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."
+ (let ((sites-info (mapcar (lambda (x)
+ (cons (cdr (assoc 'site_url x))
+ (cdr (assoc 'api_site_parameter
+ x))))
+ (sx-site--get-site-list))))
+ (mapcar (lambda (loc)
+ (let ((u-site (cdr (assoc 'site_url loc))))
+ (when (member u-site (mapcar #'car sites-info))
+ (cdr (assoc u-site sites-info)))))
+ sx-network--user-information)))
+
+(defvar sx-network--user-information nil
+ "User information for the various sites.")
+
+(defvar sx-network--user-sites nil
+ "List of sites where user already has an account.")
+
+(provide 'sx-networks)
+;;; sx-networks.el ends here
+
+;; Local Variables:
+;; indent-tabs-mode: nil
+;; End:
diff --git a/sx-question-list.el b/sx-question-list.el
index 26b7c2f..6a36f6f 100644
--- a/sx-question-list.el
+++ b/sx-question-list.el
@@ -28,6 +28,7 @@
(require 'sx-site)
(require 'sx-question)
(require 'sx-question-mode)
+(require 'sx-favorites)
;;; Customization
@@ -86,6 +87,11 @@
""
:group 'sx-question-list-faces)
+(defface sx-question-list-favorite
+ '((t :inherit sx-question-list-score-upvoted))
+ ""
+ :group 'sx-question-list-faces)
+
;;; Mode Definition
(define-derived-mode sx-question-list-mode tabulated-list-mode "Question List"
@@ -132,8 +138,33 @@ Letters do not insert themselves; instead, they are commands.
("g" sx-question-list-refresh)
(":" sx-question-list-switch-site)
("v" sx-question-list-visit)
+ ("h" sx-question-list-hide)
+ ("m" sx-question-list-mark-read)
([?\r] sx-question-list-display-question)))
+(defun sx-question-list-hide (data)
+ "Hide question under point.
+Non-interactively, DATA is a question alist."
+ (interactive
+ (list (if (derived-mode-p 'sx-question-list-mode)
+ (tabulated-list-get-id)
+ (user-error "Not in `sx-question-list-mode'"))))
+ (sx-question--mark-hidden data)
+ (when (called-interactively-p 'any)
+ (sx-question-list-refresh 'redisplay 'noupdate)))
+
+(defun sx-question-list-mark-read (data)
+ "Mark as read question under point.
+Non-interactively, DATA is a question alist."
+ (interactive
+ (list (if (derived-mode-p 'sx-question-list-mode)
+ (tabulated-list-get-id)
+ (user-error "Not in `sx-question-list-mode'"))))
+ (sx-question--mark-read data)
+ (sx-question-list-next 1)
+ (when (called-interactively-p 'any)
+ (sx-question-list-refresh 'redisplay 'noupdate)))
+
(defvar sx-question-list--current-page "Latest"
;; @TODO Other values (once we implement them) are "Top Voted",
;; "Unanswered", etc.
@@ -198,7 +229,8 @@ a new list before redisplaying."
(setq sx-question-list--current-dataset question-list)
;; Print the result.
(setq tabulated-list-entries
- (mapcar #'sx-question-list--print-info question-list)))
+ (mapcar #'sx-question-list--print-info
+ (cl-remove-if #'sx-question--hidden-p question-list))))
(when redisplay (tabulated-list-print 'remember)))
(defun sx-question-list-visit (&optional data)
@@ -219,37 +251,43 @@ Used in the questions list to indicate a question was updated \"4d ago\"."
:group 'sx-question-list)
(defun sx-question-list--print-info (question-data)
- "Format QUESTION-DATA for display in the list.
+ "Convert `json-read' DATA into tabulated-list format.
See `sx-question-list-refresh'."
(sx-assoc-let question-data
- (list
- 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 question-data)
- 'sx-question-list-answers-accepted
- 'sx-question-list-answers))
- (concat
- (propertize
- .title
- 'face (if (sx-question--read-p question-data)
- 'sx-question-list-read-question
- ;; 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 ")
- (propertize (concat (sx-time-since .last_activity_date)
- sx-question-list-ago-string)
- 'face 'sx-question-list-date)
- " "
- (propertize (mapconcat #'sx-question--tag-format .tags " ")
- 'face 'sx-question-list-tags)
- (propertize " " 'display "\n"))))))
+ (let ((favorite (if (member .question_id
+ (assoc .site
+ sx-favorites--user-favorite-list))
+ (if (char-displayable-p ?\x2b26) "\x2b26" "*") " ")))
+ (list
+ 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 question-data)
+ 'sx-question-list-answers-accepted
+ 'sx-question-list-answers))
+ (concat
+ (propertize
+ .title
+ 'face (if (sx-question--read-p question-data)
+ 'sx-question-list-read-question
+ ;; 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 ")
+ (propertize favorite 'face 'sx-question-list-favorite)
+ " "
+ (propertize (concat (sx-time-since .last_activity_date)
+ sx-question-list-ago-string)
+ 'face 'sx-question-list-date)
+ " "
+ (propertize (mapconcat #'sx-question--tag-format .tags " ")
+ 'face 'sx-question-list-tags)
+ (propertize " " 'display "\n")))))))
(defun sx-question-list-view-previous (n)
"Move to the previous question and display it.
@@ -345,6 +383,7 @@ completions from `sx-site-get-api-tokens'. Sets
NO-UPDATE is passed to `sx-question-list-refresh'."
(interactive "P")
+ (sx-initialize)
(unless (buffer-live-p sx-question-list--buffer)
(setq sx-question-list--buffer
(generate-new-buffer "*question-list*")))
diff --git a/sx-question-mode.el b/sx-question-mode.el
index d971a49..627081b 100644
--- a/sx-question-mode.el
+++ b/sx-question-mode.el
@@ -199,6 +199,7 @@ replaced with the comment."
"Print a buffer describing QUESTION.
QUESTION must be a data structure returned by `json-read'."
+ (setq sx-question-mode--data question)
;; Clear the overlays
(mapc #'delete-overlay sx-question-mode--overlays)
(setq sx-question-mode--overlays nil)
@@ -224,71 +225,75 @@ QUESTION must be a data structure returned by `json-read'."
follow-link t)
"")
-(defun sx-question-mode--print-section (question-data)
- "Print a section corresponding to QUESTION-DATA.
-
-QUESTION-DATA can represent a question or an answer."
- (sx-assoc-let question-data
- (insert sx-question-mode-header-title
- (apply
- #'propertize
- ;; Questions have title
- (or .title
- ;; Answers don't
- sx-question-mode-answer-title)
- ;; Section level
- 'sx-question-mode--section (if .title 1 2)
- ;; face, action and help-echo
- sx-question-mode--title-properties))
- ;; Sections can be hidden with overlays
- (sx-question-mode--wrap-in-overlay
- '(sx-question-mode--section-content t)
- (sx-question-mode--insert-header
- ;; Author
- sx-question-mode-header-author
- (sx-question-mode--propertize-display-name .owner)
- 'sx-question-mode-author
- ;; Date
- sx-question-mode-header-date
- (concat
- (sx-time-seconds-to-date .creation_date)
- (when .last_edit_date
- (format sx-question-mode-last-edit-format
- (sx-time-since .last_edit_date)
- (sx-question-mode--propertize-display-name .last_editor))))
- 'sx-question-mode-date)
- (when .title
- ;; Tags
- (sx-question-mode--insert-header
- sx-question-mode-header-tags
- (mapconcat #'sx-question--tag-format .tags " ")
- 'sx-question-mode-tags))
- ;; Body
- (insert "\n"
- (propertize sx-question-mode-separator
- 'face 'sx-question-mode-header
- 'sx-question-mode--section 4))
+(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)'
+ (sx-question-mode--wrap-in-text-property
+ (list 'sx-question-mode--data-here data)
+ (sx-assoc-let data
+ (insert sx-question-mode-header-title
+ (apply
+ #'propertize
+ ;; Questions have title
+ (or .title
+ ;; Answers don't
+ sx-question-mode-answer-title)
+ ;; Section level
+ 'sx-question-mode--section (if .title 1 2)
+ ;; face, action and help-echo
+ sx-question-mode--title-properties))
+ ;; Sections can be hidden with overlays
(sx-question-mode--wrap-in-overlay
- '(face sx-question-mode-content-face)
+ '(sx-question-mode--section-content t)
+ (sx-question-mode--insert-header
+ ;; Author
+ sx-question-mode-header-author
+ (sx-question-mode--propertize-display-name .owner)
+ 'sx-question-mode-author
+ ;; Date
+ sx-question-mode-header-date
+ (concat
+ (sx-time-seconds-to-date .creation_date)
+ (when .last_edit_date
+ (format sx-question-mode-last-edit-format
+ (sx-time-since .last_edit_date)
+ (sx-question-mode--propertize-display-name .last_editor))))
+ 'sx-question-mode-date)
+ (when .title
+ ;; Tags
+ (sx-question-mode--insert-header
+ sx-question-mode-header-tags
+ (mapconcat #'sx-question--tag-format .tags " ")
+ 'sx-question-mode-tags))
+ ;; Body
(insert "\n"
- (sx-question-mode--fill-and-fontify
- .body_markdown)
(propertize sx-question-mode-separator
- 'face 'sx-question-mode-header))))
- ;; Comments
- (when .comments
- (insert "\n"
- (apply #'propertize
- sx-question-mode-comments-title
- 'face 'sx-question-mode-title-comments
- 'sx-question-mode--section 3
- sx-question-mode--title-properties))
- (sx-question-mode--wrap-in-overlay
- '(sx-question-mode--section-content t)
- (insert "\n")
+ 'face 'sx-question-mode-header
+ 'sx-question-mode--section 4))
(sx-question-mode--wrap-in-overlay
'(face sx-question-mode-content-face)
- (mapc #'sx-question-mode--print-comment .comments))))))
+ (insert "\n"
+ (sx-question-mode--fill-and-fontify
+ .body_markdown)
+ (propertize sx-question-mode-separator
+ 'face 'sx-question-mode-header))))
+ ;; Comments
+ (when .comments
+ (insert "\n"
+ (apply #'propertize
+ sx-question-mode-comments-title
+ 'face 'sx-question-mode-title-comments
+ 'sx-question-mode--section 3
+ sx-question-mode--title-properties))
+ (sx-question-mode--wrap-in-overlay
+ '(sx-question-mode--section-content t)
+ (insert "\n")
+ (sx-question-mode--wrap-in-overlay
+ '(face sx-question-mode-content-face)
+ (mapc #'sx-question-mode--print-comment .comments)))))))
(defun sx-question-mode--propertize-display-name (author)
"Return display_name of AUTHOR with `sx-question-mode-author' face."
@@ -333,6 +338,17 @@ Return the result of BODY."
(push ov sx-question-mode--overlays))
result))
+(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))
+ `(let ((p (point-marker))
+ (result (progn ,@body)))
+ (add-text-properties p (point) ,properties)
+ result))
+
(defun sx-question-mode--insert-header (&rest args)
"Insert propertized ARGS.
@@ -364,11 +380,11 @@ Use as (fn header value face
(when sx-question-mode-bullet-appearance
(font-lock-add-keywords ;; Bullet items.
nil
- `(((rx line-start (0+ blank) (group-n 1 (any "*+-")) blank)
+ `((,(rx line-start (0+ blank) (group-n 1 (any "*+-")) blank)
1 '(face nil display ,sx-question-mode-bullet-appearance) prepend))))
(font-lock-add-keywords ;; Highlight usernames.
nil
- `(((rx (or blank line-start)
+ `((,(rx (or blank line-start)
(group-n 1 (and "@" (1+ (or (syntax word) (syntax symbol)))))
symbol-end)
1 font-lock-builtin-face)))
@@ -449,7 +465,7 @@ If ID is nil, use FALLBACK-ID instead."
(save-match-data
(goto-char (point-min))
(when (search-forward-regexp
- (format (rx line-start (0+ blank) "[%s]:" (1+ blank)
+ (format (rx line-start (0+ blank) "[%s]:" (0+ blank)
(group-n 1 (1+ (not blank))))
(or id fallback-id))
nil t)
@@ -573,6 +589,7 @@ Letters do not insert themselves; instead, they are commands.
`(("n" sx-question-mode-next-section)
("p" sx-question-mode-previous-section)
("g" sx-question-mode-refresh)
+ ("v" sx-question-mode-visit)
("q" quit-window)
(" " scroll-up-command)
(,(kbd "S-SPC") scroll-down-command)
@@ -583,19 +600,36 @@ Letters do not insert themselves; instead, they are commands.
(,(kbd "<backtab>") backward-button)
([return] push-button)))
+(defun sx-question-mode-visit ()
+ "Visit the currently displayed question."
+ (interactive)
+ (sx-question-mode--ensure-mode)
+ (sx-assoc-let
+ ;; This allows us to visit the thing-at-point. Which could be a
+ ;; question or an answer. We use `append', so that if one
+ ;; doesn't have a `link' item we can fallback to
+ ;; `sx-question-mode--data'.
+ (append (get-text-property (point) 'sx-question-mode--data-here)
+ sx-question-mode--data)
+ (browse-url .link)))
+
(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)
- (unless (derived-mode-p 'sx-question-mode)
- (error "Not in `sx-question-mode'"))
+ (sx-question-mode--ensure-mode)
(sx-assoc-let sx-question-mode--data
(sx-question-mode--display
(sx-question-get-question
sx-question-list--current-site .question_id)
(selected-window))))
+(defun sx-question-mode--ensure-mode ()
+ "Ensures we are in question mode, erroring otherwise."
+ (unless (derived-mode-p 'sx-question-mode)
+ (error "Not in `sx-question-mode'")))
+
(provide 'sx-question-mode)
;;; sx-question-mode.el ends here
diff --git a/sx-question.el b/sx-question.el
index 827b7c3..d576b73 100644
--- a/sx-question.el
+++ b/sx-question.el
@@ -32,11 +32,13 @@
question.answers
question.last_editor
question.accepted_answer_id
+ question.link
user.display_name
comment.owner
comment.body_markdown
comment.body
answer.last_editor
+ answer.link
answer.owner
answer.body_markdown
answer.comments)
@@ -74,6 +76,7 @@ If QUESTION-ID doesn't exist on SITE, raise an error."
;;; Question Properties
+;;;; Read/unread
(defvar sx-question--user-read-list nil
"Alist of questions read by the user.
@@ -91,7 +94,7 @@ where each element in QUESTION-LIST has the form
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 `(list ,site)))))
+ (sx-cache-get 'read-questions `'((,site))))))
(defun sx-question--read-p (question)
"Non-nil if QUESTION has been read since last updated.
@@ -121,10 +124,66 @@ See `sx-question--user-read-list'."
((setq cell (assoc .question_id site-cell))
(setcdr cell .last_activity_date))
;; Question wasn't present.
- (t (setcdr site-cell (cons q-cell (cdr site-cell)))))))
+ (t
+ (sx-sorted-insert-skip-first
+ q-cell site-cell (lambda (x y) (> (car x) (car y))))))))
;; 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).")
+
+(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
+ (sx-cache-get 'hidden-questions `'((,site))))))
+
+(defun sx-question--hidden-p (question)
+ "Non-nil if QUESTION has been hidden."
+ (sx-assoc-let question
+ (sx-question--ensure-hidden-list .site)
+ (let ((ql (cdr (assoc .site sx-question--user-hidden-list))))
+ (and ql (memq .question_id ql)))))
+
+(defun sx-question--mark-hidden (question)
+ "Mark QUESTION as being hidden."
+ (sx-assoc-let question
+ (sx-question--ensure-hidden-list .site)
+ (let ((site-cell (assoc .site sx-question--user-hidden-list))
+ cell)
+ ;; If question already hidden, do nothing.
+ (unless (memq .question_id site-cell)
+ ;; First question from this site.
+ (if (null site-cell)
+ (push (list .site .question_id) sx-question--user-hidden-list)
+ ;; Question wasn't present.
+ ;; Add it in, but make sure it's sorted (just in case we need
+ ;; it later).
+ (sx-sorted-insert-skip-first .question_id site-cell >))
+ ;; This causes a small lag on `j' and `k' as the list gets large.
+ ;; Should we do this on a timer?
+ ;; Save the results.
+ (sx-cache-set 'hidden-questions sx-question--user-hidden-list)))))
+
+
+;;;; Other data
+
(defun sx-question--accepted-answer-id (question)
"Return accepted answer in QUESTION or nil if none exists."
(sx-assoc-let question
diff --git a/sx-request.el b/sx-request.el
index b3668fe..89c9a59 100644
--- a/sx-request.el
+++ b/sx-request.el
@@ -96,12 +96,15 @@ number of requests left every time it finishes a call.")
;;; Making Requests
(defun sx-request-make
- (method &optional args)
+ (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
@@ -111,16 +114,19 @@ 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)
- (call (sx-request-build
- method
- (cons (cons 'key sx-request-api-key)
- args))))
- (sx-message "Request: %S" call)
- (let ((response-buffer (url-retrieve-synchronously call)))
+ (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)))
+ (sx-message "Request: %S" request-url)
+ (let ((response-buffer (sx-request--request request-url
+ request-args
+ request-method)))
(if (not response-buffer)
(error "Something went wrong in `url-retrieve-synchronously'")
(with-current-buffer response-buffer
@@ -158,37 +164,58 @@ the main content of the response is returned."
;;; Support Functions
-(defun sx-request-build (method keyword-arguments &optional kv-value-sep root)
- "Construct METHOD to use KEYWORD-ARGUMENTS.
+(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))
+ (url-retrieve-synchronously url)))
-The KEYWORD-ARGUMENTS are joined with KV-VALUE-SEP when it
-contains a 'vector'. See `sx-request--build-keyword-arguments'."
- (let ((base (concat (or root sx-request-api-root) method))
- (args (sx-request--build-keyword-arguments
- keyword-arguments kv-value-sep)))
- (if (string-equal "" args)
- base
- (concat base "?" args))))
-(defun sx-request--build-keyword-arguments (alist &optional kv-value-sep)
- "Format ALIST as a key-value joined with KV-VALUE-SEP.
+(defun sx-request--build-keyword-arguments (alist &optional
+ 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
`sx--thing-as-string'."
- (mapconcat
- (lambda (pair)
- (concat
- (sx--thing-as-string (car pair))
- "="
- (sx--thing-as-string (cdr pair) kv-value-sep)))
- (delq nil (mapcar
- (lambda (pair)
- (when (cdr pair) pair))
- alist))
- "&"))
+ ;; Add API key to list of arguments, this allows for increased quota
+ ;; automatically.
+ (let* ((warn (equal need-auth 'warn))
+ (api-key (cons "key" sx-request-api-key))
+ (auth
+ (let ((auth (car (sx-cache-get 'auth))))
+ (cond
+ (auth)
+ ;; Pass user error when asking to warn
+ (warn
+ (user-error
+ "This query requires authentication; run `M-x sx-auth-authenticate' and try again"))
+ ((not auth)
+ (lwarn "stack-mode" :debug
+ "This query requires authentication")
+ nil)))))
+ (push api-key alist)
+ (if (and need-auth auth)
+ (push auth alist))
+ (mapconcat
+ (lambda (pair)
+ (concat
+ (sx--thing-as-string (car pair))
+ "="
+ (sx--thing-as-string (cdr pair) kv-sep)))
+ (delq nil (mapcar
+ (lambda (pair)
+ (when (cdr pair) pair))
+ alist))
+ "&")))
+
(provide 'sx-request)
;;; sx-request.el ends here
diff --git a/sx.el b/sx.el
index eea0749..8b2456d 100644
--- a/sx.el
+++ b/sx.el
@@ -1,8 +1,12 @@
-;;; sx.el --- core functions -*- lexical-binding: t; -*-
+;;; sx.el --- Core functions of the sx package. -*- lexical-binding: t; -*-
;; Copyright (C) 2014 Sean Allred
;; Author: Sean Allred <code@seanallred.com>
+;; URL: https://github.com/vermiculus/stack-mode/
+;; Version: 0.1
+;; Keywords: help, hypermedia, tools
+;; Package-Requires: ((emacs "24.1") (cl-lib "0.5") (json "1.3") (markdown-mode "2.0"))
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
@@ -24,9 +28,38 @@
;;; Code:
+(defconst sx-version "0.1" "Version of the `sx' package.")
+
+
+;;; User commands
+(defun sx-version ()
+ "Print and return the version of the `sx' package."
+ (interactive)
+ (message "%s: %s" 'sx-version sx-version)
+ sx-version)
+
+;;;###autoload
+(defun sx-bug-report ()
+ "File a bug report about the `sx' package."
+ (interactive)
+ (browse-url "https://github.com/vermiculus/stack-mode/issues/new"))
+
;;; Utility Functions
+(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
+is intentionally skipped."
+ `(let ((tail ,list)
+ (x ,newelt))
+ (while (and ;; We're not at the end.
+ (cdr-safe tail)
+ ;; We're not at the right place.
+ (,(or predicate #'<) x (cadr tail)))
+ (setq tail (cdr tail)))
+ (setcdr tail (cons x (cdr tail)))))
+
(defun sx-message (format-string &rest args)
"Display a message."
(message "[stack] %s" (apply #'format format-string args)))
@@ -39,7 +72,10 @@
(defun sx--thing-as-string (thing &optional sequence-sep)
"Return a string representation of THING.
-If THING is already a string, just return it."
+If THING is already a string, just return it.
+
+Optional argument SEQUENCE-SEP is the separator applied between
+elements of a sequence."
(cond
((stringp thing) thing)
((symbolp thing) (symbol-name thing))
@@ -49,7 +85,7 @@ If THING is already a string, just return it."
thing (if sequence-sep sequence-sep ";")))))
(defun sx--filter-data (data desired-tree)
- "Filters DATA and returns the DESIRED-TREE.
+ "Filter DATA and return the DESIRED-TREE.
For example:
@@ -146,7 +182,7 @@ This is used internally to set initial values for variables such
as filters.")
(defun sx--< (property x y &optional predicate)
- "Non-nil if PROPERTY attribute of question X is less than that of Y.
+ "Non-nil if PROPERTY attribute of alist X is less than that of Y.
With optional argument PREDICATE, use it instead of `<'."
(funcall (or predicate #'<)
@@ -165,13 +201,21 @@ SETTER should be a function of two arguments. If SETTER is nil,
(,(or setter #'setq) ,variable ,value))))
nil)
-(defun stack-initialize ()
- "Initialize SX.
+(defvar sx-initialized nil
+ "Nil if sx hasn't been initialized yet.
+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'.
-Runs `sx-init--internal-hook' and `sx-init-hook', in that order."
- (run-hooks
- 'sx-init--internal-hook
- 'sx-init-hook))
+If FORCE is non-nil, run them even if they've already been run."
+ (when (or force (not sx-initialized))
+ (prog1
+ (run-hooks 'sx-init--internal-hook
+ 'sx-init-hook)
+ (setq sx-initialized (current-time)))))
(provide 'sx)
;;; sx.el ends here