aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--sx-inbox.el217
-rw-r--r--sx-interaction.el79
-rw-r--r--sx-load.el2
-rw-r--r--sx-notify.el86
-rw-r--r--sx-question-list.el6
-rw-r--r--sx-question-mode.el4
-rw-r--r--sx-question.el32
-rw-r--r--sx.el25
-rw-r--r--test/data-samples/inbox-item.el13
9 files changed, 426 insertions, 38 deletions
diff --git a/sx-inbox.el b/sx-inbox.el
new file mode 100644
index 0000000..07453d4
--- /dev/null
+++ b/sx-inbox.el
@@ -0,0 +1,217 @@
+;;; 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 " .title "\n"
+ (let ((col fill-column))
+ (with-temp-buffer
+ (setq fill-column col)
+ (insert " " .body)
+ (fill-region (point-min) (point-max))
+ (propertize (buffer-string)
+ 'face 'font-lock-function-name-face))))
+ '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 c6f2639..619f259 100644
--- a/sx-interaction.el
+++ b/sx-interaction.el
@@ -107,7 +107,7 @@ Only fields contained in TO are copied."
;;; Visiting
-(defun sx-visit (data &optional copy-as-kill)
+(defun sx-visit-externally (data &optional copy-as-kill)
"Visit DATA in a web browser.
DATA can be a question, answer, or comment. Interactively, it is
derived from point position.
@@ -119,27 +119,64 @@ 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.
+Element can be a question, answer, or comment."
+ (interactive
+ (let ((def (with-temp-buffer
+ (save-excursion (yank))
+ (thing-at-point 'url))))
+ (list (read-string (concat "Link (" def "): ") nil nil def))))
+ (let ((data (sx--link-to-data link)))
+ (sx-assoc-let data
+ (cl-case .type
+ (answer
+ (sx-display-question
+ (sx-question-get-from-answer .site_par .id) 'focus))
+ (question
+ (sx-display-question
+ (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.
+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.
@@ -188,7 +225,7 @@ changes."
:auth 'warn
: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)
@@ -229,14 +266,14 @@ TEXT is a string. Interactively, it is read from the minibufer."
:auth 'warn
: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)))))
@@ -255,13 +292,13 @@ If SILENT is nil, message the user about this limit."
(defun sx--get-post (type site id)
"Find in the database a post identified by TYPE, SITE and ID.
-TYPE is `question' or `answer'.
+TYPE is `question' or `answer'.
SITE is a string.
ID is an integer."
(let ((db (cons sx-question-mode--data
sx-question-list--dataset)))
(setq db
- (cond
+ (cond
((string= type "question") db)
((string= type "answer")
(apply #'cl-map 'list #'identity
@@ -269,7 +306,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)
@@ -302,7 +339,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
@@ -320,7 +357,7 @@ 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)
@@ -354,7 +391,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..481dba3 100644
--- a/sx-load.el
+++ b/sx-load.el
@@ -31,9 +31,11 @@
sx-encoding
sx-favorites
sx-filter
+ sx-inbox
sx-interaction
sx-method
sx-networks
+ sx-notify
sx-question
sx-question-list
sx-question-mode
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 6537d2b..4f71251 100644
--- a/sx-question-list.el
+++ b/sx-question-list.el
@@ -127,7 +127,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
@@ -317,12 +317,12 @@ into consideration.
(":" sx-question-list-switch-site)
("t" sx-tab-switch)
("a" sx-ask)
- ("v" sx-visit)
+ ("v" sx-visit-externally)
("u" sx-toggle-upvote)
("d" sx-toggle-downvote)
("h" sx-question-list-hide)
("m" sx-question-list-mark-read)
- ([?\r] sx-display-question)
+ ([?\r] sx-display)
))
(defun sx-question-list-hide (data)
diff --git a/sx-question-mode.el b/sx-question-mode.el
index 8d06078..7d61167 100644
--- a/sx-question-mode.el
+++ b/sx-question-mode.el
@@ -224,7 +224,7 @@ Letters do not insert themselves; instead, they are commands.
("p" sx-question-mode-previous-section)
("g" sx-question-mode-refresh)
("c" sx-comment)
- ("v" sx-visit)
+ ("v" sx-visit-externally)
("u" sx-toggle-upvote)
("d" sx-toggle-downvote)
("q" quit-window)
@@ -254,7 +254,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 9fb31fc..3fcc438 100644
--- a/sx-question.el
+++ b/sx-question.el
@@ -54,6 +54,20 @@ If QUESTION-ID doesn't exist on SITE, raise an error."
(error "Couldn't find question %S in %S"
question-id site))))
+(defun sx-question-get-from-answer (site answer-id)
+ "Get question from SITE to which ANSWER-ID belongs.
+If ANSWER-ID doesn't exist on SITE, raise an error."
+ (let ((res (sx-method-call 'answers
+ :id answer-id
+ :site site
+ :submethod 'questions
+ :auth t
+ :filter sx-browse-filter)))
+ (if (vectorp res)
+ (elt res 0)
+ (error "Couldn't find answer %S in %S"
+ answer-id site))))
+
;;; Question Properties
@@ -80,8 +94,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)))))
@@ -93,14 +107,14 @@ 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.
@@ -135,18 +149,18 @@ 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)
;; 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)
;; Question wasn't present.
;; Add it in, but make sure it's sorted (just in case we need
;; it later).
diff --git a/sx.el b/sx.el
index 6f4e7c7..1b15ad3 100644
--- a/sx.el
+++ b/sx.el
@@ -303,9 +303,12 @@ DATA can also be the link itself."
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
+ (setcdr data (cons (cons 'site_par
+ (or .site.api_site_parameter
+ (sx--site data)))
+ (cdr data)))))
data))
(defmacro sx-assoc-let (alist &rest body)
@@ -318,6 +321,22 @@ If ALIST doesn't have a `site' property, one is created using the
(sx--ensure-site ,alist)
(let-alist ,alist ,@body)))
+(defun sx--link-to-data (link)
+ "Convert string LINK into data that can be displayed."
+ (let ((result (list (cons 'site_par (sx--site link)))))
+ (when (or
+ ;; Answer
+ (and (or (string-match "/a/\\([0-9]+\\)/[0-9]+\\(#.*\\|\\)\\'" link)
+ (string-match "/questions/[0-9]+/[^/]+/\\([0-9]\\)/?\\(#.*\\|\\)\\'" link))
+ (push (cons 'type 'answer) result))
+ ;; Question
+ (and (or (string-match "/q/\\([0-9]+\\)/[0-9]+\\(#.*\\|\\)\\'" link)
+ (string-match "/questions/\\([0-9]+\\)/" link))
+ (push (cons 'type 'question) result)))
+ (push (cons 'id (string-to-number (match-string-no-properties 1 link)))
+ result))
+ result))
+
(defcustom sx-init-hook nil
"Hook run when SX initializes.
Run after `sx-init--internal-hook'."
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"))))