diff options
author | Sean Allred <code@seanallred.com> | 2015-01-04 16:23:34 -0500 |
---|---|---|
committer | Sean Allred <code@seanallred.com> | 2015-01-04 16:23:34 -0500 |
commit | 671053bf0824197fefe742ed9dd98c1b9a06565a (patch) | |
tree | 3f457adffd80d7c180b52e936cef3698b067f28b | |
parent | 3b275a93789a568d23ece65086ffebb8de430f3a (diff) | |
parent | 3c05aae9915976e749591600f6e8f59cbccef1a4 (diff) |
Merge branch 'master' into more-tests
Conflicts:
Makefile
test/tests.el
-rw-r--r-- | .agignore | 5 | ||||
-rw-r--r-- | .travis.yml | 4 | ||||
-rw-r--r-- | Makefile | 2 | ||||
-rw-r--r-- | bot/sx-bot.el | 82 | ||||
-rwxr-xr-x | bot/sx-bot.sh | 36 | ||||
-rw-r--r-- | sx-cache.el | 2 | ||||
-rw-r--r-- | sx-compose.el | 33 | ||||
-rw-r--r-- | sx-encoding.el | 2 | ||||
-rw-r--r-- | sx-favorites.el | 10 | ||||
-rw-r--r-- | sx-filter.el | 66 | ||||
-rw-r--r-- | sx-inbox.el | 216 | ||||
-rw-r--r-- | sx-interaction.el | 127 | ||||
-rw-r--r-- | sx-load.el | 7 | ||||
-rw-r--r-- | sx-method.el | 52 | ||||
-rw-r--r-- | sx-networks.el | 38 | ||||
-rw-r--r-- | sx-notify.el | 86 | ||||
-rw-r--r-- | sx-question-list.el | 54 | ||||
-rw-r--r-- | sx-question-mode.el | 9 | ||||
-rw-r--r-- | sx-question.el | 24 | ||||
-rw-r--r-- | sx-request.el | 60 | ||||
-rw-r--r-- | sx-search.el | 112 | ||||
-rw-r--r-- | sx-site.el | 27 | ||||
-rw-r--r-- | sx-switchto.el | 77 | ||||
-rw-r--r-- | sx-tab.el | 83 | ||||
-rw-r--r-- | sx-tag.el | 86 | ||||
-rw-r--r-- | sx-time.el | 2 | ||||
-rw-r--r-- | sx.el | 101 | ||||
-rw-r--r-- | test/data-samples/inbox-item.el | 13 | ||||
-rw-r--r-- | test/test-api.el | 3 | ||||
-rw-r--r-- | test/test-macros.el | 26 | ||||
-rw-r--r-- | test/test-printing.el | 2 | ||||
-rw-r--r-- | test/test-search.el | 53 | ||||
-rw-r--r-- | test/test-state.el | 22 | ||||
-rw-r--r-- | test/test-util.el | 14 | ||||
-rw-r--r-- | test/tests.el | 5 |
35 files changed, 1351 insertions, 190 deletions
@@ -1,3 +1,5 @@ +# -*- gitignore -*- + # Backup files *~ \#*\# @@ -18,3 +20,6 @@ test/data-samples # Info files *.info + +# Data directory +data/ diff --git a/.travis.yml b/.travis.yml index ae882b2..d00ab46 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,10 @@ # Stolen from capitaomorte/yasnippet language: emacs-lisp +branches: + except: + - data + env: - EVM_EMACS=emacs-24.1-bin - EVM_EMACS=emacs-24.2-bin @@ -25,7 +25,7 @@ $(VERSIONS) :: evm use emacs-24.$@-bin emacs --version cask install - cask clean-elc + rm -rf .sx/ emacs --batch -L . -l ert -l test/tests.el -f ert-run-tests-batch-and-exit install_cask: diff --git a/bot/sx-bot.el b/bot/sx-bot.el new file mode 100644 index 0000000..b32a69c --- /dev/null +++ b/bot/sx-bot.el @@ -0,0 +1,82 @@ +;;; sx-bot.el --- Functions for automated maintanence -*- lexical-binding: t; -*- + +;; Copyright (C) 2014 Artur Malabarba + +;; Author: Artur Malabarba <bruce.connor.am@gmail.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: + +;; This file defines the behavior of a bot. To allow completion for +;; tags, this bot runs through all sites in the network and retrieves +;; all of their tags. This data is then written to a directory which +;; is tracked by the git repository. + + +;;; Code: + +(require 'package) +(package-initialize) + +(require 'sx-load) + +(setq sx-request-remaining-api-requests-message-threshold 50000) + +(defcustom sx-bot-out-dir "./data/tags/" + "Directory where output tag files are saved." + :type 'directory + :group 'sx) + + +;;; Printing +(defun sx-bot-write-to-file (data) + "Write (cdr DATA) to file named (car DATA). +File is savedd in `sx-bot-out-dir'." + (let ((file-name (expand-file-name (car data) sx-bot-out-dir))) + (with-temp-file file-name + (let* (print-length + (repr (prin1-to-string + (sort (cdr data) + #'string-lessp)))) + (insert repr "\n") + (goto-char (point-min)) + (while (search-forward "\" \"" nil t) + (replace-match "\"\n \"" nil t)))) + (message "Wrote %S" file-name) + file-name)) + +(defun sx-bot-fetch-and-write-tags () + "Get a list of all tags of all sites and save to disk." + (make-directory sx-bot-out-dir t) + (let* ((url-show-status nil) + (site-tokens (sx-site-get-api-tokens)) + (number-of-sites (length site-tokens)) + (current-site-number 0) + (sx-request-all-items-delay 0.25)) + (mapcar + (lambda (site) + (message "[%d/%d] Working on %S" + (cl-incf current-site-number) + number-of-sites + site) + (sx-bot-write-to-file + (cons (concat site ".el") + (sx-tag--get-all site)))) + site-tokens))) + + +;;; Newest +(provide 'sx-bot) +;;; sx-bot.el ends here diff --git a/bot/sx-bot.sh b/bot/sx-bot.sh new file mode 100755 index 0000000..6a5df17 --- /dev/null +++ b/bot/sx-bot.sh @@ -0,0 +1,36 @@ +#!/usr/bin/bash + +DESTINATION_BRANCH=gh-pages + +function notify-done { + local title + local message + title="SX Tag Bot" + message="Finished retrieving tag lists" + case $(uname | tr '[[:upper:]]' '[[:lower:]]') in + darwin) + terminal-notifier \ + -message ${message} \ + -title ${title} \ + -sound default + ;; + *) + echo ${message} + esac +} + +function generate-tags { + emacs -Q --batch \ + -L "./" -L "./bot/" -l sx-bot \ + -f sx-bot-fetch-and-write-tags + ret = $? + notify-done + return ${ret} +} + +git branch ${DESTINATION_BRANCH} && + git pull && + generate-tags && + git stage data/ && + git commit -m "Update tag data" && + echo 'Ready for "git push"' diff --git a/sx-cache.el b/sx-cache.el index 51c2267..e68397d 100644 --- a/sx-cache.el +++ b/sx-cache.el @@ -1,4 +1,4 @@ -;;; sx-cache.el --- caching +;;; sx-cache.el --- caching -*- lexical-binding: t; -*- ;; Copyright (C) 2014 Sean Allred diff --git a/sx-compose.el b/sx-compose.el index ab4a58d..8a8637b 100644 --- a/sx-compose.el +++ b/sx-compose.el @@ -82,6 +82,10 @@ Is invoked between `sx-compose-before-send-hook' and "Headers inserted when composing a new question. Used by `sx-compose-create'.") +(defvar sx-compose--site nil + "Site which the curent compose buffer belongs to.") +(make-variable-buffer-local 'sx-compose--site) + ;;; Major-mode (define-derived-mode sx-compose-mode markdown-mode "Compose" @@ -116,6 +120,8 @@ contents to the API, then calls `sx-compose-after-send-functions'." (run-hook-with-args 'sx-compose-after-send-functions (current-buffer) result))))) + +;;; Functions for use in hooks (defun sx-compose-quit (buffer _) "Close BUFFER's window and kill it." (interactive (list (current-buffer) nil)) @@ -131,6 +137,26 @@ contents to the API, then calls `sx-compose-after-send-functions'." (with-current-buffer buffer (kill-new (buffer-string))))) +(defun sx-compose--check-tags () + "Check if tags in current compose buffer are valid." + (save-excursion + (goto-char (point-min)) + (unless (search-forward-regexp + "^Tags : *\\([^[:space:]].*\\) *$" + (next-single-property-change (point-min) 'sx-compose-separator) + 'noerror) + (error "No Tags header found")) + (let ((invalid-tags + (sx-tag--invalid-name-p + (split-string (match-string 1) "[[:space:],;]" + 'omit-nulls "[[:space:]]") + sx-compose--site))) + (if invalid-tags + ;; If the user doesn't want to create the tags, we return + ;; nil and sending is aborted. + (y-or-n-p "Following tags don't exist. Create them? %s " invalid-tags) + t)))) + ;;; Functions to help preparing buffers (defun sx-compose-create (site parent &optional before-functions after-functions) @@ -153,6 +179,7 @@ respectively added locally to `sx-compose-before-send-hook' and (cdr (assoc 'title parent)))))) (with-current-buffer (sx-compose--get-buffer-create site parent) (sx-compose-mode) + (setq sx-compose--site site) (setq sx-compose--send-function (if (consp parent) (sx-assoc-let parent @@ -161,7 +188,7 @@ respectively added locally to `sx-compose-before-send-hook' and (.comment_id 'comments) (t 'answers)) :auth 'warn - :url-method "POST" + :url-method 'post :filter sx-browse-filter :site site :keywords (sx-compose--generate-keywords is-question) @@ -169,7 +196,7 @@ respectively added locally to `sx-compose-before-send-hook' and :submethod 'edit))) (lambda () (sx-method-call 'questions :auth 'warn - :url-method "POST" + :url-method 'post :filter sx-browse-filter :site site :keywords (sx-compose--generate-keywords is-question) @@ -180,6 +207,8 @@ respectively added locally to `sx-compose-before-send-hook' and (add-hook 'sx-compose-before-send-hook it nil t)) (dolist (it (reverse after-functions)) (add-hook 'sx-compose-after-send-functions it nil t)) + (when is-question + (add-hook 'sx-compose-before-send-hook #'sx-compose--check-tags nil t)) ;; If the buffer is empty, the draft didn't exist. So prepare the ;; question. (when (or (string= (buffer-string) "") diff --git a/sx-encoding.el b/sx-encoding.el index 0e66677..795f175 100644 --- a/sx-encoding.el +++ b/sx-encoding.el @@ -1,4 +1,4 @@ -;;; sx-encoding.el --- encoding +;;; sx-encoding.el --- encoding -*- lexical-binding: t; -*- ;; Copyright (C) 2014 Sean Allred diff --git a/sx-favorites.el b/sx-favorites.el index d957167..e86e521 100644 --- a/sx-favorites.el +++ b/sx-favorites.el @@ -25,15 +25,11 @@ (require 'sx-cache) (require 'sx-site) (require 'sx-networks) +(require 'sx-filter) (defvar sx-favorite-list-filter - '((.backoff - .items - .quota_max - .quota_remaining - question.question_id) - nil - none)) + (sx-filter-from-nil + (question.question_id))) (defvar sx-favorites--user-favorite-list nil "Alist of questions favorited by the user. diff --git a/sx-filter.el b/sx-filter.el index 8c00c12..15bd8a1 100644 --- a/sx-filter.el +++ b/sx-filter.el @@ -41,7 +41,27 @@ Structure: ...)") -;;; Compilation +;;; Creation +(defmacro sx-filter-from-nil (included) + "Creates a filter data structure with INCLUDED fields. +All wrapper fields are included by default." + `(quote + ((,@(sx--tree-expand + (lambda (path) + (intern (mapconcat #'symbol-name path "."))) + included) + .backoff + .error_id + .error_message + .error_name + .has_more + .items + .page + .page_size + .quota_max + .quota_remaining + .total) + nil none))) ;;; @TODO allow BASE to be a precompiled filter name (defun sx-filter-compile (&optional include exclude base) @@ -81,6 +101,50 @@ return the compiled filter." (sx-cache-set 'filter sx--filter-alist) filter)))) + +;;; Browsing filter +(defvar sx-browse-filter + (sx-filter-from-nil + ((question body_markdown + bounty_amount + comments + answers + last_editor + last_activity_date + accepted_answer_id + link + upvoted + downvoted + question_id + share_link) + (user display_name + reputation) + (shallow_user display_name + reputation) + (comment owner + body_markdown + body + link + edited + creation_date + upvoted + score + post_type + post_id + comment_id) + (answer answer_id + last_editor + last_activity_date + link + share_link + owner + body_markdown + upvoted + downvoted + comments))) + "The filter applied when retrieving question data. +See `sx-question-get-questions' and `sx-question-get-question'.") + (provide 'sx-filter) ;;; sx-filter.el ends here diff --git a/sx-inbox.el b/sx-inbox.el new file mode 100644 index 0000000..d0be379 --- /dev/null +++ b/sx-inbox.el @@ -0,0 +1,216 @@ +;;; sx-inbox.el --- Base inbox logic. -*- lexical-binding: t; -*- + +;; Copyright (C) 2014 Artur Malabarba + +;; Author: Artur Malabarba <bruce.connor.am@gmail.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) +(require 'sx-filter) +(require 'sx-method) +(require 'sx-question-list) + + +;;; API +(defvar sx-inbox-filter + '((inbox_item.answer_id + inbox_item.body + inbox_item.comment_id + inbox_item.creation_date + inbox_item.is_unread + inbox_item.item_type + inbox_item.link + inbox_item.question_id + inbox_item.site + inbox_item.title) + (site.logo_url + site.audience + site.icon_url + site.high_resolution_icon_url + site.site_state + site.launch_date + site.markdown_extensions + site.related_sites + site.styling)) + "Filter used when retrieving inbox items.") + +(defcustom sx-inbox-fill-column 40 + "`fill-column' used in `sx-inbox-mode'." + :type 'integer + :group 'sx) + +(defun sx-inbox-get (&optional notifications page keywords) + "Get an array of inbox items for the current user. +If NOTIFICATIONS is non-nil, query from `notifications' method, +otherwise use `inbox' method. + +Return an array of items. Each item is an alist of properties +returned by the API. +See https://api.stackexchange.com/docs/types/inbox-item + +KEYWORDS are added to the method call along with PAGE. + +`sx-method-call' is used with `sx-inbox-filter'." + (sx-method-call (if notifications 'notifications 'inbox) + :keywords keywords + :filter sx-inbox-filter)) + + +;;; Major-mode +(defvar sx-inbox--notification-p nil + "If non-nil, current buffer lists notifications, not inbox.") +(make-variable-buffer-local 'sx-inbox--notification-p) + +(defvar sx-inbox--unread-inbox nil + "List of inbox items still unread.") + +(defvar sx-inbox--unread-notifications nil + "List of notifications items still unread.") + +(defvar sx-inbox--read-inbox nil + "List of inbox items which are read. +These are identified by their links.") + +(defvar sx-inbox--read-notifications nil + "List of notification items which are read. +These are identified by their links.") + +(defvar sx-inbox--header-line + '(" " + (:propertize "n p j k" face mode-line-buffer-id) + ": Navigate" + " " + (:propertize "RET" face mode-line-buffer-id) + ": View" + " " + (:propertize "v" face mode-line-buffer-id) + ": Visit externally" + " " + (:propertize "q" face mode-line-buffer-id) + ": Quit") + "Header-line used on the inbox list.") + +(defvar sx-inbox--mode-line + '(" " + (:propertize + (sx-inbox--notification-p + "Notifications" + "Inbox") + face mode-line-buffer-id)) + "Mode-line used on the inbox list.") + +(define-derived-mode sx-inbox-mode + sx-question-list-mode "Question List" + "Mode used to list inbox and notification items." + (toggle-truncate-lines 1) + (setq fill-column sx-inbox-fill-column) + (setq sx-question-list--print-function #'sx-inbox--print-info) + (setq sx-question-list--next-page-function + (lambda (page) (sx-inbox-get sx-inbox--notification-p page))) + (setq tabulated-list-format + [("Type" 30 t nil t) ("Date" 10 t :right-align t) ("Title" 0)]) + (setq mode-line-format sx-inbox--mode-line) + (setq header-line-format sx-inbox--header-line) + ;; @TODO: This will no longer be necessary once we properly + ;; refactor sx-question-list-mode. + (remove-hook 'tabulated-list-revert-hook + #'sx-question-list--update-mode-line t)) + + +;;; Keybinds +(mapc (lambda (x) (define-key sx-inbox-mode-map (car x) (cadr x))) + '( + ("t" nil) + ("a" nil) + ("h" nil) + ("m" sx-inbox-mark-read) + ([?\r] sx-display) + )) + + +;;; print-info +(defun sx-inbox--print-info (data) + "Convert `json-read' DATA into tabulated-list format. + +This is the default printer used by `sx-inbox'. It assumes DATA +is an alist containing the elements: + `answer_id', `body', `comment_id', `creation_date', `is_unread', + `item_type', `link', `question_id', `site', `title'." + (list + data + (sx-assoc-let data + (vector + (list + (concat (capitalize + (replace-regexp-in-string + "_" " " (or .item_type .notification_type))) + (cond (.answer_id " on Answer at:") + (.question_id " on:"))) + 'face 'font-lock-keyword-face) + (list + (concat (sx-time-since .creation_date) + sx-question-list-ago-string) + 'face 'sx-question-list-date) + (list + (propertize + " " 'display + (concat "\n " (propertize .title 'face 'sx-question-list-date) "\n" + (let ((col fill-column)) + (with-temp-buffer + (setq fill-column col) + (insert " " .body) + (fill-region (point-min) (point-max)) + (buffer-string)))) + 'face 'default)))))) + + +;;; Entry commands +(defvar sx-inbox--buffer nil + "Buffer being used to display inbox.") + +(defun sx-inbox (&optional notifications) + "Display a buffer listing inbox items. +With prefix NOTIFICATIONS, list notifications instead of inbox." + (interactive "P") + (setq sx-inbox--buffer (get-buffer-create "*sx-inbox*")) + (let ((inhibit-read-only t)) + (with-current-buffer sx-inbox--buffer + (erase-buffer) + (sx-inbox-mode) + (setq sx-inbox--notification-p notifications) + (tabulated-list-revert))) + (let ((w (get-buffer-window sx-inbox--buffer))) + (if (window-live-p w) + (select-window w) + (pop-to-buffer sx-inbox--buffer) + (enlarge-window + (- (+ fill-column 4) (window-width)) + 'horizontal)))) + +(defun sx-inbox-notifications () + "Display a buffer listing notification items." + (interactive) + (sx-inbox t)) + +(provide 'sx-inbox) +;;; sx-inbox.el ends here + +;; Local Variables: +;; indent-tabs-mode: nil +;; End: diff --git a/sx-interaction.el b/sx-interaction.el index 372a5b1..dc4398e 100644 --- a/sx-interaction.el +++ b/sx-interaction.el @@ -119,15 +119,14 @@ Interactively, this is specified with a prefix argument. If DATA is a question, also mark it as read." (interactive (list (sx--data-here) current-prefix-arg)) (sx-assoc-let data - (let ((link - (when (stringp .link) - (funcall (if copy-as-kill #'kill-new #'browse-url) - .link)))) + (if (not (stringp .link)) + (sx-message "Nothing to visit here.") + (funcall (if copy-as-kill #'kill-new #'browse-url) .link) (when (and (called-interactively-p 'any) copy-as-kill) - (message "Copied: %S" link))) - (when (and .title (not copy-as-kill)) - (sx-question--mark-read data) - (sx--maybe-update-display)))) + (message "Copied: %S" .link)) + (when (and .title (not copy-as-kill)) + (sx-question--mark-read data) + (sx--maybe-update-display))))) (defun sx-open-link (link) "Visit element given by LINK inside Emacs. @@ -142,22 +141,42 @@ Element can be a question, answer, or comment." (cl-case .type (answer (sx-display-question - (sx-question-get-from-answer .site .id) 'focus)) + (sx-question-get-from-answer .site_par .id) 'focus)) (question (sx-display-question - (sx-question-get-question .site .id) 'focus)))))) + (sx-question-get-question .site_par .id) 'focus)))))) ;;; Displaying +(defun sx-display (&optional data) + "Display object given by DATA. +Interactively, display object under point. Object can be a +question, an answer, or an inbox_item. + +This is meant for interactive use. In lisp code, use +object-specific functions such as `sx-display-question' and the +likes." + (interactive (list (sx--data-here))) + (sx-assoc-let data + (cond + (.notification_type + (sx-message "Viewing notifications is not yet implemented")) + (.item_type (sx-open-link .link)) + (.answer_id + (sx-display-question + (sx-question-get-from-answer .site_par .id) 'focus)) + (.title + (sx-display-question data 'focus))))) + (defun sx-display-question (&optional data focus window) "Display question given by DATA, on WINDOW. -When DATA is nil, display question under point. When FOCUS is +Interactively, display question under point. When FOCUS is non-nil (the default when called interactively), also focus the relevant window. If WINDOW nil, the window is decided by `sx-question-mode-display-buffer-function'." - (interactive (list (sx--data-here) t)) + (interactive (list (sx--data-here 'question) t)) (when (sx-question--mark-read data) (sx--maybe-update-display)) ;; Display the question. @@ -170,22 +189,42 @@ If WINDOW nil, the window is decided by (switch-to-buffer sx-question-mode--buffer)))) -;;; Voting -(defun sx-toggle-upvote (data) - "Apply or remove upvote from DATA. -DATA can be a question, answer, or comment. Interactively, it is -guessed from context at point." - (interactive (list (sx--error-if-unread (sx--data-here)))) +;;; Favoriting +(defun sx-favorite (data &optional undo) + "Favorite question given by DATA. +Interactively, it is guessed from context at point. +With the UNDO prefix argument, unfavorite the question instead." + (interactive (list (sx--error-if-unread (sx--data-here 'question)) + current-prefix-arg)) (sx-assoc-let data - (sx-set-vote data "upvote" (null (eq .upvoted t))))) + (sx-method-call 'questions + :id .question_id + :submethod (if undo 'favorite/undo 'favorite) + :auth 'warn + :site .site_par + :url-method 'post + :filter sx-browse-filter))) +(defalias 'sx-star #'sx-favorite) -(defun sx-toggle-downvote (data) - "Apply or remove downvote from DATA. + +;;; Voting +(defun sx-upvote (data &optional undo) + "Upvote an object given by DATA. +DATA can be a question, answer, or comment. Interactively, it is +guessed from context at point. +With UNDO prefix argument, remove upvote instead of applying it." + (interactive (list (sx--error-if-unread (sx--data-here)) + current-prefix-arg)) + (sx-set-vote data "upvote" (not undo))) + +(defun sx-downvote (data &optional undo) + "Downvote an object given by DATA. DATA can be a question or an answer. Interactively, it is guessed -from context at point." - (interactive (list (sx--error-if-unread (sx--data-here)))) - (sx-assoc-let data - (sx-set-vote data "downvote" (null (eq .downvoted t))))) +from context at point. +With UNDO prefix argument, remove downvote instead of applying it." + (interactive (list (sx--error-if-unread (sx--data-here)) + current-prefix-arg)) + (sx-set-vote data "downvote" (not undo))) (defun sx-set-vote (data type status) "Set the DATA's vote TYPE to STATUS. @@ -204,9 +243,9 @@ changes." :id (or .comment_id .answer_id .question_id) :submethod (concat type (unless status "/undo")) :auth 'warn - :url-method "POST" + :url-method 'post :filter sx-browse-filter - :site .site)))) + :site .site_par)))) ;; The api returns the new DATA. (when (> (length result) 0) (sx--copy-data (elt result 0) data) @@ -245,16 +284,16 @@ TEXT is a string. Interactively, it is read from the minibufer." :id (or .post_id .answer_id .question_id) :submethod "comments/add" :auth 'warn - :url-method "POST" + :url-method 'post :filter sx-browse-filter - :site .site + :site .site_par :keywords `((body . ,text))))) ;; The api returns the new DATA. (when (> (length result) 0) (sx--add-comment-to-object (elt result 0) (if .post_id - (sx--get-post .post_type .site .post_id) + (sx--get-post .post_type .site_par .post_id) data)) ;; Display the changes in `data'. (sx--maybe-update-display))))) @@ -287,7 +326,7 @@ ID is an integer." (car (cl-member-if (lambda (x) (sx-assoc-let x (and (equal (or .answer_id .question_id) id) - (equal .site site)))) + (equal .site_par site)))) db)))) (defun sx--add-comment-to-object (comment object) @@ -320,7 +359,7 @@ from context at point." (let ((buffer (current-buffer))) (pop-to-buffer (sx-compose-create - .site data + .site_par data ;; Before send hook (when .comment_id (list #'sx--comment-valid-p)) ;; After send functions @@ -338,12 +377,24 @@ from context at point." (defun sx--interactive-site-prompt () "Query the user for a site." (let ((default (or sx-question-list--site - (sx-assoc-let sx-question-mode--data .site) + (sx-assoc-let sx-question-mode--data .site_par) sx-default-site))) - (funcall (if ido-mode #'ido-completing-read #'completing-read) - (format "Site (%s): " default) - (sx-site-get-api-tokens) nil t nil nil - default))) + (sx-completing-read + (format "Site (%s): " default) + (sx-site-get-api-tokens) nil t nil nil + default))) + +(defun sx--maybe-site-prompt (arg) + "Get a site token conditionally in an interactive context. +If ARG is non-nil, use `sx--interactive-site-prompt'. +Otherwise, use `sx-question-list--site' if non-nil. +If nil, use `sx--interactive-site-prompt' anyway." + ;; This could eventually be generalized into (sx--maybe-prompt + ;; prefix-arg value-if-non-nil #'prompt-function). + (if arg + (sx--interactive-site-prompt) + (or sx-question-list--site + (sx--interactive-site-prompt)))) ;;;###autoload (defun sx-ask (site) @@ -373,7 +424,7 @@ context at point. " (sx-assoc-let data (pop-to-buffer (sx-compose-create - .site .question_id nil + .site_par .question_id nil ;; After send functions (list (lambda (_ res) (sx--add-answer-to-question-object @@ -1,4 +1,4 @@ -;;; sx-load.el --- Load all files of the sx package. +;;; sx-load.el --- Load all files of the sx package. -*- lexical-binding: t; -*- ;; Copyright (C) 2014 Artur Malabarba @@ -31,16 +31,21 @@ sx-encoding sx-favorites sx-filter + sx-inbox sx-interaction sx-method sx-networks + sx-notify sx-question sx-question-list sx-question-mode sx-question-print sx-request + sx-search sx-site + sx-switchto sx-tab + sx-tag )) (provide 'sx-load) diff --git a/sx-method.el b/sx-method.el index 1078014..bff6d30 100644 --- a/sx-method.el +++ b/sx-method.el @@ -35,9 +35,14 @@ (cl-defun sx-method-call (method &key id submethod keywords + page + (pagesize 100) (filter '(())) auth - (url-method "GET") + (url-method 'get) + get-all + (process-function + #'sx-request-response-get-items) site) "Call METHOD with additional keys. @@ -48,8 +53,15 @@ user. :FILTER is the set of filters to control the returned information :AUTH defines how to act if the method or filters require authentication. -:URL-METHOD is either \"POST\" or \"GET\" +:URL-METHOD is either `post' or `get' :SITE is the api parameter specifying the site. +:GET-ALL is nil or non-nil +:PROCESS-FUNCTION is a response-processing function +:PAGE is the page number which will be requested +:PAGESIZE is the number of items to retrieve per request, default 100 + +Any conflicting information in :KEYWORDS overrides the :PAGE +and :PAGESIZE settings. When AUTH is nil, it is assumed that no auth-requiring filters or methods will be used. If they are an error will be signaled. This is @@ -66,6 +78,18 @@ for interactive commands that absolutely require authentication \(submitting questions/answers, reading inbox, etc). Filters will treat 'warn as equivalent to t. +If GET-ALL is nil, this method will only return the first (or +specified) page available from this method call. If t, all pages +will be retrieved (`sx-request-all-stop-when-no-more') . +Otherwise, it is a function STOP-WHEN for `sx-request-all-items'. + +If PROCESS-FUNCTION is nil, only the items of the response will +be returned (`sx-request-response-get-items'). Otherwise, it is +a function that processes the entire response (as returned by +`json-read'). + +See `sx-request-make' and `sx-request-all-items'. + Return the entire response as a complex alist." (declare (indent 1)) (let ((access-token (sx-cache-get 'auth)) @@ -78,12 +102,15 @@ Return the entire response as a complex alist." (format "/%s" submethod)) ;; On GET methods site is buggy, so we ;; need to provide it as a url argument. - (when (and site (string= url-method "GET")) + (when (and site (eq url-method 'get)) (prog1 (format "?site=%s" site) (setq site nil))))) - (call #'sx-request-make) - parameters) + (call (if get-all #'sx-request-all-items #'sx-request-make)) + (get-all + (cond + ((eq get-all t) #'sx-request-all-stop-when-no-more) + (t get-all)))) (lwarn "sx-call-method" :debug "A: %S T: %S. M: %S,%s. F: %S" (equal 'warn auth) access-token method-auth full-method filter-auth) (unless access-token @@ -102,15 +129,18 @@ Return the entire response as a complex alist." ((and (or filter-auth method-auth) (not auth)) (error "This request requires authentication.")))) ;; Concatenate all parameters now that filter is ensured. - (setq parameters - (cons (cons 'filter (sx-filter-get-var filter)) - keywords)) + (push `(filter . ,(sx-filter-get-var filter)) keywords) + (unless (assq 'page keywords) + (push `(page . ,page) keywords)) + (unless (assq 'pagesize keywords) + (push `(pagesize . ,pagesize) keywords)) (when site - (setq parameters (cons (cons 'site site) parameters))) + (push `(site . ,site) keywords)) (funcall call full-method - parameters - url-method))) + keywords + url-method + (or get-all process-function)))) (provide 'sx-method) ;;; sx-method.el ends here diff --git a/sx-networks.el b/sx-networks.el index e4660af..58ebff5 100644 --- a/sx-networks.el +++ b/sx-networks.el @@ -24,31 +24,23 @@ (require 'sx-method) (require 'sx-cache) (require 'sx-site) +(require 'sx-filter) (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)) + (sx-filter-from-nil + ((badge_count bronze + silver + gold) + (network_user account_id + answer_count + badge_counts + creation_date + last_access_date + reputation + site_name + site_url + user_id + user_type)))) (defun sx-network--get-associated () "Retrieve cached information for network user. diff --git a/sx-notify.el b/sx-notify.el new file mode 100644 index 0000000..c335427 --- /dev/null +++ b/sx-notify.el @@ -0,0 +1,86 @@ +;;; sx-notify.el --- Mode-line notifications. -*- lexical-binding: t; -*- + +;; Copyright (C) 2014 Artur Malabarba + +;; Author: Artur Malabarba <bruce.connor.am@gmail.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) +(require 'sx-inbox) + + +;;; mode-line notification +(defvar sx-notify--mode-line + '((sx-inbox--unread-inbox (sx-inbox--unread-notifications " [")) + (sx-inbox--unread-inbox + (:propertize + (:eval (format "i:%s" (length sx-inbox--unread-inbox))) + face mode-line-buffer-id + mouse-face mode-line-highlight)) + (sx-inbox--unread-inbox (sx-inbox--unread-notifications " ")) + (sx-inbox--unread-notifications + (:propertize + (:eval (format "n:%s" (length sx-inbox--unread-notifications))) + mouse-face mode-line-highlight)) + (sx-inbox--unread-inbox (sx-notify--unread-notifications "]"))) + "") +(put 'sx-notify--mode-line 'risky-local-variable t) + + +;;; minor-mode definition +(defcustom sx-notify-timer-delay (* 60 5) + "Idle time, in seconds, before querying for inbox items." + :type 'integer + :group 'sx-notify) + +(defvar sx-notify--timer nil + "Timer used for fetching notifications.") + +(define-minor-mode sx-notify-mode nil nil nil nil + :global t + (if sx-notify-mode + (progn + (add-to-list 'global-mode-string '(t sx-notify--mode-line) 'append) + (setq sx-notify--timer + (run-with-idle-timer sx-notify-timer-delay 'repeat + #'sx-notify--update-unread))) + (when (timerp sx-notify--timer) + (cancel-timer sx-notify--timer) + (setq sx-notify--timer nil)) + (setq global-mode-string + (delete '(t sx-notify--mode-line) global-mode-string)))) + +(defun sx-notify--update-unread () + "Update the lists of unread notifications." + (setq sx-inbox--unread-inbox + (cl-remove-if + (lambda (x) (member (cdr (assq 'link x)) sx-inbox--read-inbox)) + (append (sx-inbox-get) nil))) + (setq sx-inbox--unread-notifications + (cl-remove-if + (lambda (x) (member (cdr (assq 'link x)) sx-inbox--read-notifications)) + (append (sx-inbox-get t) nil)))) + +(provide 'sx-notify) +;;; sx-notify.el ends here + +;; Local Variables: +;; indent-tabs-mode: nil +;; End: diff --git a/sx-question-list.el b/sx-question-list.el index 4bd6478..3354052 100644 --- a/sx-question-list.el +++ b/sx-question-list.el @@ -104,6 +104,21 @@ "" :group 'sx-question-list-faces) +(defface sx-question-list-bounty + '((t :inherit font-lock-warning-face)) + "" + :group 'sx-question-list-faces) + +(defface sx-question-list-reputation + '((t :inherit sx-question-list-date)) + "" + :group 'sx-question-list-faces) + +(defface sx-question-list-user + '((t :inherit font-lock-builtin-face)) + "" + :group 'sx-question-list-faces) + ;;; Backend variables (defvar sx-question-list--print-function #'sx-question-list--print-info @@ -127,7 +142,7 @@ elements: Also see `sx-question-list-refresh'." (sx-assoc-let question-data (let ((favorite (if (member .question_id - (assoc .site + (assoc .site_par sx-favorites--user-favorite-list)) (if (char-displayable-p ?\x2b26) "\x2b26" "*") " "))) (list @@ -141,20 +156,35 @@ Also see `sx-question-list-refresh'." 'sx-question-list-answers-accepted 'sx-question-list-answers)) (concat + ;; First line (propertize .title 'face (if (sx-question--read-p question-data) 'sx-question-list-read-question 'sx-question-list-unread-question)) (propertize " " 'display "\n ") + ;; Second line (propertize favorite 'face 'sx-question-list-favorite) - " " - (propertize (concat (sx-time-since .last_activity_date) - sx-question-list-ago-string) + (if (and (numberp .bounty_amount) (> .bounty_amount 0)) + (propertize (format "%4d" .bounty_amount) + 'face 'sx-question-list-bounty) + " ") + " " + (propertize (format "%3s%s" + (sx-time-since .last_activity_date) + sx-question-list-ago-string) 'face 'sx-question-list-date) " " - (propertize (mapconcat #'sx-question--tag-format .tags " ") + ;; @TODO: Make this width customizable. (Or maybe just make + ;; the whole thing customizable) + (propertize (format "%-40s" (mapconcat #'sx-question--tag-format .tags " ")) 'face 'sx-question-list-tags) + " " + (let-alist .owner + (format "%15s %5s" + (propertize .display_name 'face 'sx-question-list-user) + (propertize (number-to-string .reputation) + 'face 'sx-question-list-reputation))) (propertize " " 'display "\n"))))))) (defvar sx-question-list--pages-so-far 0 @@ -314,15 +344,17 @@ into consideration. ("J" sx-question-list-next-far) ("K" sx-question-list-previous-far) ("g" sx-question-list-refresh) - (":" sx-question-list-switch-site) ("t" sx-tab-switch) ("a" sx-ask) + ("S" sx-search) + ("s" sx-switchto-map) ("v" sx-visit-externally) - ("u" sx-toggle-upvote) - ("d" sx-toggle-downvote) + ("u" sx-upvote) + ("d" sx-downvote) ("h" sx-question-list-hide) ("m" sx-question-list-mark-read) - ([?\r] sx-display-question) + ("*" sx-favorite) + ([?\r] sx-display) )) (defun sx-question-list-hide (data) @@ -398,6 +430,7 @@ Non-interactively, DATA is a question alist." (defvar sx-question-list--site nil "Site being displayed in the *question-list* buffer.") +(make-variable-buffer-local 'sx-question-list--site) (defun sx-question-list-refresh (&optional redisplay no-update) "Update the list of questions. @@ -559,12 +592,11 @@ This does not update `sx-question-mode--window'." (defun sx-question-list-switch-site (site) "Switch the current site to SITE and display its questions. -Use `ido-completing-read' if variable `ido-mode' is active. Retrieve completions from `sx-site-get-api-tokens'. Sets `sx-question-list--site' and then call `sx-question-list-refresh' with `redisplay'." (interactive - (list (funcall (if ido-mode #'ido-completing-read #'completing-read) + (list (sx-completing-read "Switch to site: " (sx-site-get-api-tokens) (lambda (site) (not (equal site sx-question-list--site))) t))) diff --git a/sx-question-mode.el b/sx-question-mode.el index a60cf3a..b13caf3 100644 --- a/sx-question-mode.el +++ b/sx-question-mode.el @@ -225,12 +225,15 @@ Letters do not insert themselves; instead, they are commands. ("g" sx-question-mode-refresh) ("c" sx-comment) ("v" sx-visit-externally) - ("u" sx-toggle-upvote) - ("d" sx-toggle-downvote) + ("u" sx-upvote) + ("d" sx-downvote) ("q" quit-window) (" " scroll-up-command) ("a" sx-answer) ("e" sx-edit) + ("S" sx-search) + ("s" sx-switchto-map) + ("*" sx-favorite) (,(kbd "S-SPC") scroll-down-command) ([backspace] scroll-down-command) ([tab] forward-button) @@ -254,7 +257,7 @@ query the api." (if no-update sx-question-mode--data (sx-assoc-let sx-question-mode--data - (sx-question-get-question .site .question_id)))) + (sx-question-get-question .site_par .question_id)))) (goto-char point) (when (equal (selected-window) (get-buffer-window (current-buffer))) diff --git a/sx-question.el b/sx-question.el index 03ebb4b..0e830a6 100644 --- a/sx-question.el +++ b/sx-question.el @@ -26,7 +26,7 @@ (require 'sx-filter) (require 'sx-method) -(defun sx-question-get-questions (site &optional page keywords) +(defun sx-question-get-questions (site &optional page keywords submethod) "Get SITE questions. Return page PAGE (the first if nil). Return a list of question. Each question is an alist of properties returned by the API with an added (site SITE) @@ -39,6 +39,7 @@ KEYWORDS are added to the method call along with PAGE. :keywords `((page . ,page) ,@keywords) :site site :auth t + :submethod submethod :filter sx-browse-filter)) (defun sx-question-get-question (site question-id) @@ -94,8 +95,8 @@ If no cache exists for it, initialize one with SITE." "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)))) + (sx-question--ensure-read-list .site_par) + (let ((ql (cdr (assoc .site_par sx-question--user-read-list)))) (and ql (>= (or (cdr (assoc .question_id ql)) 0) .last_activity_date))))) @@ -107,18 +108,19 @@ read, i.e., if it was `sx-question--read-p'. See `sx-question--user-read-list'." (prog1 (sx-assoc-let question - (sx-question--ensure-read-list .site) - (let ((site-cell (assoc .site sx-question--user-read-list)) + (sx-question--ensure-read-list .site_par) + (let ((site-cell (assoc .site_par sx-question--user-read-list)) (q-cell (cons .question_id .last_activity_date)) cell) (cond ;; First question from this site. ((null site-cell) - (push (list .site q-cell) sx-question--user-read-list)) + (push (list .site_par q-cell) sx-question--user-read-list)) ;; Question already present. ((setq cell (assoc .question_id site-cell)) ;; Current version is newer than cached version. - (when (> .last_activity_date (cdr cell)) + (when (or (not (numberp (cdr cell))) + (> .last_activity_date (cdr cell))) (setcdr cell .last_activity_date))) ;; Question wasn't present. (t @@ -149,19 +151,19 @@ If no cache exists for it, initialize one with 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)))) + (sx-question--ensure-hidden-list .site_par) + (let ((ql (cdr (assoc .site_par 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 - (let ((site-cell (assoc .site sx-question--user-hidden-list))) + (let ((site-cell (assoc .site_par sx-question--user-hidden-list))) ;; If question already hidden, do nothing. (unless (memq .question_id site-cell) (if (null site-cell) ;; First question from this site. - (push (list .site .question_id) sx-question--user-hidden-list) + (push (list .site_par .question_id) sx-question--user-hidden-list) ;; Not first question and question wasn't present. ;; Add it in, but make sure it's sorted (just in case we ;; decide to rely on it later). diff --git a/sx-request.el b/sx-request.el index bc34f9c..bab53ec 100644 --- a/sx-request.el +++ b/sx-request.el @@ -92,16 +92,52 @@ number of requests left every time it finishes a call." :group 'sx :type 'integer) +(defvar sx-request-all-items-delay + 1 + "Delay in seconds with each `sx-request-all-items' iteration. +It is good to use a reasonable delay to avoid rate-limiting.") + ;;; Making Requests +(defun sx-request-all-items (method &optional args request-method + stop-when) + "Call METHOD with ARGS until there are no more items. +STOP-WHEN is a function that takes the entire response and +returns non-nil if the process should stop. + +All other arguments are identical to `sx-request-make', but +PROCESS-FUNCTION is given the default value of `identity' (rather +than `sx-request-response-get-items') to allow STOP-WHEN to +access the response wrapper." + ;; @TODO: Refactor. This is the product of a late-night jam + ;; session... it is not intended to be model code. + (declare (indent 1)) + (let* ((return-value []) + (current-page 1) + (stop-when (or stop-when #'sx-request-all-stop-when-no-more)) + (process-function #'identity) + (response + (sx-request-make method `((page . ,current-page) ,@args) + request-method process-function))) + (while (not (funcall stop-when response)) + (setq current-page (1+ current-page) + return-value + (vconcat return-value + (cdr (assoc 'items response)))) + (sleep-for sx-request-all-items-delay) + (setq response + (sx-request-make method `((page . ,current-page) ,@args) + request-method process-function))) + (vconcat return-value + (cdr (assoc 'items response))))) -(defun sx-request-make (method &optional args request-method) +(defun sx-request-make (method &optional args request-method process-function) "Make a request to the API, executing METHOD with ARGS. You should almost certainly be using `sx-method-call' instead of -this function. REQUEST-METHOD is one of `GET' (default) or `POST'. +this function. REQUEST-METHOD is one of `get' (default) or `post'. -Returns cleaned response content. -See (`sx-encoding-clean-content-deep'). +Returns the entire response as processed by PROCESS-FUNCTION. +This defaults to `sx-request-response-get-items'. The full set of arguments is built with `sx-request--build-keyword-arguments', prepending @@ -117,11 +153,12 @@ then read with `json-read-from-string'. `sx-request-remaining-api-requests' is updated appropriately and the main content of the response is returned." + (declare (indent 1)) (let* ((url-automatic-caching t) (url-inhibit-uncompression t) (url-request-data (sx-request--build-keyword-arguments args nil)) (request-url (concat sx-request-api-root method)) - (url-request-method request-method) + (url-request-method (and request-method (symbol-name request-method))) (url-request-extra-headers '(("Content-Type" . "application/x-www-form-urlencoded"))) (response-buffer (url-retrieve-synchronously request-url))) @@ -164,7 +201,8 @@ the main content of the response is returned." sx-request-remaining-api-requests-message-threshold) (sx-message "%d API requests remaining" sx-request-remaining-api-requests)) - (sx-encoding-clean-content-deep .items))))))) + (funcall (or process-function #'sx-request-response-get-items) + response))))))) (defun sx-request-fallback (_method &optional _args _request-method) "Fallback method when authentication is not available. @@ -205,6 +243,16 @@ false, use the symbol `false'. Each element is processed with alist)) "&"))) + +;;; Response Processors +(defun sx-request-response-get-items (response) + "Returns the items from RESPONSE." + (sx-assoc-let response + (sx-encoding-clean-content-deep .items))) + +(defun sx-request-all-stop-when-no-more (response) + (or (not response) + (equal :json-false (cdr (assoc 'has_more response))))) (provide 'sx-request) ;;; sx-request.el ends here diff --git a/sx-search.el b/sx-search.el new file mode 100644 index 0000000..2633da9 --- /dev/null +++ b/sx-search.el @@ -0,0 +1,112 @@ +;;; sx-search.el --- Searching for questions. -*- lexical-binding: t; -*- + +;; Copyright (C) 2014 Artur Malabarba + +;; Author: Artur Malabarba <bruce.connor.am@gmail.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: + +;; Implements sarch functionality. The basic function is +;; `sx-search-get-questions', which returns an array of questions +;; according to a search term. +;; +;; This also defines a user-level command, `sx-search', which is an +;; interactive wrapper around `sx-search-get-questions' and +;; `sx-question-list-mode'. + + +;;; Code: + +(require 'sx) +(require 'sx-question-list) + +(defvar sx-search--query-history nil + "Query history for interactive prompts.") + +(defvar sx-search--tag-history nil + "Tags history for interactive prompts.") + + +;;; Basic function +(defun sx-search-get-questions (site page query &optional tags excluded-tags keywords) + "Like `sx-question-get-questions', but restrict results by a search. + +Perform search on SITE. PAGE is an integer indicating which page +of results to return. QUERY, TAGS, and EXCLUDED-TAGS restrict the +possible returned questions as per `sx-search'. + +Either QUERY or TAGS must be non-nil, or the search will +fail. EXCLUDED-TAGS is only is used if TAGS is also provided. + +KEYWORDS is passed to `sx-method-call'." + (sx-method-call 'search + :keywords `((page . ,page) + (sort . activity) + (intitle . ,query) + (tagged . ,tags) + (nottagged . ,excluded-tags) + ,@keywords) + :site site + :auth t + :filter sx-browse-filter)) + + +;;; User command +(defun sx-search (site query &optional tags excluded-tags) + "Display search on SITE for question titles containing QUERY. +When TAGS is given, it is a lists of tags, one of which must +match. When EXCLUDED-TAGS is given, it is a list of tags, none +of which is allowed to match. + +Interactively, the user is asked for SITE and QUERY. With a +prefix argument, the user is asked for everything." + (interactive + (let ((site (sx--maybe-site-prompt current-prefix-arg)) + (query (read-string + (format "Query (%s): " + (if current-prefix-arg "optional" "mandatory")) + "" + 'sx-search--query-history)) + tags excluded-tags) + (when (string= query "") + (setq query nil)) + (when current-prefix-arg + (setq tags (sx--multiple-read + (format "Tags (%s)" + (if query "optional" "mandatory")) + 'sx-search--tag-history)) + (when (and (not query) (string= "" tags)) + (sx-user-error "Must supply either QUERY or TAGS")) + (setq excluded-tags + (sx--multiple-read + "Excluded tags (optional)" 'sx-search--tag-history))) + (list site query tags excluded-tags))) + + ;; Here starts the actual function + (sx-initialize) + (with-current-buffer (get-buffer-create "*sx-search-result*") + (sx-question-list-mode) + (setq sx-question-list--next-page-function + (lambda (page) + (sx-search-get-questions + sx-question-list--site page + query tags excluded-tags))) + (setq sx-question-list--site site) + (sx-question-list-refresh 'redisplay) + (switch-to-buffer (current-buffer)))) + +(provide 'sx-search) +;;; sx-search.el ends here @@ -23,25 +23,16 @@ (require 'sx-method) (require 'sx-cache) +(require 'sx-filter) (defvar sx-site-browse-filter - '((.backoff - .error_id - .error_message - .error_name - .has_more - .items - .quota_max - .quota_remaining - site.site_type - site.name - site.site_url - site.api_site_parameter - site.related_sites - related_site.api_site_parameter - related_site.relation) - nil - none) + (sx-filter-from-nil + ((site site_type + name + api_site_parameter + related_sites) + (related_site api_site_parameter + relation))) "Filter for browsing sites.") (defun sx-site--get-site-list () @@ -49,7 +40,7 @@ (sx-cache-get 'site-list '(sx-method-call 'sites - :keywords '((pagesize . 999)) + :pagesize 999 :filter sx-site-browse-filter))) (defcustom sx-site-favorites diff --git a/sx-switchto.el b/sx-switchto.el new file mode 100644 index 0000000..76804e4 --- /dev/null +++ b/sx-switchto.el @@ -0,0 +1,77 @@ +;;; sx-switchto.el --- Keymap for navigating between pages. -*- lexical-binding: t; -*- + +;; Copyright (C) 2014 Artur Malabarba + +;; Author: Artur Malabarba <bruce.connor.am@gmail.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) +(require 'sx-filter) +(require 'sx-method) +(require 'sx-question-list) + + +;;; Keybinds +;;;###autoload +(define-prefix-command 'sx-switchto-map) + +(mapc (lambda (x) (define-key sx-switchto-map (car x) (cadr x))) + '( + ;; These immitate the site's G hotkey. + ("a" sx-ask) + ("h" sx-tab-frontpage) + ("m" sx-tab-meta-or-main) + ;; This is `n' on the site. + ("u" sx-tab-unanswered) + ;; These are extra things we can do, because we're awesome. + ("f" sx-tab-featured) + ("i" sx-inbox) + ("n" sx-tab-newest) + ("t" sx-tab-switch) + ("U" sx-tab-unanswered-my-tags) + ("v" sx-tab-topvoted) + ("w" sx-tab-week) + ("*" sx-tab-starred) + )) + + +;;; These are keys which depend on context. +;;;; For instance, it makes no sense to have `switch-site' bound to a +;;;; key on a buffer with no `sx-question-list--site' variable. +(defmacro sx--define-conditional-key (keymap key def &rest body) + "In KEYMAP, define key sequence KEY as DEF conditionally. +This is like `define-key', except the definition \"disapears\" +whenever BODY evaluates to nil." + (declare (indent 3) + (debug (form form form &rest sexp))) + `(define-key ,keymap ,key + '(menu-item + ,(format "maybe-%s" (or (car (cdr-safe def)) def)) ignore + :filter (lambda (&optional _) + (when (progn ,@body) ,def))))) + +(sx--define-conditional-key sx-switchto-map "s" #'sx-question-list-switch-site + (and (boundp 'sx-question-list--site) sx-question-list--site)) + +(provide 'sx-switchto) +;;; sx-switchto.el ends here + +;; Local Variables: +;; indent-tabs-mode: nil +;; End: @@ -34,10 +34,10 @@ (defun sx-tab-switch (tab) "Switch to another question-list tab." (interactive - (list (funcall (if ido-mode #'ido-completing-read #'completing-read) - "Switch to tab: " sx-tab--list - (lambda (tab) (not (equal tab sx-question-list--current-tab))) - t))) + (list (sx-completing-read + "Switch to tab: " sx-tab--list + (lambda (tab) (not (equal tab sx-question-list--current-tab))) + t))) (funcall (intern (format "sx-tab-%s" (downcase tab))))) @@ -189,5 +189,80 @@ If SITE is nil, use `sx-default-site'." (file-name-directory load-file-name))) nil t) + +;;; Unanswered +(sx-tab--define "Unanswered" + (lambda (page) + (sx-question-get-questions + sx-question-list--site page nil 'unanswered))) +;;;###autoload +(autoload 'sx-tab-unanswered + (expand-file-name + "sx-tab" + (when load-file-name + (file-name-directory load-file-name))) + nil t) + + +;;; Unanswered My-tags +(sx-tab--define "Unanswered-my-tags" + (lambda (page) + (sx-question-get-questions + sx-question-list--site page nil 'unanswered/my-tags))) +;;;###autoload +(autoload 'sx-tab-unanswered + (expand-file-name + "sx-tab" + (when load-file-name + (file-name-directory load-file-name))) + nil t) + + +;;; Featured +(sx-tab--define "Featured" + (lambda (page) + (sx-question-get-questions + sx-question-list--site page nil 'featured))) +;;;###autoload +(autoload 'sx-tab-featured + (expand-file-name + "sx-tab" + (when load-file-name + (file-name-directory load-file-name))) + nil t) + + +;;; Starred +(sx-tab--define "Starred" + (lambda (page) + (sx-method-call 'me + :page page + :site sx-question-list--site + :auth t + :submethod 'favorites + :filter sx-browse-filter))) +;;;###autoload +(autoload 'sx-tab-featured + (expand-file-name + "sx-tab" + (when load-file-name + (file-name-directory load-file-name))) + nil t) + + +;;; Inter-modes navigation +(defun sx-tab-meta-or-main () + "Switch to the meta version of a main site, or vice-versa. +Inside a question, go to the frontpage of the site this question +belongs to." + (interactive) + (if (and (derived-mode-p 'sx-question-list-mode) + sx-question-list--site) + (sx-question-list-switch-site + (if (string-match "\\`meta\\." sx-question-list--site) + (replace-match "" :fixedcase nil sx-question-list--site) + (concat "meta." sx-question-list--site))) + (sx-tab-frontpage nil (sx--site (sx--data-here 'question))))) + (provide 'sx-tab) ;;; sx-tab.el ends here diff --git a/sx-tag.el b/sx-tag.el new file mode 100644 index 0000000..8c468a6 --- /dev/null +++ b/sx-tag.el @@ -0,0 +1,86 @@ +;;; sx-tag.el --- Retrieving list of tags and handling tags. -*- lexical-binding: t; -*- + +;; 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: +(eval-when-compile + '(require 'cl-lib)) + +(require 'sx) +(require 'sx-method) + + +;;; Getting the list from a site +(defvar sx-tag-filter + (sx-filter-from-nil + (tag.name + tag.synonyms)) + "Filter used when querying tags.") + +(defun sx-tag--get-all (site &optional no-synonyms) + "Retrieve all tags for SITE. +If NO-SYNONYMS is non-nil, don't return synonyms." + (cl-reduce + (lambda (so-far tag) + (let-alist tag + (cons .name + (if no-synonyms so-far + (append .synonyms so-far))))) + (sx-method-call 'tags + :get-all t + :filter sx-tag-filter + :site site) + :initial-value nil)) + +(defun sx-tag--get-some-tags-containing (site string) + "Return at most 100 tags for SITE containing STRING. +Returns an array." + (sx-method-call 'tags + :auth nil + :filter sx-tag-filter + :site site + :keywords `((inname . ,string)))) + +(defun sx-tag--get-some-tag-names-containing (site string) + "Return at most 100 tag names for SITE containing STRING. +Returns a list." + (mapcar (lambda (x) (cdr (assoc 'name x))) + (sx-tag--get-some-tags-containing site string))) + + +;;; Check tag validity +(defun sx-tag--invalid-name-p (site tags) + "Nil if TAGS exist in SITE. +TAGS can be a string (the tag name) or a list of strings. +Fails if TAGS is a list with more than 100 items. +Return the list of invalid tags in TAGS." + (and (listp tags) (> (length tags) 100) + (error "Invalid argument. TAG has more than 100 items")) + (let ((result + (mapcar + (lambda (x) (cdr (assoc 'name x))) + (sx-method-call 'tags + :id (sx--thing-as-string tags) + :submethod 'info + :auth nil + :filter sx-tag-filter + :site site)))) + (cl-remove-if (lambda (x) (member x result)) tags))) + +(provide 'sx-tag) +;;; sx-tag.el ends here @@ -1,4 +1,4 @@ -;;; sx-time.el --- time +;;; sx-time.el --- time -*- lexical-binding: t; -*- ;; Copyright (C) 2014 Sean Allred @@ -6,7 +6,7 @@ ;; URL: https://github.com/vermiculus/sx.el/ ;; Version: 0.1 ;; Keywords: help, hypermedia, tools -;; Package-Requires: ((emacs "24.1") (cl-lib "0.5") (json "1.3") (markdown-mode "2.0") (let-alist "1.0.1")) +;; Package-Requires: ((emacs "24.1") (cl-lib "0.5") (json "1.3") (markdown-mode "2.0") (let-alist "1.0.3")) ;; 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 @@ -74,13 +74,18 @@ DATA can also be the link itself." "\\1\\2" link)))) (defun sx--ensure-site (data) - "Add a `site' property to DATA if it doesn't have one. Return DATA. + "Add a `site' property to DATA if it doesn't have one. Return DATA. DATA can be a question, answer, comment, or user (or any object with a `link' property)." (when data - (unless (assq 'site data) - (setcdr data (cons (cons 'site (sx--site data)) - (cdr data)))) + (let-alist data + (unless .site_par + ;; @TODO: Change this to .site.api_site_parameter sometime + ;; after February. + (setcdr data (cons (cons 'site_par + (or (cdr (assq 'api_site_parameter .site)) + (sx--site data))) + (cdr data))))) data)) (defun sx--link-to-data (link) @@ -131,8 +136,29 @@ with a `link' property)." result)) result)) +(defun sx--tree-paths (tree) + "Return a list of all paths in TREE. +Adapted from http://stackoverflow.com/q/3019250." + (if (atom tree) + (list (list tree)) + (apply #'append + (mapcar (lambda (node) + (mapcar (lambda (path) + (cons (car tree) path)) + (sx--tree-paths node))) + (cdr tree))))) + +(defun sx--tree-expand (path-func tree) + "Apply PATH-FUNC to every path in TREE. +Return the result. See `sx--tree-paths'." + (mapcar path-func + (apply #'append + (mapcar #'sx--tree-paths + tree)))) + (defmacro sx-assoc-let (alist &rest body) - "Identical to `let-alist', except `.site' has a special meaning. + "Use ALIST with `let-alist' to execute BODY. +`.site_par' has a special meaning, thanks to `sx--ensure-site'. If ALIST doesn't have a `site' property, one is created using the `link' property." (declare (indent 1) (debug t)) @@ -143,45 +169,30 @@ If ALIST doesn't have a `site' property, one is created using the `(let-alist ,alist ,@body)))) -;;; Browsing filter -(defvar sx-browse-filter - '((question.body_markdown - question.comments - question.answers - question.last_editor - question.accepted_answer_id - question.link - question.upvoted - question.downvoted - question.question_id - question.share_link - user.display_name - comment.owner - comment.body_markdown - comment.body - comment.link - comment.edited - comment.creation_date - comment.upvoted - comment.score - comment.post_type - comment.post_id - comment.comment_id - answer.answer_id - answer.last_editor - answer.link - answer.share_link - answer.owner - answer.body_markdown - answer.upvoted - answer.downvoted - answer.comments) - (user.profile_image shallow_user.profile_image)) - "The filter applied when retrieving question data. -See `sx-question-get-questions' and `sx-question-get-question'.") - - ;;; Utility Functions +(defun sx-completing-read (&rest args) + "Like `completing-read', but possibly use ido. +All ARGS are passed to `completing-read' or `ido-completing-read'." + (apply (if ido-mode #'ido-completing-read #'completing-read) + args)) + +(defun sx--multiple-read (prompt hist-var) + "Interactively query the user for a list of strings. +Call `read-string' multiple times, until the input is empty. + +PROMPT is a string displayed to the user and should not end with +a space nor a colon. HIST-VAR is a quoted symbol, indicating a +list in which to store input history." + (let (list input) + (while (not (string= + "" + (setq input (read-string + (concat prompt " [" + (mapconcat #'identity list ",") + "]: ") + "" hist-var)))) + (push input list)) + list)) (defmacro sx-sorted-insert-skip-first (newelt list &optional predicate) "Inserted NEWELT into LIST sorted by PREDICATE. @@ -310,7 +321,7 @@ Return the result of BODY." ("ĥ" . "h") ("ĵ" . "j") ("^[:ascii:]" . "")) - "List of replacements to use for non-ascii characters + "List of replacements to use for non-ascii characters. Used to convert user names into @mentions.") (defun sx--user-@name (user) diff --git a/test/data-samples/inbox-item.el b/test/data-samples/inbox-item.el new file mode 100644 index 0000000..faeba12 --- /dev/null +++ b/test/data-samples/inbox-item.el @@ -0,0 +1,13 @@ +((title . "Can I mark inbox items as read in api v2.2?") + (link . "http://stackapps.com/posts/comments/12080?noredirect=1") + (item_type . "comment") + (question_id . 5059) + (comment_id . 12080) + (creation_date . 1419153905) + (is_unread . :json-false) + (site (site_type . "main_site") + (name . "Stack Apps") + (api_site_parameter . "stackapps") + (site_url . "http://stackapps.com") + (favicon_url . "http://cdn.sstatic.net/stackapps/img/favicon.ico") + (styling (link_color . "#0077DD") (tag_foreground_color . "#555555") (tag_background_color . "#E7ECEC")))) diff --git a/test/test-api.el b/test/test-api.el index ca775ff..b7d5dbb 100644 --- a/test/test-api.el +++ b/test/test-api.el @@ -11,3 +11,6 @@ (should-error (sx-request-make "questions" '(())))) +(ert-deftest test-method-get-all () + "Tests sx-method interface to `sx-request-all-items'" + (should (< 250 (length (sx-method-call 'sites :get-all t))))) diff --git a/test/test-macros.el b/test/test-macros.el index 2169088..1634603 100644 --- a/test/test-macros.el +++ b/test/test-macros.el @@ -9,14 +9,36 @@ "Test `sx-assoc-let'" (sx-test-with-json-data data (should - (null (let-alist data .site)))) + (null (let-alist data .site_par)))) (sx-test-with-json-data data (should - (equal (sx-assoc-let data .site) + (equal (sx-assoc-let data .site_par) "meta.emacs"))) (sx-test-with-json-data data (should (equal (sx-assoc-let data (cons .test-one .test-two)) '(1 . 2))))) + +(ert-deftest macro-test--sx-filter-from-nil () + "Test `sx-filter-from-nil'" + (should + (equal + (sx-filter-from-nil + (one two (three four five) (six seven) + (a b c d e))) + '((one two three.four three.five six.seven + a.b a.c a.d a.e + .backoff + .error_id + .error_message + .error_name + .has_more + .items + .page + .page_size + .quota_max + .quota_remaining + .total) + nil none)))) diff --git a/test/test-printing.el b/test/test-printing.el index 60382b2..29c209d 100644 --- a/test/test-printing.el +++ b/test/test-printing.el @@ -73,7 +73,7 @@ after being run through `sx-question--tag-format'." 2 1 "autocomplete" "performance" "ctags")))) (ert-deftest sx--user-@name () - "Test macro expansion for `sx-assoc-let'" + "Test `sx--user-@name' character substitution" (should (string= (sx--user-@name '((display_name . "ĥÞßđłřğĝýÿñńśşšŝżźžçćčĉùúûüŭůòóôõöøőðìíîïıèéêëęàåáâäãåąĵ★"))) diff --git a/test/test-search.el b/test/test-search.el new file mode 100644 index 0000000..72f0846 --- /dev/null +++ b/test/test-search.el @@ -0,0 +1,53 @@ +(defmacro test-with-bogus-string (cell &rest body) + "Let-bind a bogus string to CELL and execute BODY." + (declare (indent 1)) + `(let ((,cell "E7631BCF-A94B-4507-8F0C-02CFB3207F55")) + ,@body)) + + +(ert-deftest test-search-basic () + "Test basic search functionality" + (should + (sx-search-get-questions + "emacs" 1 "emacs"))) + +(ert-deftest test-search-empty () + "Test bogus search returns empty vector" + (test-with-bogus-string query + (should + (equal + [] + (sx-search-get-questions "emacs" 1 query))))) + +(ert-deftest test-search-invalid () + "Test invalid search" + (should-error + ;; @todo: test the interactive call + (sx-search + "emacs" nil nil ["emacs"]))) + +(ert-deftest test-search-full-page () + "Test retrieval of the full search page" + (should + (= 100 (length (sx-search-get-questions + "stackoverflow" 1 "jquery"))))) + +(ert-deftest test-search-exclude-tags () + "Test excluding tags from a search" + (should + (cl-every + (lambda (p) + (sx-assoc-let p + (not (member "org-export" .tags)))) + (sx-search-get-questions + "emacs" 1 nil "org-mode" "org-export"))) + (should + (cl-every + (lambda (p) + (sx-assoc-let p + (not (or (member "org-export" .tags) + (member "org-agenda" .tags))))) + (sx-search-get-questions + "emacs" 1 nil "org-mode" + ["org-export" "org-agenda"])))) + diff --git a/test/test-state.el b/test/test-state.el new file mode 100644 index 0000000..7af4a64 --- /dev/null +++ b/test/test-state.el @@ -0,0 +1,22 @@ +(defmacro with-question-data (cell id &rest body) + (declare (indent 2)) + `(let ((,cell '((question_id . ,id) + (site_par . "emacs") + (last_activity_date . 1234123456)))) + ,@body)) + +(ert-deftest test-question-mark-read () + "00ccd139248e782cd8316eff65c26aed838c7e46" + (with-question-data q 10 + ;; Check basic logic. + (should (sx-question--mark-read q)) + (should (sx-question--read-p q)) + (should (not (setcdr (assq 10 (cdr (assoc "emacs" sx-question--user-read-list))) nil))) + ;; Don't freak out because the cdr was nil. + (should (not (sx-question--read-p q))) + (should (sx-question--mark-read q))) + (should + (with-question-data q nil + ;; Don't freak out because question_id was nil. + (sx-question--mark-read q)))) + diff --git a/test/test-util.el b/test/test-util.el index 5db1691..1e3dc2b 100644 --- a/test/test-util.el +++ b/test/test-util.el @@ -29,3 +29,17 @@ (string= (sx--thing-as-string 'test& nil t) "test%26"))) + +(ert-deftest tree () + (should + (equal + (sx--tree-expand + (lambda (path) (mapconcat #'symbol-name path ".")) + '(a b (c d (e f g) h i (j k) l) m (n o) p)) + '("a" "b" "c.d" "c.e.f" "c.e.g" "c.h" "c.i" "c.j.k" "c.l" "m" "n.o" "p"))) + (should + (equal + (sx--tree-expand + (lambda (path) (intern (mapconcat #'symbol-name path "/"))) + '(a b (c d (e f g) h i (j k) l) m (n o) p)) + '(a b c/d c/e/f c/e/g c/h c/i c/j/k c/l m n/o p)))) diff --git a/test/tests.el b/test/tests.el index 7bfc86c..5eec57c 100644 --- a/test/tests.el +++ b/test/tests.el @@ -11,6 +11,7 @@ sx-initialized t sx-request-remaining-api-requests-message-threshold 50000 debug-on-error t + url-show-status nil user-emacs-directory "." sx-test-base-dir (file-name-directory (or load-file-name "./"))) @@ -55,10 +56,10 @@ (defun sx-test-message (message &rest args) (when sx-test-enable-messages - (apply #'message (cons message args)))) + (apply #'message message args))) (mapc #'sx-load-test - '(api macros printing util)) + '(api macros printing util search)) (ert-deftest user-entry-functions () "Ensures all entry functions are autoloaded." |