aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSean Allred <code@seanallred.com>2015-01-04 16:23:34 -0500
committerSean Allred <code@seanallred.com>2015-01-04 16:23:34 -0500
commit671053bf0824197fefe742ed9dd98c1b9a06565a (patch)
tree3f457adffd80d7c180b52e936cef3698b067f28b
parent3b275a93789a568d23ece65086ffebb8de430f3a (diff)
parent3c05aae9915976e749591600f6e8f59cbccef1a4 (diff)
Merge branch 'master' into more-tests
Conflicts: Makefile test/tests.el
-rw-r--r--.agignore5
-rw-r--r--.travis.yml4
-rw-r--r--Makefile2
-rw-r--r--bot/sx-bot.el82
-rwxr-xr-xbot/sx-bot.sh36
-rw-r--r--sx-cache.el2
-rw-r--r--sx-compose.el33
-rw-r--r--sx-encoding.el2
-rw-r--r--sx-favorites.el10
-rw-r--r--sx-filter.el66
-rw-r--r--sx-inbox.el216
-rw-r--r--sx-interaction.el127
-rw-r--r--sx-load.el7
-rw-r--r--sx-method.el52
-rw-r--r--sx-networks.el38
-rw-r--r--sx-notify.el86
-rw-r--r--sx-question-list.el54
-rw-r--r--sx-question-mode.el9
-rw-r--r--sx-question.el24
-rw-r--r--sx-request.el60
-rw-r--r--sx-search.el112
-rw-r--r--sx-site.el27
-rw-r--r--sx-switchto.el77
-rw-r--r--sx-tab.el83
-rw-r--r--sx-tag.el86
-rw-r--r--sx-time.el2
-rw-r--r--sx.el101
-rw-r--r--test/data-samples/inbox-item.el13
-rw-r--r--test/test-api.el3
-rw-r--r--test/test-macros.el26
-rw-r--r--test/test-printing.el2
-rw-r--r--test/test-search.el53
-rw-r--r--test/test-state.el22
-rw-r--r--test/test-util.el14
-rw-r--r--test/tests.el5
35 files changed, 1351 insertions, 190 deletions
diff --git a/.agignore b/.agignore
index e00db68..3f11419 100644
--- a/.agignore
+++ b/.agignore
@@ -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
diff --git a/Makefile b/Makefile
index a72bea1..ae11f84 100644
--- a/Makefile
+++ b/Makefile
@@ -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
diff --git a/sx-load.el b/sx-load.el
index d71b8ed..f1ec7c3 100644
--- a/sx-load.el
+++ b/sx-load.el
@@ -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
diff --git a/sx-site.el b/sx-site.el
index 8bd4fc0..2f0a31d 100644
--- a/sx-site.el
+++ b/sx-site.el
@@ -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:
diff --git a/sx-tab.el b/sx-tab.el
index 6c5e21e..f97119a 100644
--- a/sx-tab.el
+++ b/sx-tab.el
@@ -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
diff --git a/sx-time.el b/sx-time.el
index 3de124d..e05d95a 100644
--- a/sx-time.el
+++ b/sx-time.el
@@ -1,4 +1,4 @@
-;;; sx-time.el --- time
+;;; sx-time.el --- time -*- lexical-binding: t; -*-
;; Copyright (C) 2014 Sean Allred
diff --git a/sx.el b/sx.el
index 73d1a40..f77b313 100644
--- a/sx.el
+++ b/sx.el
@@ -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."