diff options
author | Sean Allred <code@seanallred.com> | 2014-11-18 23:49:07 -0500 |
---|---|---|
committer | Sean Allred <code@seanallred.com> | 2014-11-19 00:00:42 -0500 |
commit | 0dd95e3a3d4ee52f52a585388c3ba65e045c305b (patch) | |
tree | f8c4497519cf6f741ea7ec379c537f4b71a4de88 | |
parent | 20dd7254da8e95bd01ce57f806733dee20005039 (diff) | |
parent | 681319aeb250a83d982d1e3e02264a7af0ae4120 (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.el | 22 | ||||
-rw-r--r-- | sx-cache.el | 25 | ||||
-rw-r--r-- | sx-favorites.el | 86 | ||||
-rw-r--r-- | sx-method.el | 12 | ||||
-rw-r--r-- | sx-networks.el | 112 | ||||
-rw-r--r-- | sx-question-list.el | 97 | ||||
-rw-r--r-- | sx-question-mode.el | 164 | ||||
-rw-r--r-- | sx-question.el | 63 | ||||
-rw-r--r-- | sx-request.el | 87 | ||||
-rw-r--r-- | sx.el | 64 |
10 files changed, 584 insertions, 148 deletions
@@ -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 @@ -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 |